From 6c3e6fb8b0438461af1dbe79b19403ae0dec0e60 Mon Sep 17 00:00:00 2001 From: boxdot Date: Mon, 27 Jan 2025 17:11:32 +0100 Subject: [PATCH] chore: add snaphot tests in CI Tests are added for the following widgets: - `HomeScreen` (desktop layout) - `ConverstationList` - `ConversationListContent` - `Conversation` - `MessageList` By default, snapshots are created with loaded fonts, on physical screen size of a Pixel 8 and pixel density of 3. The snapshot tests tolerance is 0.022 for ignoring the differences in rendering on different platforms. The snapshots are stored in git LFS. The tests are run in CI on macOS and Linux runners. The line height of a message text is now set to default, because otherwise it creates inconsistent rendering on different platforms. --- .gitattributes | 5 + .github/workflows/flutter_test.yml | 9 + .github/workflows/ios_build.yml | 12 ++ .../conversation_screen.dart | 45 +++- .../conversation_list_content.dart | 42 +--- .../conversation_list_view.dart | 7 +- app/lib/home_screen.dart | 35 ++- app/lib/message_list/message_list.dart | 2 + app/lib/message_list/message_list_view.dart | 100 ++++----- app/lib/theme/styles.dart | 4 +- app/pubspec.lock | 24 +++ app/pubspec.yaml | 2 + .../conversation_screen_view_test.dart | 121 +++++++++++ .../goldens/conversation_screen.png | 3 + .../goldens/conversation_screen_empty.png | 3 + .../conversation_list_content_test.dart | 182 ++++++++++++++++ .../conversation_list_test.dart | 89 ++++++++ .../goldens/conversation_list.png | 3 + .../goldens/conversation_list_content.png | 3 + .../conversation_list_content_empty.png | 3 + .../goldens/conversation_list_empty.png | 3 + app/test/flutter_test_config.dart | 80 +++++++ app/test/goldens/home_screen_desktop.png | 3 + .../goldens/home_screen_desktop_empty.png | 3 + .../home_screen_desktop_no_conversation.png | 3 + app/test/helpers.dart | 31 +++ app/test/home_screen_test.dart | 164 ++++++++++++++ .../message_list/goldens/message_list.png | 3 + .../goldens/message_list_empty.png | 3 + app/test/message_list/message_list_test.dart | 200 ++++++++++++++++++ app/test/mocks.dart | 91 ++++++++ app/test/widget_test.dart | 12 -- 32 files changed, 1159 insertions(+), 131 deletions(-) create mode 100644 .gitattributes create mode 100644 app/test/conversation/conversation_screen_view_test.dart create mode 100644 app/test/conversation/goldens/conversation_screen.png create mode 100644 app/test/conversation/goldens/conversation_screen_empty.png create mode 100644 app/test/conversation_list/conversation_list_content_test.dart create mode 100644 app/test/conversation_list/conversation_list_test.dart create mode 100644 app/test/conversation_list/goldens/conversation_list.png create mode 100644 app/test/conversation_list/goldens/conversation_list_content.png create mode 100644 app/test/conversation_list/goldens/conversation_list_content_empty.png create mode 100644 app/test/conversation_list/goldens/conversation_list_empty.png create mode 100644 app/test/flutter_test_config.dart create mode 100644 app/test/goldens/home_screen_desktop.png create mode 100644 app/test/goldens/home_screen_desktop_empty.png create mode 100644 app/test/goldens/home_screen_desktop_no_conversation.png create mode 100644 app/test/helpers.dart create mode 100644 app/test/home_screen_test.dart create mode 100644 app/test/message_list/goldens/message_list.png create mode 100644 app/test/message_list/goldens/message_list_empty.png create mode 100644 app/test/message_list/message_list_test.dart create mode 100644 app/test/mocks.dart delete mode 100644 app/test/widget_test.dart diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..3b19089e --- /dev/null +++ b/.gitattributes @@ -0,0 +1,5 @@ +# SPDX-FileCopyrightText: 2025 Phoenix R&D GmbH +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +**/goldens/** filter=lfs diff=lfs merge=lfs -text diff --git a/.github/workflows/flutter_test.yml b/.github/workflows/flutter_test.yml index af6e6ec0..47d355cf 100644 --- a/.github/workflows/flutter_test.yml +++ b/.github/workflows/flutter_test.yml @@ -22,6 +22,8 @@ jobs: steps: - name: Clone repository uses: actions/checkout@v4 + with: + lfs: true - name: Set up Just uses: extractions/setup-just@v2 @@ -50,3 +52,10 @@ jobs: - name: Run Flutter tests run: just test-flutter + + - name: Upload golden failures + if: failure() + uses: actions/upload-artifact@v4 + with: + name: golden-failures + path: app/test/**/failures/* diff --git a/.github/workflows/ios_build.yml b/.github/workflows/ios_build.yml index e9530e13..6f76de47 100644 --- a/.github/workflows/ios_build.yml +++ b/.github/workflows/ios_build.yml @@ -19,6 +19,8 @@ jobs: steps: - name: Checkout code uses: actions/checkout@v4 + with: + lfs: true - name: Set up Just uses: extractions/setup-just@v2 @@ -47,6 +49,16 @@ jobs: - name: Generate Dart files run: just generate-dart-files + - name: Test flutter + run: just test-flutter + + - name: Upload golden failures + if: failure() + uses: actions/upload-artifact@v4 + with: + name: golden-failures + path: app/test/**/failures/* + - name: Build iOS app env: APP_STORE_KEY_ID: ${{ secrets.APP_STORE_KEY_ID }} diff --git a/app/lib/conversation_details/conversation_screen.dart b/app/lib/conversation_details/conversation_screen.dart index 7eaff4a4..7c038727 100644 --- a/app/lib/conversation_details/conversation_screen.dart +++ b/app/lib/conversation_details/conversation_screen.dart @@ -8,6 +8,7 @@ import 'package:prototype/core/core.dart'; import 'package:prototype/message_list/message_list.dart'; import 'package:prototype/navigation/navigation.dart'; import 'package:prototype/theme/theme.dart'; +import 'package:prototype/user/user.dart'; import 'package:prototype/widgets/widgets.dart'; import 'conversation_details_cubit.dart'; @@ -24,13 +25,25 @@ class ConversationScreen extends StatelessWidget { return const _EmptyConversationPane(); } - return BlocProvider( - // rebuilds the cubit when the conversation changes - key: ValueKey(conversationId), - create: (context) => ConversationDetailsCubit( - userCubit: context.read(), - conversationId: conversationId, - ), + return MultiBlocProvider( + providers: [ + BlocProvider( + // rebuilds the cubit when the conversation changes + key: ValueKey("conversation-detail-cubit-$conversationId"), + create: (context) => ConversationDetailsCubit( + userCubit: context.read(), + conversationId: conversationId, + ), + ), + BlocProvider( + // rebuilds the cubit when the conversation changes + key: ValueKey("message-list-cubit-$conversationId"), + create: (context) => MessageListCubit( + userCubit: context.read(), + conversationId: conversationId, + ), + ), + ], child: const ConversationScreenView(), ); } @@ -53,18 +66,32 @@ class _EmptyConversationPane extends StatelessWidget { } class ConversationScreenView extends StatelessWidget { - const ConversationScreenView({super.key}); + const ConversationScreenView({ + super.key, + this.createMessageCubit = MessageCubit.new, + }); + + final MessageCubitCreate createMessageCubit; @override Widget build(BuildContext context) { + final conversationId = + context.select((NavigationCubit cubit) => cubit.state.conversationId); + final conversationTitle = context.select( (ConversationDetailsCubit cubit) => cubit.state.conversation?.title); + if (conversationId == null) { + return const _EmptyConversationPane(); + } + return Scaffold( body: Stack(children: [ Column( children: [ - const MessageListContainer(), + Expanded( + child: MessageListView(createMessageCubit: createMessageCubit), + ), const MessageComposer(), ], ), diff --git a/app/lib/conversation_list/conversation_list_content.dart b/app/lib/conversation_list/conversation_list_content.dart index 6aa12e6e..82bd078c 100644 --- a/app/lib/conversation_list/conversation_list_content.dart +++ b/app/lib/conversation_list/conversation_list_content.dart @@ -65,6 +65,7 @@ class _ListTile extends StatelessWidget { Widget build(BuildContext context) { final currentConversationId = context.select((NavigationCubit cubit) => cubit.state.conversationId); + final isSelected = currentConversationId == conversation.id; return ListTile( horizontalTitleGap: 0, contentPadding: const EdgeInsets.symmetric( @@ -79,11 +80,7 @@ class _ListTile extends StatelessWidget { padding: const EdgeInsets.all(10), decoration: BoxDecoration( borderRadius: BorderRadius.circular(10), - color: _selectionColor( - context, - conversation.id, - currentConversationId, - ), + color: isSelected ? convPaneFocusColor : null, ), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, @@ -110,31 +107,12 @@ class _ListTile extends StatelessWidget { ], ), ), - selected: _isConversationSelected( - conversation.id, - currentConversationId, - context, - ), + selected: isSelected, focusColor: convListItemSelectedColor, - onTap: () => _onSelectConversation(context, conversation.id), + onTap: () => + context.read().openConversation(conversation.id), ); } - - void _onSelectConversation( - BuildContext context, - ConversationId conversationId, - ) { - context.read().openConversation(conversationId); - } - - Color? _selectionColor( - BuildContext context, - ConversationId conversationId, - ConversationId? currentConversationId, - ) => - isLargeScreen(context) && currentConversationId == conversationId - ? convPaneFocusColor - : null; } class _ListTileTop extends StatelessWidget { @@ -320,16 +298,6 @@ class _ConversationTitle extends StatelessWidget { } } -bool _isConversationSelected( - ConversationId conversationId, - ConversationId? currentConversationId, - BuildContext context, -) { - return isLargeScreen(context) - ? currentConversationId == conversationId - : false; -} - String formatTimestamp(String t, {DateTime? now}) { DateTime timestamp; try { diff --git a/app/lib/conversation_list/conversation_list_view.dart b/app/lib/conversation_list/conversation_list_view.dart index 4dd88be9..4600a040 100644 --- a/app/lib/conversation_list/conversation_list_view.dart +++ b/app/lib/conversation_list/conversation_list_view.dart @@ -17,11 +17,10 @@ class ConversationListContainer extends StatelessWidget { @override Widget build(BuildContext context) { - final userCubit = context.read(); return BlocProvider( - create: (context) { - return ConversationListCubit(userCubit: userCubit); - }, + create: (context) => ConversationListCubit( + userCubit: context.read(), + ), child: const ConversationListView(), ); } diff --git a/app/lib/home_screen.dart b/app/lib/home_screen.dart index b8e9e817..ff83af83 100644 --- a/app/lib/home_screen.dart +++ b/app/lib/home_screen.dart @@ -13,21 +13,40 @@ class HomeScreen extends StatelessWidget { @override Widget build(BuildContext context) { const mobileLayout = ConversationListContainer(); - const desktopLayout = Row( + const desktopLayout = HomeScreenDesktopLayout( + conversationList: ConversationListContainer(), + conversation: ConversationScreen(), + ); + return const ResponsiveScreen( + mobile: mobileLayout, + tablet: desktopLayout, + desktop: desktopLayout, + ); + } +} + +class HomeScreenDesktopLayout extends StatelessWidget { + const HomeScreenDesktopLayout({ + required this.conversationList, + required this.conversation, + super.key, + }); + + final Widget conversationList; + final Widget conversation; + + @override + Widget build(BuildContext context) { + return Row( children: [ SizedBox( width: 300, - child: ConversationListContainer(), + child: conversationList, ), Expanded( - child: ConversationScreen(), + child: conversation, ), ], ); - return const ResponsiveScreen( - mobile: mobileLayout, - tablet: desktopLayout, - desktop: desktopLayout, - ); } } diff --git a/app/lib/message_list/message_list.dart b/app/lib/message_list/message_list.dart index 8e92abcd..853157aa 100644 --- a/app/lib/message_list/message_list.dart +++ b/app/lib/message_list/message_list.dart @@ -7,3 +7,5 @@ library; export 'message_list_view.dart'; export 'message_composer.dart'; +export 'message_list_cubit.dart'; +export 'message_cubit.dart'; diff --git a/app/lib/message_list/message_list_view.dart b/app/lib/message_list/message_list_view.dart index a1f78577..e7ab3ab3 100644 --- a/app/lib/message_list/message_list_view.dart +++ b/app/lib/message_list/message_list_view.dart @@ -8,82 +8,62 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:prototype/conversation_details/conversation_details.dart'; import 'package:prototype/core/core.dart'; -import 'package:prototype/navigation/navigation.dart'; +import 'package:prototype/user/user.dart'; import 'package:visibility_detector/visibility_detector.dart'; import 'conversation_tile.dart'; import 'message_cubit.dart'; import 'message_list_cubit.dart'; -class MessageListContainer extends StatelessWidget { - const MessageListContainer({ - super.key, - }); - - @override - Widget build(BuildContext context) { - final conversationId = - context.select((NavigationCubit cubit) => cubit.state.conversationId); - - if (conversationId == null) { - throw StateError("an active conversation is obligatory"); - } - - return BlocProvider( - create: (context) => MessageListCubit( - userCubit: context.read(), - conversationId: conversationId, - ), - child: const MessageListView(), - ); - } -} +typedef MessageCubitCreate = MessageCubit Function({ + required UserCubit userCubit, + required MessageState initialState, +}); class MessageListView extends StatelessWidget { const MessageListView({ super.key, + this.createMessageCubit = MessageCubit.new, }); + final MessageCubitCreate createMessageCubit; + @override Widget build(BuildContext context) { final state = context.select((MessageListCubit cubit) => cubit.state); - return Expanded( - child: SelectionArea( - child: ListView.custom( - physics: _scrollPhysics, - reverse: true, - childrenDelegate: SliverChildBuilderDelegate( - (context, reverseIndex) { - final index = state.loadedMessagesCount - reverseIndex - 1; - final message = state.messageAt(index); - return message != null - ? BlocProvider( - key: ValueKey(message.id), - create: (context) { - return MessageCubit( - userCubit: context.read(), - initialState: MessageState(message: message), - ); - }, - child: _VisibilityConversationTile( - messageId: message.id, - timestamp: DateTime.parse(message.timestamp), - ), - ) - : const SizedBox.shrink(); - }, - findChildIndexCallback: (key) { - final messageKey = key as ValueKey; - final messageId = messageKey.value; - final index = state.messageIdIndex(messageId); - // reverse index - return index != null - ? state.loadedMessagesCount - index - 1 - : null; - }, - childCount: state.loadedMessagesCount, - ), + return SelectionArea( + child: ListView.custom( + physics: _scrollPhysics, + reverse: true, + childrenDelegate: SliverChildBuilderDelegate( + (context, reverseIndex) { + final index = state.loadedMessagesCount - reverseIndex - 1; + final message = state.messageAt(index); + return message != null + ? BlocProvider( + key: ValueKey(message.id), + create: (context) { + return createMessageCubit( + userCubit: context.read(), + initialState: MessageState(message: message), + ); + }, + child: _VisibilityConversationTile( + messageId: message.id, + timestamp: DateTime.parse(message.timestamp), + ), + ) + : const SizedBox.shrink(); + }, + findChildIndexCallback: (key) { + final messageKey = key as ValueKey; + final messageId = messageKey.value; + final index = state.messageIdIndex(messageId); + // reverse index + return index != null ? state.loadedMessagesCount - index - 1 : null; + }, + childCount: state.loadedMessagesCount, ), ), ); diff --git a/app/lib/theme/styles.dart b/app/lib/theme/styles.dart index f7b92fd9..e61fd9a0 100644 --- a/app/lib/theme/styles.dart +++ b/app/lib/theme/styles.dart @@ -116,7 +116,9 @@ TextStyle messageTextStyle(BuildContext context, bool inverted) => TextStyle( isLargeScreen(context) ? variationRegular : variationMedium, letterSpacing: -0.05, fontSize: isLargeScreen(context) ? 14 : 15, - height: isLargeScreen(context) ? 1.5 : 1.3, + // NOTE: When specifying line height, the text is rendered inconsistently on + // Linux and macOS (and therefore also on Android and iOS). For now, we use the default one. + // height: isLargeScreen(context) ? 1.5 : 1.3, ); final textInputBorder = OutlineInputBorder( diff --git a/app/pubspec.lock b/app/pubspec.lock index 14c14a1f..a586c081 100644 --- a/app/pubspec.lock +++ b/app/pubspec.lock @@ -54,6 +54,14 @@ packages: url: "https://pub.dev" source: hosted version: "8.1.4" + bloc_test: + dependency: "direct dev" + description: + name: bloc_test + sha256: "165a6ec950d9252ebe36dc5335f2e6eb13055f33d56db0eeb7642768849b43d2" + url: "https://pub.dev" + source: hosted + version: "9.1.7" boolean_selector: dependency: transitive description: @@ -222,6 +230,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.7" + diff_match_patch: + dependency: transitive + description: + name: diff_match_patch + sha256: "2efc9e6e8f449d0abe15be240e2c2a3bcd977c8d126cfd70598aee60af35c0a4" + url: "https://pub.dev" + source: hosted + version: "0.4.1" fake_async: dependency: transitive description: @@ -604,6 +620,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.0" + mocktail: + dependency: "direct dev" + description: + name: mocktail + sha256: "890df3f9688106f25755f26b1c60589a92b3ab91a22b8b224947ad041bf172d8" + url: "https://pub.dev" + source: hosted + version: "1.0.4" nested: dependency: transitive description: diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 8f8c1a63..4f185aee 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -55,6 +55,8 @@ dev_dependencies: flutter_driver: sdk: flutter test: ^1.25.7 + bloc_test: ^9.1.7 + mocktail: ^1.0.4 # For information on the generic Dart part of this file, see the # following page: https://dart.dev/tools/pub/pubspec diff --git a/app/test/conversation/conversation_screen_view_test.dart b/app/test/conversation/conversation_screen_view_test.dart new file mode 100644 index 00000000..3d8b93f0 --- /dev/null +++ b/app/test/conversation/conversation_screen_view_test.dart @@ -0,0 +1,121 @@ +// SPDX-FileCopyrightText: 2025 Phoenix R&D GmbH +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:prototype/conversation_details/conversation_details.dart'; +import 'package:prototype/core/core.dart'; +import 'package:prototype/message_list/message_list.dart'; +import 'package:prototype/navigation/navigation.dart'; +import 'package:prototype/theme/theme.dart'; +import 'package:prototype/user/user.dart'; +import 'package:visibility_detector/visibility_detector.dart'; + +import '../conversation_list/conversation_list_content_test.dart'; +import '../helpers.dart'; +import '../message_list/message_list_test.dart'; +import '../mocks.dart'; + +final conversation = conversations[2]; + +final members = [ + "alice@localhost", + "bob@localhost", + "eve@localhost", +]; + +void main() { + setUpAll(() { + registerFallbackValue(0.conversationMessageId()); + }); + + group('ConversationScreenView', () { + late MockNavigationCubit navigationCubit; + late MockUserCubit userCubit; + late MockConversationDetailsCubit conversationDetailsCubit; + late MockMessageListCubit messageListCubit; + + setUp(() async { + navigationCubit = MockNavigationCubit(); + userCubit = MockUserCubit(); + conversationDetailsCubit = MockConversationDetailsCubit(); + messageListCubit = MockMessageListCubit(); + + when(() => userCubit.state) + .thenReturn(MockUiUser(userName: "alice@localhost")); + when(() => userCubit.userProfile(any())) + .thenAnswer((_) => Future.value(null)); + when(() => conversationDetailsCubit.state).thenReturn( + ConversationDetailsState( + conversation: conversation, + members: members, + ), + ); + when(() => conversationDetailsCubit.markAsRead( + untilMessageId: any(named: "untilMessageId"), + untilTimestamp: any(named: "untilTimestamp"), + )).thenAnswer((_) => Future.value()); + }); + + Widget buildSubject() => MultiBlocProvider( + providers: [ + BlocProvider.value( + value: navigationCubit, + ), + BlocProvider.value( + value: userCubit, + ), + BlocProvider.value( + value: conversationDetailsCubit, + ), + BlocProvider.value( + value: messageListCubit, + ), + ], + child: Builder( + builder: (context) { + return MaterialApp( + debugShowCheckedModeBanner: false, + theme: themeData(context), + home: Scaffold( + body: ConversationScreenView( + createMessageCubit: createMockMessageCubit, + ), + ), + ); + }, + ), + ); + + testWidgets('renders correctly when empty', (tester) async { + when(() => navigationCubit.state).thenReturn(NavigationState.home()); + when(() => messageListCubit.state).thenReturn(MockMessageListState([])); + + await tester.pumpWidget(buildSubject()); + + await expectLater( + find.byType(MaterialApp), + matchesGoldenFile('goldens/conversation_screen_empty.png'), + ); + }); + + testWidgets('renders correctly', (tester) async { + when(() => navigationCubit.state) + .thenReturn(NavigationState.home(conversationId: conversation.id)); + when(() => messageListCubit.state) + .thenReturn(MockMessageListState(messages)); + + VisibilityDetectorController.instance.updateInterval = Duration.zero; + + await tester.pumpWidget(buildSubject()); + + await expectLater( + find.byType(MaterialApp), + matchesGoldenFile('goldens/conversation_screen.png'), + ); + }); + }); +} diff --git a/app/test/conversation/goldens/conversation_screen.png b/app/test/conversation/goldens/conversation_screen.png new file mode 100644 index 00000000..3a59a428 --- /dev/null +++ b/app/test/conversation/goldens/conversation_screen.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:db493ebebe751b9e12c5eea567a10d96fd984a45046dfaf867da58452caf0c95 +size 162766 diff --git a/app/test/conversation/goldens/conversation_screen_empty.png b/app/test/conversation/goldens/conversation_screen_empty.png new file mode 100644 index 00000000..462659f8 --- /dev/null +++ b/app/test/conversation/goldens/conversation_screen_empty.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c2f41aedc66f9c66ced479dc9cc97f12124f86e57c71f9e92c296a2092adc1fc +size 28977 diff --git a/app/test/conversation_list/conversation_list_content_test.dart b/app/test/conversation_list/conversation_list_content_test.dart new file mode 100644 index 00000000..72e39719 --- /dev/null +++ b/app/test/conversation_list/conversation_list_content_test.dart @@ -0,0 +1,182 @@ +// SPDX-FileCopyrightText: 2025 Phoenix R&D GmbH +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:prototype/conversation_list/conversation_list_content.dart'; +import 'package:prototype/conversation_list/conversation_list_cubit.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:prototype/core/core.dart'; +import 'package:prototype/navigation/navigation.dart'; +import 'package:prototype/theme/theme.dart'; +import 'package:prototype/user/user.dart'; + +import '../mocks.dart'; +import '../helpers.dart'; + +final conversations = [ + UiConversationDetails( + id: 1.conversationId(), + status: UiConversationStatus.active(), + conversationType: UiConversationType_Connection("bob@localhost"), + unreadMessages: 10, + messagesCount: 10, + attributes: UiConversationAttributes( + title: "Bob", + picture: null, + ), + lastUsed: "2023-01-01T00:00:00.000Z", + lastMessage: UiConversationMessage( + id: 1.conversationMessageId(), + conversationId: 1.conversationId(), + timestamp: '2023-01-01T00:00:00.000Z', + message: UiMessage_Content( + UiContentMessage( + sender: "bob@localhost", + sent: true, + content: UiMimiContent( + id: 1.messageId(), + timestamp: DateTime.parse("2023-01-01T00:00:00.000Z"), + lastSeen: [], + body: 'Hello Alice', + ), + ), + ), + position: UiFlightPosition.single, + ), + ), + UiConversationDetails( + id: 2.conversationId(), + status: UiConversationStatus.active(), + conversationType: UiConversationType_UnconfirmedConnection("eve@localhost"), + unreadMessages: 0, + messagesCount: 10, + attributes: UiConversationAttributes( + title: "Eve", + picture: null, + ), + lastUsed: "2023-01-01T00:00:00.000Z", + lastMessage: UiConversationMessage( + id: 2.conversationMessageId(), + conversationId: 2.conversationId(), + timestamp: '2023-01-01T00:00:00.000Z', + message: UiMessage_Content( + UiContentMessage( + sender: "eve@localhost", + sent: true, + content: UiMimiContent( + id: 2.messageId(), + timestamp: DateTime.parse("2023-01-01T00:00:00.000Z"), + lastSeen: [], + body: 'Hello Alice', + ), + ), + ), + position: UiFlightPosition.single, + ), + ), + UiConversationDetails( + id: 3.conversationId(), + status: UiConversationStatus.active(), + conversationType: UiConversationType_Group(), + unreadMessages: 0, + messagesCount: 10, + attributes: UiConversationAttributes( + title: "Group", + picture: null, + ), + lastUsed: "2023-01-01T00:00:00.000Z", + lastMessage: UiConversationMessage( + id: 3.conversationMessageId(), + conversationId: 3.conversationId(), + timestamp: '2023-01-01T00:00:00.000Z', + message: UiMessage_Content( + UiContentMessage( + sender: "somebody@localhost", + sent: true, + content: UiMimiContent( + id: 3.messageId(), + timestamp: DateTime.parse("2023-01-01T00:00:00.000Z"), + lastSeen: [], + body: 'Hello All', + ), + ), + ), + position: UiFlightPosition.single, + ), + ), +]; + +void main() { + group('ConversationListContent', () { + late MockNavigationCubit navigationCubit; + late MockConversationListCubit conversationListCubit; + late MockUserCubit userCubit; + + setUp(() async { + navigationCubit = MockNavigationCubit(); + userCubit = MockUserCubit(); + conversationListCubit = MockConversationListCubit(); + + when(() => navigationCubit.state).thenReturn(NavigationState.home()); + when(() => userCubit.state) + .thenReturn(MockUiUser(userName: "alice@localhost")); + }); + + Widget buildSubject() => MultiBlocProvider( + providers: [ + BlocProvider.value( + value: navigationCubit, + ), + BlocProvider.value( + value: userCubit, + ), + BlocProvider.value( + value: conversationListCubit, + ), + ], + child: Builder( + builder: (context) { + return MaterialApp( + debugShowCheckedModeBanner: false, + theme: themeData(context), + home: Scaffold(body: ConversationListContent()), + ); + }, + ), + ); + + testWidgets('renders correctly when there are no conversations', + (tester) async { + when(() => conversationListCubit.state) + .thenReturn(ConversationListState(conversations: [])); + + await tester.pumpWidget(buildSubject()); + + await expectLater( + find.byType(MaterialApp), + matchesGoldenFile('goldens/conversation_list_content_empty.png'), + ); + }); + + testWidgets('renders correctly', (tester) async { + when(() => navigationCubit.state).thenReturn( + NavigationState.home(conversationId: conversations[1].id)); + when(() => conversationListCubit.state).thenReturn( + ConversationListState( + conversations: List.generate( + 20, (index) => conversations[index % conversations.length]), + ), + ); + + await tester.pumpWidget(buildSubject()); + + await expectLater( + find.byType(MaterialApp), + matchesGoldenFile('goldens/conversation_list_content.png'), + ); + }); + }); +} diff --git a/app/test/conversation_list/conversation_list_test.dart b/app/test/conversation_list/conversation_list_test.dart new file mode 100644 index 00000000..9a24f57c --- /dev/null +++ b/app/test/conversation_list/conversation_list_test.dart @@ -0,0 +1,89 @@ +// SPDX-FileCopyrightText: 2025 Phoenix R&D GmbH +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:prototype/conversation_list/conversation_list.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:prototype/conversation_list/conversation_list_cubit.dart'; +import 'package:prototype/core/core.dart'; +import 'package:prototype/navigation/navigation.dart'; +import 'package:prototype/theme/theme.dart'; +import 'package:prototype/user/user.dart'; + +import '../mocks.dart'; +import 'conversation_list_content_test.dart'; + +void main() { + group('ConversationList', () { + late MockNavigationCubit navigationCubit; + late MockConversationListCubit conversationListCubit; + late MockUserCubit userCubit; + + setUp(() async { + navigationCubit = MockNavigationCubit(); + userCubit = MockUserCubit(); + conversationListCubit = MockConversationListCubit(); + + when(() => navigationCubit.state).thenReturn(NavigationState.home()); + when(() => userCubit.state) + .thenReturn(MockUiUser(userName: "alice@localhost")); + }); + + Widget buildSubject() => MultiBlocProvider( + providers: [ + BlocProvider.value( + value: navigationCubit, + ), + BlocProvider.value( + value: userCubit, + ), + BlocProvider.value( + value: conversationListCubit, + ), + ], + child: Builder( + builder: (context) { + return MaterialApp( + debugShowCheckedModeBanner: false, + theme: themeData(context), + home: Scaffold(body: ConversationListView()), + ); + }, + ), + ); + + testWidgets('renders correctly when there are no conversations', + (tester) async { + when(() => conversationListCubit.state) + .thenReturn(ConversationListState(conversations: [])); + + await tester.pumpWidget(buildSubject()); + + await expectLater( + find.byType(MaterialApp), + matchesGoldenFile('goldens/conversation_list_empty.png'), + ); + }); + + testWidgets('renders correctly', (tester) async { + when(() => navigationCubit.state).thenReturn( + NavigationState.home(conversationId: conversations[1].id)); + when(() => conversationListCubit.state).thenReturn( + ConversationListState( + conversations: List.generate( + 20, (index) => conversations[index % conversations.length]), + ), + ); + + await tester.pumpWidget(buildSubject()); + + await expectLater( + find.byType(MaterialApp), + matchesGoldenFile('goldens/conversation_list.png'), + ); + }); + }); +} diff --git a/app/test/conversation_list/goldens/conversation_list.png b/app/test/conversation_list/goldens/conversation_list.png new file mode 100644 index 00000000..ad73c864 --- /dev/null +++ b/app/test/conversation_list/goldens/conversation_list.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0f519c902ce05c5228189311c2d5e0171dab1821c8f3ee730f9795fad3e33293 +size 173321 diff --git a/app/test/conversation_list/goldens/conversation_list_content.png b/app/test/conversation_list/goldens/conversation_list_content.png new file mode 100644 index 00000000..73f08872 --- /dev/null +++ b/app/test/conversation_list/goldens/conversation_list_content.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:755b1026bad0fd7548e9c6c70ea9c5f9d7e62481f567ca090c356371df72482d +size 206450 diff --git a/app/test/conversation_list/goldens/conversation_list_content_empty.png b/app/test/conversation_list/goldens/conversation_list_content_empty.png new file mode 100644 index 00000000..d3572465 --- /dev/null +++ b/app/test/conversation_list/goldens/conversation_list_content_empty.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7048d0c1b99c6c1515f9b5800cbb19929eb09f3c9d511fa905727024bab74614 +size 30282 diff --git a/app/test/conversation_list/goldens/conversation_list_empty.png b/app/test/conversation_list/goldens/conversation_list_empty.png new file mode 100644 index 00000000..f3c621cc --- /dev/null +++ b/app/test/conversation_list/goldens/conversation_list_empty.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d1261e2077ded85903869607c8e05ae37aaa57dc83ac6da2662146329a080218 +size 61855 diff --git a/app/test/flutter_test_config.dart b/app/test/flutter_test_config.dart new file mode 100644 index 00000000..2f7c6ed7 --- /dev/null +++ b/app/test/flutter_test_config.dart @@ -0,0 +1,80 @@ +// SPDX-FileCopyrightText: 2025 Phoenix R&D GmbH +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// The threshold for golden file comparisons to pass (between 0 and 1 as percent) +const goldenThreshold = 0.022; + +/// The physical size of the screen in the test environment +const pixel8ScreenSize = Size(1080, 2400); + +Future testExecutable(FutureOr Function() testMain) async { + setUpAll(() async { + final binding = TestWidgetsFlutterBinding.ensureInitialized(); + await _loadFonts(); + await _setGoldenFileComparatorWithThreshold(goldenThreshold); + await _setPhysicalScreenSize(binding, pixel8ScreenSize); + }); + + await testMain(); +} + +Future _loadFonts() async { + final fonts = { + "InterEmbedded": "assets/fonts/inter.ttf", + "MaterialIcons": "fonts/MaterialIcons-Regular.otf", + }; + for (final entry in fonts.entries) { + final font = rootBundle.load(entry.value); + final FontLoader fontLoader = FontLoader(entry.key)..addFont(font); + await fontLoader.load(); + } +} + +Future _setGoldenFileComparatorWithThreshold(double threshold) async { + assert(goldenFileComparator is LocalFileComparator); + final testUrl = (goldenFileComparator as LocalFileComparator).basedir; + goldenFileComparator = _LocalFileComparatorWithThreshold( + // only the base dir is used from this URI, so pass a dummy file name + Uri.parse('$testUrl/test.dart'), threshold, + ); +} + +class _LocalFileComparatorWithThreshold extends LocalFileComparator { + _LocalFileComparatorWithThreshold(super.testFile, this.threshold); + + final double threshold; + + @override + Future compare(Uint8List imageBytes, Uri golden) async { + final result = await GoldenFileComparator.compareLists( + imageBytes, + await getGoldenBytes(golden), + ); + if (!result.passed && result.diffPercent < threshold) { + return true; + } else if (!result.passed) { + final error = await generateFailureOutput(result, golden, basedir); + throw FlutterError(error); + } else { + return result.passed; + } + } +} + +_setPhysicalScreenSize( + TestWidgetsFlutterBinding binding, + Size pixel8screenSize, +) { + // set physical size of the screen + binding.platformDispatcher.views.first.physicalSize = pixel8ScreenSize; + addTearDown(() { + binding.platformDispatcher.views.first.resetPhysicalSize(); + }); +} diff --git a/app/test/goldens/home_screen_desktop.png b/app/test/goldens/home_screen_desktop.png new file mode 100644 index 00000000..7e153a3c --- /dev/null +++ b/app/test/goldens/home_screen_desktop.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:134b49bd845a8d083ccb7b3ddeae29a80a43bcff3acececd2a320831d877f903 +size 263076 diff --git a/app/test/goldens/home_screen_desktop_empty.png b/app/test/goldens/home_screen_desktop_empty.png new file mode 100644 index 00000000..e5df25b0 --- /dev/null +++ b/app/test/goldens/home_screen_desktop_empty.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ac9ed2409b935bf25ada13c47154072133e4e6a4e4d95f7aef88568198c6e837 +size 97587 diff --git a/app/test/goldens/home_screen_desktop_no_conversation.png b/app/test/goldens/home_screen_desktop_no_conversation.png new file mode 100644 index 00000000..6a1412d3 --- /dev/null +++ b/app/test/goldens/home_screen_desktop_no_conversation.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7d723f5e6911f33c29ce596d0828d59e0f0f46223e4bb7ec7f490b5bd3b95e3f +size 142028 diff --git a/app/test/helpers.dart b/app/test/helpers.dart new file mode 100644 index 00000000..05c8f553 --- /dev/null +++ b/app/test/helpers.dart @@ -0,0 +1,31 @@ +// SPDX-FileCopyrightText: 2025 Phoenix R&D GmbH +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +import 'dart:typed_data'; + +import 'package:prototype/core/core.dart'; +import 'package:uuid/uuid.dart'; + +extension IntTestExtension on int { + ConversationId conversationId() => + ConversationId(uuid: _intToUuidValue(this)); + + ConversationMessageId conversationMessageId() => + ConversationMessageId(uuid: _intToUuidValue(this)); + + UiMessageId messageId({ + String domain = "localhost", + }) => + UiMessageId( + id: _intToUuidValue(this), + domain: domain, + ); +} + +UuidValue _intToUuidValue(int value) { + // Convert int to 16-byte array + final bytes = Uint8List(16) + ..buffer.asByteData().setInt64(0, value, Endian.little); + return UuidValue.fromByteList(bytes); +} diff --git a/app/test/home_screen_test.dart b/app/test/home_screen_test.dart new file mode 100644 index 00000000..36313879 --- /dev/null +++ b/app/test/home_screen_test.dart @@ -0,0 +1,164 @@ +// SPDX-FileCopyrightText: 2025 Phoenix R&D GmbH +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:prototype/conversation_details/conversation_details.dart'; +import 'package:prototype/conversation_list/conversation_list.dart'; +import 'package:prototype/conversation_list/conversation_list_cubit.dart'; +import 'package:prototype/core/core.dart'; +import 'package:prototype/home_screen.dart'; +import 'package:prototype/message_list/message_list.dart'; +import 'package:prototype/navigation/navigation.dart'; +import 'package:prototype/theme/theme.dart'; +import 'package:prototype/user/user.dart'; +import 'package:visibility_detector/visibility_detector.dart'; + +import 'conversation/conversation_screen_view_test.dart'; +import 'conversation_list/conversation_list_content_test.dart'; +import 'helpers.dart'; +import 'message_list/message_list_test.dart'; +import 'mocks.dart'; + +void main() { + setUpAll(() { + registerFallbackValue(0.conversationMessageId()); + }); + + group('HomeScreen', () { + late MockNavigationCubit navigationCubit; + late MockUserCubit userCubit; + late MockConversationListCubit conversationListCubit; + late MockConversationDetailsCubit conversationDetailsCubit; + late MockMessageListCubit messageListCubit; + + setUp(() async { + navigationCubit = MockNavigationCubit(); + userCubit = MockUserCubit(); + conversationListCubit = MockConversationListCubit(); + conversationDetailsCubit = MockConversationDetailsCubit(); + messageListCubit = MockMessageListCubit(); + + when(() => userCubit.state) + .thenReturn(MockUiUser(userName: "alice@localhost")); + when(() => userCubit.userProfile(any())) + .thenAnswer((_) => Future.value(null)); + when(() => userCubit.state) + .thenReturn(MockUiUser(userName: "alice@localhost")); + when(() => conversationDetailsCubit.state).thenReturn( + ConversationDetailsState( + conversation: conversation, + members: members, + ), + ); + when(() => conversationDetailsCubit.markAsRead( + untilMessageId: any(named: "untilMessageId"), + untilTimestamp: any(named: "untilTimestamp"), + )).thenAnswer((_) => Future.value()); + }); + + Widget buildSubject() => MultiBlocProvider( + providers: [ + BlocProvider.value( + value: navigationCubit, + ), + BlocProvider.value( + value: userCubit, + ), + BlocProvider.value( + value: conversationListCubit, + ), + BlocProvider.value( + value: conversationDetailsCubit, + ), + BlocProvider.value( + value: messageListCubit, + ), + ], + child: Builder( + builder: (context) { + return MaterialApp( + debugShowCheckedModeBanner: false, + theme: themeData(context), + home: HomeScreenDesktopLayout( + conversationList: ConversationListView(), + conversation: ConversationScreenView( + createMessageCubit: createMockMessageCubit, + ), + ), + ); + }, + ), + ); + + testWidgets('desktop layout empty', (tester) async { + final binding = TestWidgetsFlutterBinding.ensureInitialized(); + binding.platformDispatcher.views.first.physicalSize = Size(3840, 2160); + addTearDown(() { + binding.platformDispatcher.views.first.resetPhysicalSize(); + }); + + when(() => navigationCubit.state).thenReturn(NavigationState.home()); + when(() => conversationListCubit.state) + .thenReturn(ConversationListState(conversations: [])); + when(() => messageListCubit.state).thenReturn(MockMessageListState([])); + + await tester.pumpWidget(buildSubject()); + + await expectLater( + find.byType(MaterialApp), + matchesGoldenFile('goldens/home_screen_desktop_empty.png'), + ); + }); + + testWidgets('desktop layout no conversation', (tester) async { + final binding = TestWidgetsFlutterBinding.ensureInitialized(); + binding.platformDispatcher.views.first.physicalSize = Size(3840, 2160); + addTearDown(() { + binding.platformDispatcher.views.first.resetPhysicalSize(); + }); + + when(() => navigationCubit.state).thenReturn(NavigationState.home()); + when(() => conversationListCubit.state) + .thenReturn(ConversationListState(conversations: conversations)); + when(() => messageListCubit.state) + .thenReturn(MockMessageListState(messages)); + + VisibilityDetectorController.instance.updateInterval = Duration.zero; + + await tester.pumpWidget(buildSubject()); + + await expectLater( + find.byType(MaterialApp), + matchesGoldenFile('goldens/home_screen_desktop_no_conversation.png'), + ); + }); + + testWidgets('desktop layout selected conversation', (tester) async { + final binding = TestWidgetsFlutterBinding.ensureInitialized(); + binding.platformDispatcher.views.first.physicalSize = Size(3840, 2160); + addTearDown(() { + binding.platformDispatcher.views.first.resetPhysicalSize(); + }); + + when(() => navigationCubit.state).thenReturn( + NavigationState.home(conversationId: conversations[2].id)); + when(() => conversationListCubit.state) + .thenReturn(ConversationListState(conversations: conversations)); + when(() => messageListCubit.state) + .thenReturn(MockMessageListState(messages)); + + VisibilityDetectorController.instance.updateInterval = Duration.zero; + + await tester.pumpWidget(buildSubject()); + + await expectLater( + find.byType(MaterialApp), + matchesGoldenFile('goldens/home_screen_desktop.png'), + ); + }); + }); +} diff --git a/app/test/message_list/goldens/message_list.png b/app/test/message_list/goldens/message_list.png new file mode 100644 index 00000000..9ea15703 --- /dev/null +++ b/app/test/message_list/goldens/message_list.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d109b718902e0c21c87031fd40a77bc3c455805413710437b669a9aec3de7cac +size 149997 diff --git a/app/test/message_list/goldens/message_list_empty.png b/app/test/message_list/goldens/message_list_empty.png new file mode 100644 index 00000000..c90df2f0 --- /dev/null +++ b/app/test/message_list/goldens/message_list_empty.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:944e0993cfdf319e44c1538778c93b8321a83ffb8b36e392a4c6b4bd9a3ff086 +size 15580 diff --git a/app/test/message_list/message_list_test.dart b/app/test/message_list/message_list_test.dart new file mode 100644 index 00000000..c27005d0 --- /dev/null +++ b/app/test/message_list/message_list_test.dart @@ -0,0 +1,200 @@ +// SPDX-FileCopyrightText: 2025 Phoenix R&D GmbH +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:prototype/conversation_details/conversation_details.dart'; +import 'package:prototype/core/core.dart'; +import 'package:prototype/message_list/message_list.dart'; +import 'package:prototype/theme/theme.dart'; +import 'package:prototype/user/user.dart'; +import 'package:visibility_detector/visibility_detector.dart'; + +import '../helpers.dart'; +import '../mocks.dart'; + +final conversationId = 1.conversationId(); + +final messages = [ + UiConversationMessage( + id: 1.conversationMessageId(), + conversationId: conversationId, + timestamp: '2023-01-01T00:00:00.000Z', + message: UiMessage_Content( + UiContentMessage( + sender: "bob@localhost", + sent: true, + content: UiMimiContent( + id: 1.messageId(), + timestamp: DateTime.parse("2023-01-01T00:00:00.000Z"), + lastSeen: [], + body: 'Hello Alice from Bob', + ), + ), + ), + position: UiFlightPosition.single, + ), + UiConversationMessage( + id: 2.conversationMessageId(), + conversationId: conversationId, + timestamp: '2023-01-01T00:01:00.000Z', + message: UiMessage_Content( + UiContentMessage( + sender: "eve@localhost", + sent: true, + content: UiMimiContent( + id: 2.messageId(), + timestamp: DateTime.parse("2023-01-01T00:00:00.000Z"), + lastSeen: [], + body: + 'Hello Alice. This is a long message that should not be truncated but properly split into multiple lines.', + ), + ), + ), + position: UiFlightPosition.single, + ), + UiConversationMessage( + id: 3.conversationMessageId(), + conversationId: conversationId, + timestamp: '2023-01-01T00:02:00.000Z', + message: UiMessage_Content( + UiContentMessage( + sender: "alice@localhost", + sent: true, + content: UiMimiContent( + id: 3.messageId(), + timestamp: DateTime.parse("2023-01-01T00:00:00.000Z"), + lastSeen: [], + body: 'Hello Bob and Eve', + ), + ), + ), + position: UiFlightPosition.start, + ), + UiConversationMessage( + id: 5.conversationMessageId(), + conversationId: conversationId, + timestamp: '2023-01-01T00:03:00.000Z', + message: UiMessage_Content( + UiContentMessage( + sender: "alice@localhost", + sent: true, + content: UiMimiContent( + id: 5.messageId(), + timestamp: DateTime.parse("2023-01-01T00:00:00.000Z"), + lastSeen: [], + body: 'How are you doing?', + ), + ), + ), + position: UiFlightPosition.middle, + ), + UiConversationMessage( + id: 4.conversationMessageId(), + conversationId: conversationId, + timestamp: '2023-01-01T00:03:00.000Z', + message: UiMessage_Content( + UiContentMessage( + sender: "alice@localhost", + sent: true, + content: UiMimiContent( + id: 4.messageId(), + timestamp: DateTime.parse("2023-01-01T00:00:00.000Z"), + lastSeen: [], + body: """Nice to see you both here! 👋 + +This is a message with multiple lines. It should be properly displayed in the message buble and split between multiple lines.""", + ), + ), + ), + position: UiFlightPosition.end, + ), +]; + +MessageCubit createMockMessageCubit({ + required UserCubit userCubit, + required MessageState initialState, +}) => + MockMessageCubit(initialState: initialState); + +void main() { + setUpAll(() { + registerFallbackValue(0.conversationMessageId()); + }); + + group('MessageListView', () { + late MockUserCubit userCubit; + late MockConversationDetailsCubit conversationDetailsCubit; + late MockMessageListCubit messageListCubit; + + setUp(() async { + userCubit = MockUserCubit(); + conversationDetailsCubit = MockConversationDetailsCubit(); + messageListCubit = MockMessageListCubit(); + + when(() => userCubit.state) + .thenReturn(MockUiUser(userName: "alice@localhost")); + when(() => userCubit.userProfile(any())) + .thenAnswer((_) => Future.value(null)); + when(() => conversationDetailsCubit.markAsRead( + untilMessageId: any(named: "untilMessageId"), + untilTimestamp: any(named: "untilTimestamp"), + )).thenAnswer((_) => Future.value()); + }); + + Widget buildSubject() => MultiBlocProvider( + providers: [ + BlocProvider.value( + value: userCubit, + ), + BlocProvider.value( + value: conversationDetailsCubit, + ), + BlocProvider.value( + value: messageListCubit, + ), + ], + child: Builder( + builder: (context) { + return MaterialApp( + debugShowCheckedModeBanner: false, + theme: themeData(context), + home: Scaffold( + body: MessageListView( + createMessageCubit: createMockMessageCubit, + ), + ), + ); + }, + ), + ); + + testWidgets('renders correctly when empty', (tester) async { + when(() => messageListCubit.state).thenReturn(MockMessageListState([])); + + await tester.pumpWidget(buildSubject()); + + await expectLater( + find.byType(MaterialApp), + matchesGoldenFile('goldens/message_list_empty.png'), + ); + }); + + testWidgets('renders correctly', (tester) async { + when(() => messageListCubit.state) + .thenReturn(MockMessageListState(messages)); + + VisibilityDetectorController.instance.updateInterval = Duration.zero; + + await tester.pumpWidget(buildSubject()); + + await expectLater( + find.byType(MaterialApp), + matchesGoldenFile('goldens/message_list.png'), + ); + }); + }); +} diff --git a/app/test/mocks.dart b/app/test/mocks.dart new file mode 100644 index 00000000..84e5f98b --- /dev/null +++ b/app/test/mocks.dart @@ -0,0 +1,91 @@ +// SPDX-FileCopyrightText: 2025 Phoenix R&D GmbH +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +import 'dart:typed_data'; + +import 'package:bloc_test/bloc_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:prototype/conversation_details/conversation_details.dart'; +import 'package:prototype/conversation_list/conversation_list_cubit.dart'; +import 'package:prototype/core/core.dart'; +import 'package:prototype/message_list/message_cubit.dart'; +import 'package:prototype/message_list/message_list_cubit.dart'; +import 'package:prototype/navigation/navigation.dart'; +import 'package:prototype/user/user.dart'; + +class MockNavigationCubit extends MockCubit + implements NavigationCubit {} + +class MockUserCubit extends MockCubit implements UserCubit {} + +class MockUiUser implements UiUser { + MockUiUser({ + required String userName, + String? displayName, + Uint8List? profilePicture, + }) : _userName = userName, + _displayName = displayName, + _profilePicture = profilePicture; + + final String _userName; + final String? _displayName; + final Uint8List? _profilePicture; + + @override + String? get displayName => _displayName; + + @override + void dispose() {} + + @override + bool get isDisposed => false; + + @override + Uint8List? get profilePicture => _profilePicture; + + @override + String get userName => _userName; +} + +class MockConversationDetailsCubit extends MockCubit + implements ConversationDetailsCubit {} + +class MockConversationListState extends Mock implements ConversationListState {} + +class MockConversationListCubit extends MockCubit + implements ConversationListCubit {} + +class MockMessageListCubit extends MockCubit + implements MessageListCubit {} + +class MockMessageListState implements MessageListState { + MockMessageListState(this.messages); + + final List messages; + + @override + void dispose() {} + + @override + bool get isDisposed => false; + + @override + int get loadedMessagesCount => messages.length; + + @override + UiConversationMessage? messageAt(int index) => + messages.elementAtOrNull(index); + + @override + int? messageIdIndex(ConversationMessageId messageId) { + final index = messages.indexWhere((element) => element.id == messageId); + return index != -1 ? index : null; + } +} + +class MockMessageCubit extends MockCubit implements MessageCubit { + MockMessageCubit({required MessageState initialState}) { + when(() => state).thenReturn(initialState); + } +} diff --git a/app/test/widget_test.dart b/app/test/widget_test.dart deleted file mode 100644 index b06b7e90..00000000 --- a/app/test/widget_test.dart +++ /dev/null @@ -1,12 +0,0 @@ -// SPDX-FileCopyrightText: 2024 Phoenix R&D GmbH -// -// SPDX-License-Identifier: AGPL-3.0-or-later - -// This is a basic Flutter widget test. -// -// To perform an interaction with a widget in your test, use the WidgetTester -// utility in the flutter_test package. For example, you can send tap and scroll -// gestures. You can also use WidgetTester to find child widgets in the widget -// tree, read text, and verify that the values of widget properties are correct. - -void main() {}