From a4f2270a0eae580f6ba6b30c2366e4a137e7e37f Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Thu, 12 Dec 2024 21:49:53 -0800 Subject: [PATCH 01/19] action_sheet test: Make the app-bar topic-row finder more precise --- test/widgets/action_sheet_test.dart | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/widgets/action_sheet_test.dart b/test/widgets/action_sheet_test.dart index c10957363e..066ccb06ca 100644 --- a/test/widgets/action_sheet_test.dart +++ b/test/widgets/action_sheet_test.dart @@ -149,7 +149,8 @@ void main() { // global store, per-account store, and message list get loaded await tester.pumpAndSettle(); - await tester.longPress(find.byType(ZulipAppBar)); + final topicRow = find.descendant(of: find.byType(ZulipAppBar), matching: find.text(topic)); + await tester.longPress(topicRow); // sheet appears onscreen; default duration of bottom-sheet enter animation await tester.pump(const Duration(milliseconds: 250)); check(find.byType(BottomSheet)).findsOne(); From 8d800f0315d6ddc5f56ea48a0994550f70950e3d Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Thu, 12 Dec 2024 22:01:38 -0800 Subject: [PATCH 02/19] action_sheet test [nfc]: Make checkButtons helper for showTopicActionSheet --- test/widgets/action_sheet_test.dart | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/test/widgets/action_sheet_test.dart b/test/widgets/action_sheet_test.dart index 066ccb06ca..eee22ffa72 100644 --- a/test/widgets/action_sheet_test.dart +++ b/test/widgets/action_sheet_test.dart @@ -125,6 +125,19 @@ void main() { await store.addMessage(message); } + void checkButtons() { + final actionSheetFinder = find.byType(BottomSheet); + check(actionSheetFinder).findsOne(); + + void checkButton(String label) { + check( + find.descendant(of: actionSheetFinder, matching: find.text(label)) + ).findsOne(); + } + + checkButton('Follow topic'); + } + testWidgets('show from inbox', (tester) async { await prepare(); await tester.pumpWidget(TestZulipApp(accountId: eg.selfAccount.id, @@ -135,8 +148,7 @@ void main() { await tester.longPress(find.text(topic)); // sheet appears onscreen; default duration of bottom-sheet enter animation await tester.pump(const Duration(milliseconds: 250)); - check(find.byType(BottomSheet)).findsOne(); - check(find.text('Follow topic')).findsOne(); + checkButtons(); }); testWidgets('show from app bar', (tester) async { @@ -153,8 +165,7 @@ void main() { await tester.longPress(topicRow); // sheet appears onscreen; default duration of bottom-sheet enter animation await tester.pump(const Duration(milliseconds: 250)); - check(find.byType(BottomSheet)).findsOne(); - check(find.text('Follow topic')).findsOne(); + checkButtons(); }); testWidgets('show from recipient header', (tester) async { @@ -170,8 +181,7 @@ void main() { of: find.byType(RecipientHeader), matching: find.text(topic))); // sheet appears onscreen; default duration of bottom-sheet enter animation await tester.pump(const Duration(milliseconds: 250)); - check(find.byType(BottomSheet)).findsOne(); - check(find.text('Follow topic')).findsOne(); + checkButtons(); }); }); From c7c60e8d0d1ca2d282bf5de93949b04884c1e708 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Fri, 13 Dec 2024 16:16:48 -0800 Subject: [PATCH 03/19] action_sheet test [nfc]: Add new '{topic,message} action sheet' groups --- test/widgets/action_sheet_test.dart | 1534 ++++++++++++++------------- 1 file changed, 769 insertions(+), 765 deletions(-) diff --git a/test/widgets/action_sheet_test.dart b/test/widgets/action_sheet_test.dart index eee22ffa72..a930535f03 100644 --- a/test/widgets/action_sheet_test.dart +++ b/test/widgets/action_sheet_test.dart @@ -106,311 +106,388 @@ void main() { connection.prepare(httpStatus: 400, json: fakeResponseJson); } - group('showTopicActionSheet', () { - final channel = eg.stream(); - const topic = 'my topic'; - final message = eg.streamMessage( - stream: channel, topic: topic, sender: eg.otherUser); - - Future prepare() async { - addTearDown(testBinding.reset); - - await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot( - realmUsers: [eg.selfUser, eg.otherUser], - streams: [channel], - subscriptions: [eg.subscription(channel)])); - store = await testBinding.globalStore.perAccount(eg.selfAccount.id); - connection = store.connection as FakeApiConnection; - - await store.addMessage(message); - } - - void checkButtons() { - final actionSheetFinder = find.byType(BottomSheet); - check(actionSheetFinder).findsOne(); - - void checkButton(String label) { - check( - find.descendant(of: actionSheetFinder, matching: find.text(label)) - ).findsOne(); + group('topic action sheet', () { + group('showTopicActionSheet', () { + final channel = eg.stream(); + const topic = 'my topic'; + final message = eg.streamMessage( + stream: channel, topic: topic, sender: eg.otherUser); + + Future prepare() async { + addTearDown(testBinding.reset); + + await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot( + realmUsers: [eg.selfUser, eg.otherUser], + streams: [channel], + subscriptions: [eg.subscription(channel)])); + store = await testBinding.globalStore.perAccount(eg.selfAccount.id); + connection = store.connection as FakeApiConnection; + + await store.addMessage(message); } - checkButton('Follow topic'); - } + void checkButtons() { + final actionSheetFinder = find.byType(BottomSheet); + check(actionSheetFinder).findsOne(); - testWidgets('show from inbox', (tester) async { - await prepare(); - await tester.pumpWidget(TestZulipApp(accountId: eg.selfAccount.id, - child: const HomePage())); - await tester.pump(); - check(find.byType(InboxPageBody)).findsOne(); + void checkButton(String label) { + check( + find.descendant(of: actionSheetFinder, matching: find.text(label)) + ).findsOne(); + } - await tester.longPress(find.text(topic)); - // sheet appears onscreen; default duration of bottom-sheet enter animation - await tester.pump(const Duration(milliseconds: 250)); - checkButtons(); - }); + checkButton('Follow topic'); + } - testWidgets('show from app bar', (tester) async { - await prepare(); - connection.prepare(json: eg.newestGetMessagesResult( - foundOldest: true, messages: [message]).toJson()); - await tester.pumpWidget(TestZulipApp(accountId: eg.selfAccount.id, - child: MessageListPage( - initNarrow: eg.topicNarrow(channel.streamId, topic)))); - // global store, per-account store, and message list get loaded - await tester.pumpAndSettle(); - - final topicRow = find.descendant(of: find.byType(ZulipAppBar), matching: find.text(topic)); - await tester.longPress(topicRow); - // sheet appears onscreen; default duration of bottom-sheet enter animation - await tester.pump(const Duration(milliseconds: 250)); - checkButtons(); - }); + testWidgets('show from inbox', (tester) async { + await prepare(); + await tester.pumpWidget(TestZulipApp(accountId: eg.selfAccount.id, + child: const HomePage())); + await tester.pump(); + check(find.byType(InboxPageBody)).findsOne(); + + await tester.longPress(find.text(topic)); + // sheet appears onscreen; default duration of bottom-sheet enter animation + await tester.pump(const Duration(milliseconds: 250)); + checkButtons(); + }); + + testWidgets('show from app bar', (tester) async { + await prepare(); + connection.prepare(json: eg.newestGetMessagesResult( + foundOldest: true, messages: [message]).toJson()); + await tester.pumpWidget(TestZulipApp(accountId: eg.selfAccount.id, + child: MessageListPage( + initNarrow: eg.topicNarrow(channel.streamId, topic)))); + // global store, per-account store, and message list get loaded + await tester.pumpAndSettle(); - testWidgets('show from recipient header', (tester) async { - await prepare(); - connection.prepare(json: eg.newestGetMessagesResult( - foundOldest: true, messages: [message]).toJson()); - await tester.pumpWidget(TestZulipApp(accountId: eg.selfAccount.id, - child: const MessageListPage(initNarrow: CombinedFeedNarrow()))); - // global store, per-account store, and message list get loaded - await tester.pumpAndSettle(); - - await tester.longPress(find.descendant( - of: find.byType(RecipientHeader), matching: find.text(topic))); - // sheet appears onscreen; default duration of bottom-sheet enter animation - await tester.pump(const Duration(milliseconds: 250)); - checkButtons(); + final topicRow = find.descendant(of: find.byType(ZulipAppBar), matching: find.text(topic)); + await tester.longPress(topicRow); + // sheet appears onscreen; default duration of bottom-sheet enter animation + await tester.pump(const Duration(milliseconds: 250)); + checkButtons(); + }); + + testWidgets('show from recipient header', (tester) async { + await prepare(); + connection.prepare(json: eg.newestGetMessagesResult( + foundOldest: true, messages: [message]).toJson()); + await tester.pumpWidget(TestZulipApp(accountId: eg.selfAccount.id, + child: const MessageListPage(initNarrow: CombinedFeedNarrow()))); + // global store, per-account store, and message list get loaded + await tester.pumpAndSettle(); + + await tester.longPress(find.descendant( + of: find.byType(RecipientHeader), matching: find.text(topic))); + // sheet appears onscreen; default duration of bottom-sheet enter animation + await tester.pump(const Duration(milliseconds: 250)); + checkButtons(); + }); }); - }); - group('UserTopicUpdateButton', () { - late ZulipStream channel; - late String topic; - - final mute = find.text('Mute topic'); - final unmute = find.text('Unmute topic'); - final follow = find.text('Follow topic'); - final unfollow = find.text('Unfollow topic'); - - /// Prepare store and bring up a topic action sheet. - /// - /// If `isChannelMuted` is `null`, the user is not subscribed to the - /// channel. - Future setupToTopicActionSheet(WidgetTester tester, { - required bool? isChannelMuted, - required UserTopicVisibilityPolicy visibilityPolicy, - int? zulipFeatureLevel, - }) async { - addTearDown(testBinding.reset); - - channel = eg.stream(); - topic = 'isChannelMuted: $isChannelMuted, policy: $visibilityPolicy'; - - final account = eg.selfAccount.copyWith(zulipFeatureLevel: zulipFeatureLevel); - final subscriptions = isChannelMuted == null ? [] - : [eg.subscription(channel, isMuted: isChannelMuted)]; - await testBinding.globalStore.add(account, eg.initialSnapshot( - realmUsers: [eg.selfUser], - streams: [channel], - subscriptions: subscriptions, - userTopics: [eg.userTopicItem(channel, topic, visibilityPolicy)], - zulipFeatureLevel: zulipFeatureLevel)); - store = await testBinding.globalStore.perAccount(account.id); - connection = store.connection as FakeApiConnection; - - connection.prepare(json: eg.newestGetMessagesResult( - foundOldest: true, messages: [ - eg.streamMessage(stream: channel, topic: topic)]).toJson()); - await tester.pumpWidget(TestZulipApp(accountId: account.id, - child: MessageListPage( - initNarrow: eg.topicNarrow(channel.streamId, topic)))); - await tester.pumpAndSettle(); - - await tester.longPress(find.descendant( - of: find.byType(RecipientHeader), matching: find.text(topic))); - // sheet appears onscreen; default duration of bottom-sheet enter animation - await tester.pump(const Duration(milliseconds: 250)); - } - - void checkButtons(List expectedButtonFinders) { - if (expectedButtonFinders.isEmpty) { - check(find.byType(BottomSheet)).findsNothing(); - return; + group('UserTopicUpdateButton', () { + late ZulipStream channel; + late String topic; + + final mute = find.text('Mute topic'); + final unmute = find.text('Unmute topic'); + final follow = find.text('Follow topic'); + final unfollow = find.text('Unfollow topic'); + + /// Prepare store and bring up a topic action sheet. + /// + /// If `isChannelMuted` is `null`, the user is not subscribed to the + /// channel. + Future setupToTopicActionSheet(WidgetTester tester, { + required bool? isChannelMuted, + required UserTopicVisibilityPolicy visibilityPolicy, + int? zulipFeatureLevel, + }) async { + addTearDown(testBinding.reset); + + channel = eg.stream(); + topic = 'isChannelMuted: $isChannelMuted, policy: $visibilityPolicy'; + + final account = eg.selfAccount.copyWith(zulipFeatureLevel: zulipFeatureLevel); + final subscriptions = isChannelMuted == null ? [] + : [eg.subscription(channel, isMuted: isChannelMuted)]; + await testBinding.globalStore.add(account, eg.initialSnapshot( + realmUsers: [eg.selfUser], + streams: [channel], + subscriptions: subscriptions, + userTopics: [eg.userTopicItem(channel, topic, visibilityPolicy)], + zulipFeatureLevel: zulipFeatureLevel)); + store = await testBinding.globalStore.perAccount(account.id); + connection = store.connection as FakeApiConnection; + + connection.prepare(json: eg.newestGetMessagesResult( + foundOldest: true, messages: [ + eg.streamMessage(stream: channel, topic: topic)]).toJson()); + await tester.pumpWidget(TestZulipApp(accountId: account.id, + child: MessageListPage( + initNarrow: eg.topicNarrow(channel.streamId, topic)))); + await tester.pumpAndSettle(); + + await tester.longPress(find.descendant( + of: find.byType(RecipientHeader), matching: find.text(topic))); + // sheet appears onscreen; default duration of bottom-sheet enter animation + await tester.pump(const Duration(milliseconds: 250)); } - check(find.byType(BottomSheet)).findsOne(); - for (final buttonFinder in expectedButtonFinders) { - check(buttonFinder).findsOne(); + void checkButtons(List expectedButtonFinders) { + if (expectedButtonFinders.isEmpty) { + check(find.byType(BottomSheet)).findsNothing(); + return; + } + check(find.byType(BottomSheet)).findsOne(); + + for (final buttonFinder in expectedButtonFinders) { + check(buttonFinder).findsOne(); + } + check(find.bySubtype()) + .findsExactly(expectedButtonFinders.length); } - check(find.bySubtype()) - .findsExactly(expectedButtonFinders.length); - } - - void checkUpdateUserTopicRequest(UserTopicVisibilityPolicy expectedPolicy) async { - check(connection.lastRequest).isA() - ..url.path.equals('/api/v1/user_topics') - ..bodyFields.deepEquals({ - 'stream_id': '${channel.streamId}', - 'topic': topic, - 'visibility_policy': jsonEncode(expectedPolicy), - }); - } - - testWidgets('unmuteInMutedChannel', (tester) async { - await setupToTopicActionSheet(tester, - isChannelMuted: true, - visibilityPolicy: UserTopicVisibilityPolicy.none); - await tester.tap(unmute); - await tester.pump(); - checkUpdateUserTopicRequest(UserTopicVisibilityPolicy.unmuted); - }); - testWidgets('unmute', (tester) async { - await setupToTopicActionSheet(tester, - isChannelMuted: false, - visibilityPolicy: UserTopicVisibilityPolicy.muted); - await tester.tap(unmute); - await tester.pump(); - checkUpdateUserTopicRequest(UserTopicVisibilityPolicy.none); - }); + void checkUpdateUserTopicRequest(UserTopicVisibilityPolicy expectedPolicy) async { + check(connection.lastRequest).isA() + ..url.path.equals('/api/v1/user_topics') + ..bodyFields.deepEquals({ + 'stream_id': '${channel.streamId}', + 'topic': topic, + 'visibility_policy': jsonEncode(expectedPolicy), + }); + } - testWidgets('mute', (tester) async { - await setupToTopicActionSheet(tester, - isChannelMuted: false, - visibilityPolicy: UserTopicVisibilityPolicy.none); - await tester.tap(mute); - await tester.pump(); - checkUpdateUserTopicRequest(UserTopicVisibilityPolicy.muted); - }); + testWidgets('unmuteInMutedChannel', (tester) async { + await setupToTopicActionSheet(tester, + isChannelMuted: true, + visibilityPolicy: UserTopicVisibilityPolicy.none); + await tester.tap(unmute); + await tester.pump(); + checkUpdateUserTopicRequest(UserTopicVisibilityPolicy.unmuted); + }); - testWidgets('follow', (tester) async { - await setupToTopicActionSheet(tester, - isChannelMuted: false, - visibilityPolicy: UserTopicVisibilityPolicy.none); - await tester.tap(follow); - await tester.pump(); - checkUpdateUserTopicRequest(UserTopicVisibilityPolicy.followed); - }); + testWidgets('unmute', (tester) async { + await setupToTopicActionSheet(tester, + isChannelMuted: false, + visibilityPolicy: UserTopicVisibilityPolicy.muted); + await tester.tap(unmute); + await tester.pump(); + checkUpdateUserTopicRequest(UserTopicVisibilityPolicy.none); + }); - testWidgets('unfollow', (tester) async { - await setupToTopicActionSheet(tester, - isChannelMuted: false, - visibilityPolicy: UserTopicVisibilityPolicy.followed); - await tester.tap(unfollow); - await tester.pump(); - checkUpdateUserTopicRequest(UserTopicVisibilityPolicy.none); - }); + testWidgets('mute', (tester) async { + await setupToTopicActionSheet(tester, + isChannelMuted: false, + visibilityPolicy: UserTopicVisibilityPolicy.none); + await tester.tap(mute); + await tester.pump(); + checkUpdateUserTopicRequest(UserTopicVisibilityPolicy.muted); + }); + + testWidgets('follow', (tester) async { + await setupToTopicActionSheet(tester, + isChannelMuted: false, + visibilityPolicy: UserTopicVisibilityPolicy.none); + await tester.tap(follow); + await tester.pump(); + checkUpdateUserTopicRequest(UserTopicVisibilityPolicy.followed); + }); - testWidgets('request fails with an error dialog', (tester) async { - await setupToTopicActionSheet(tester, - isChannelMuted: false, - visibilityPolicy: UserTopicVisibilityPolicy.followed); + testWidgets('unfollow', (tester) async { + await setupToTopicActionSheet(tester, + isChannelMuted: false, + visibilityPolicy: UserTopicVisibilityPolicy.followed); + await tester.tap(unfollow); + await tester.pump(); + checkUpdateUserTopicRequest(UserTopicVisibilityPolicy.none); + }); - connection.prepare(httpStatus: 400, json: { - 'result': 'error', 'code': 'BAD_REQUEST', 'msg': ''}); - await tester.tap(unfollow); - await tester.pumpAndSettle(); + testWidgets('request fails with an error dialog', (tester) async { + await setupToTopicActionSheet(tester, + isChannelMuted: false, + visibilityPolicy: UserTopicVisibilityPolicy.followed); + + connection.prepare(httpStatus: 400, json: { + 'result': 'error', 'code': 'BAD_REQUEST', 'msg': ''}); + await tester.tap(unfollow); + await tester.pumpAndSettle(); + + checkErrorDialog(tester, expectedTitle: 'Failed to unfollow topic'); + }); - checkErrorDialog(tester, expectedTitle: 'Failed to unfollow topic'); + group('check expected buttons', () { + final testCases = [ + (false, UserTopicVisibilityPolicy.muted, [unmute, follow]), + (false, UserTopicVisibilityPolicy.none, [mute, follow]), + (false, UserTopicVisibilityPolicy.unmuted, [mute, follow]), + (false, UserTopicVisibilityPolicy.followed, [mute, unfollow]), + + (true, UserTopicVisibilityPolicy.muted, [unmute, follow]), + (true, UserTopicVisibilityPolicy.none, [unmute, follow]), + (true, UserTopicVisibilityPolicy.unmuted, [mute, follow]), + (true, UserTopicVisibilityPolicy.followed, [mute, unfollow]), + + (null, UserTopicVisibilityPolicy.none, []), + ]; + + for (final (isChannelMuted, visibilityPolicy, buttons) in testCases) { + final description = 'isChannelMuted: ${isChannelMuted ?? "(not subscribed)"}, $visibilityPolicy'; + testWidgets(description, (tester) async { + await setupToTopicActionSheet(tester, + isChannelMuted: isChannelMuted, + visibilityPolicy: visibilityPolicy); + checkButtons(buttons); + }); + } + }); + + group('legacy: follow is unsupported when FL < 219', () { + final testCases = [ + (false, UserTopicVisibilityPolicy.muted, [unmute]), + (false, UserTopicVisibilityPolicy.none, [mute]), + (false, UserTopicVisibilityPolicy.unmuted, [mute]), + (false, UserTopicVisibilityPolicy.followed, [mute]), + + (true, UserTopicVisibilityPolicy.muted, [unmute]), + (true, UserTopicVisibilityPolicy.none, [unmute]), + (true, UserTopicVisibilityPolicy.unmuted, [mute]), + (true, UserTopicVisibilityPolicy.followed, [mute]), + + (null, UserTopicVisibilityPolicy.none, []), + ]; + + for (final (isChannelMuted, visibilityPolicy, buttons) in testCases) { + final description = 'isChannelMuted: ${isChannelMuted ?? "(not subscribed)"}, $visibilityPolicy'; + testWidgets(description, (tester) async { + await setupToTopicActionSheet(tester, + isChannelMuted: isChannelMuted, + visibilityPolicy: visibilityPolicy, + zulipFeatureLevel: 218); + checkButtons(buttons); + }); + } + }); + + group('legacy: unmute is unsupported when FL < 170', () { + final testCases = [ + (false, UserTopicVisibilityPolicy.muted, [unmute]), + (false, UserTopicVisibilityPolicy.none, [mute]), + (false, UserTopicVisibilityPolicy.unmuted, [mute]), + (false, UserTopicVisibilityPolicy.followed, [mute]), + + (true, UserTopicVisibilityPolicy.muted, []), + (true, UserTopicVisibilityPolicy.none, []), + (true, UserTopicVisibilityPolicy.unmuted, []), + (true, UserTopicVisibilityPolicy.followed, []), + + (null, UserTopicVisibilityPolicy.none, []), + ]; + + for (final (isChannelMuted, visibilityPolicy, buttons) in testCases) { + final description = 'isChannelMuted: ${isChannelMuted ?? "(not subscribed)"}, $visibilityPolicy'; + testWidgets(description, (tester) async { + await setupToTopicActionSheet(tester, + isChannelMuted: isChannelMuted, + visibilityPolicy: visibilityPolicy, + zulipFeatureLevel: 169); + checkButtons(buttons); + }); + } + }); }); + }); - group('check expected buttons', () { - final testCases = [ - (false, UserTopicVisibilityPolicy.muted, [unmute, follow]), - (false, UserTopicVisibilityPolicy.none, [mute, follow]), - (false, UserTopicVisibilityPolicy.unmuted, [mute, follow]), - (false, UserTopicVisibilityPolicy.followed, [mute, unfollow]), - - (true, UserTopicVisibilityPolicy.muted, [unmute, follow]), - (true, UserTopicVisibilityPolicy.none, [unmute, follow]), - (true, UserTopicVisibilityPolicy.unmuted, [mute, follow]), - (true, UserTopicVisibilityPolicy.followed, [mute, unfollow]), - - (null, UserTopicVisibilityPolicy.none, []), - ]; - - for (final (isChannelMuted, visibilityPolicy, buttons) in testCases) { - final description = 'isChannelMuted: ${isChannelMuted ?? "(not subscribed)"}, $visibilityPolicy'; - testWidgets(description, (tester) async { - await setupToTopicActionSheet(tester, - isChannelMuted: isChannelMuted, - visibilityPolicy: visibilityPolicy); - checkButtons(buttons); + group('message action sheet', () { + group('ReactionButtons', () { + final popularCandidates = EmojiStore.popularEmojiCandidates; + + for (final emoji in popularCandidates) { + final emojiDisplay = emoji.emojiDisplay as UnicodeEmojiDisplay; + + Future tapButton(WidgetTester tester) async { + await tester.tap(find.descendant( + of: find.byType(BottomSheet), + matching: find.text(emojiDisplay.emojiUnicode))); + } + + testWidgets('${emoji.emojiName} adding success', (tester) async { + final message = eg.streamMessage(); + await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message)); + + connection.prepare(json: {}); + await tapButton(tester); + await tester.pump(Duration.zero); + + check(connection.lastRequest).isA() + ..method.equals('POST') + ..url.path.equals('/api/v1/messages/${message.id}/reactions') + ..bodyFields.deepEquals({ + 'reaction_type': 'unicode_emoji', + 'emoji_code': emoji.emojiCode, + 'emoji_name': emoji.emojiName, + }); }); - } - }); - group('legacy: follow is unsupported when FL < 219', () { - final testCases = [ - (false, UserTopicVisibilityPolicy.muted, [unmute]), - (false, UserTopicVisibilityPolicy.none, [mute]), - (false, UserTopicVisibilityPolicy.unmuted, [mute]), - (false, UserTopicVisibilityPolicy.followed, [mute]), - - (true, UserTopicVisibilityPolicy.muted, [unmute]), - (true, UserTopicVisibilityPolicy.none, [unmute]), - (true, UserTopicVisibilityPolicy.unmuted, [mute]), - (true, UserTopicVisibilityPolicy.followed, [mute]), - - (null, UserTopicVisibilityPolicy.none, []), - ]; - - for (final (isChannelMuted, visibilityPolicy, buttons) in testCases) { - final description = 'isChannelMuted: ${isChannelMuted ?? "(not subscribed)"}, $visibilityPolicy'; - testWidgets(description, (tester) async { - await setupToTopicActionSheet(tester, - isChannelMuted: isChannelMuted, - visibilityPolicy: visibilityPolicy, - zulipFeatureLevel: 218); - checkButtons(buttons); + testWidgets('${emoji.emojiName} removing success', (tester) async { + final message = eg.streamMessage( + reactions: [Reaction( + emojiName: emoji.emojiName, + emojiCode: emoji.emojiCode, + reactionType: ReactionType.unicodeEmoji, + userId: eg.selfAccount.userId)] + ); + await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message)); + + connection.prepare(json: {}); + await tapButton(tester); + await tester.pump(Duration.zero); + + check(connection.lastRequest).isA() + ..method.equals('DELETE') + ..url.path.equals('/api/v1/messages/${message.id}/reactions') + ..bodyFields.deepEquals({ + 'reaction_type': 'unicode_emoji', + 'emoji_code': emoji.emojiCode, + 'emoji_name': emoji.emojiName, + }); }); - } - }); - group('legacy: unmute is unsupported when FL < 170', () { - final testCases = [ - (false, UserTopicVisibilityPolicy.muted, [unmute]), - (false, UserTopicVisibilityPolicy.none, [mute]), - (false, UserTopicVisibilityPolicy.unmuted, [mute]), - (false, UserTopicVisibilityPolicy.followed, [mute]), - - (true, UserTopicVisibilityPolicy.muted, []), - (true, UserTopicVisibilityPolicy.none, []), - (true, UserTopicVisibilityPolicy.unmuted, []), - (true, UserTopicVisibilityPolicy.followed, []), - - (null, UserTopicVisibilityPolicy.none, []), - ]; - - for (final (isChannelMuted, visibilityPolicy, buttons) in testCases) { - final description = 'isChannelMuted: ${isChannelMuted ?? "(not subscribed)"}, $visibilityPolicy'; - testWidgets(description, (tester) async { - await setupToTopicActionSheet(tester, - isChannelMuted: isChannelMuted, - visibilityPolicy: visibilityPolicy, - zulipFeatureLevel: 169); - checkButtons(buttons); + testWidgets('${emoji.emojiName} request has an error', (tester) async { + final message = eg.streamMessage(); + await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message)); + + connection.prepare(httpStatus: 400, json: { + 'code': 'BAD_REQUEST', + 'msg': 'Invalid message(s)', + 'result': 'error', + }); + await tapButton(tester); + await tester.pump(Duration.zero); // error arrives; error dialog shows + + await tester.tap(find.byWidget(checkErrorDialog(tester, + expectedTitle: 'Adding reaction failed', + expectedMessage: 'Invalid message(s)'))); }); } }); - }); - - group('ReactionButtons', () { - final popularCandidates = EmojiStore.popularEmojiCandidates; - - for (final emoji in popularCandidates) { - final emojiDisplay = emoji.emojiDisplay as UnicodeEmojiDisplay; - Future tapButton(WidgetTester tester) async { + group('StarButton', () { + Future tapButton(WidgetTester tester, {bool starred = false}) async { + // Starred messages include the same icon so we need to + // match only by descendants of [BottomSheet]. + await tester.ensureVisible(find.descendant( + of: find.byType(BottomSheet), + matching: find.byIcon(starred ? ZulipIcons.star_filled : ZulipIcons.star, skipOffstage: false))); await tester.tap(find.descendant( of: find.byType(BottomSheet), - matching: find.text(emojiDisplay.emojiUnicode))); + matching: find.byIcon(starred ? ZulipIcons.star_filled : ZulipIcons.star))); + await tester.pump(); // [MenuItemButton.onPressed] called in a post-frame callback: flutter/flutter@e4a39fa2e } - testWidgets('${emoji.emojiName} adding success', (tester) async { - final message = eg.streamMessage(); + testWidgets('star success', (tester) async { + final message = eg.streamMessage(flags: []); await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message)); connection.prepare(json: {}); @@ -419,41 +496,36 @@ void main() { check(connection.lastRequest).isA() ..method.equals('POST') - ..url.path.equals('/api/v1/messages/${message.id}/reactions') + ..url.path.equals('/api/v1/messages/flags') ..bodyFields.deepEquals({ - 'reaction_type': 'unicode_emoji', - 'emoji_code': emoji.emojiCode, - 'emoji_name': emoji.emojiName, - }); + 'messages': jsonEncode([message.id]), + 'op': 'add', + 'flag': 'starred', + }); }); - testWidgets('${emoji.emojiName} removing success', (tester) async { - final message = eg.streamMessage( - reactions: [Reaction( - emojiName: emoji.emojiName, - emojiCode: emoji.emojiCode, - reactionType: ReactionType.unicodeEmoji, - userId: eg.selfAccount.userId)] - ); + testWidgets('unstar success', (tester) async { + final message = eg.streamMessage(flags: [MessageFlag.starred]); await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message)); connection.prepare(json: {}); - await tapButton(tester); + await tapButton(tester, starred: true); await tester.pump(Duration.zero); check(connection.lastRequest).isA() - ..method.equals('DELETE') - ..url.path.equals('/api/v1/messages/${message.id}/reactions') + ..method.equals('POST') + ..url.path.equals('/api/v1/messages/flags') ..bodyFields.deepEquals({ - 'reaction_type': 'unicode_emoji', - 'emoji_code': emoji.emojiCode, - 'emoji_name': emoji.emojiName, - }); + 'messages': jsonEncode([message.id]), + 'op': 'remove', + 'flag': 'starred', + }); }); - testWidgets('${emoji.emojiName} request has an error', (tester) async { - final message = eg.streamMessage(); + testWidgets('star request has an error', (tester) async { + final message = eg.streamMessage(flags: []); await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message)); + final zulipLocalizations = GlobalLocalizations.zulipLocalizations; connection.prepare(httpStatus: 400, json: { 'code': 'BAD_REQUEST', @@ -464,180 +536,93 @@ void main() { await tester.pump(Duration.zero); // error arrives; error dialog shows await tester.tap(find.byWidget(checkErrorDialog(tester, - expectedTitle: 'Adding reaction failed', + expectedTitle: zulipLocalizations.errorStarMessageFailedTitle, expectedMessage: 'Invalid message(s)'))); }); - } - }); - group('StarButton', () { - Future tapButton(WidgetTester tester, {bool starred = false}) async { - // Starred messages include the same icon so we need to - // match only by descendants of [BottomSheet]. - await tester.ensureVisible(find.descendant( - of: find.byType(BottomSheet), - matching: find.byIcon(starred ? ZulipIcons.star_filled : ZulipIcons.star, skipOffstage: false))); - await tester.tap(find.descendant( - of: find.byType(BottomSheet), - matching: find.byIcon(starred ? ZulipIcons.star_filled : ZulipIcons.star))); - await tester.pump(); // [MenuItemButton.onPressed] called in a post-frame callback: flutter/flutter@e4a39fa2e - } - - testWidgets('star success', (tester) async { - final message = eg.streamMessage(flags: []); - await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message)); - - connection.prepare(json: {}); - await tapButton(tester); - await tester.pump(Duration.zero); - - check(connection.lastRequest).isA() - ..method.equals('POST') - ..url.path.equals('/api/v1/messages/flags') - ..bodyFields.deepEquals({ - 'messages': jsonEncode([message.id]), - 'op': 'add', - 'flag': 'starred', - }); - }); + testWidgets('unstar request has an error', (tester) async { + final message = eg.streamMessage(flags: [MessageFlag.starred]); + await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message)); + final zulipLocalizations = GlobalLocalizations.zulipLocalizations; - testWidgets('unstar success', (tester) async { - final message = eg.streamMessage(flags: [MessageFlag.starred]); - await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message)); - - connection.prepare(json: {}); - await tapButton(tester, starred: true); - await tester.pump(Duration.zero); - - check(connection.lastRequest).isA() - ..method.equals('POST') - ..url.path.equals('/api/v1/messages/flags') - ..bodyFields.deepEquals({ - 'messages': jsonEncode([message.id]), - 'op': 'remove', - 'flag': 'starred', + connection.prepare(httpStatus: 400, json: { + 'code': 'BAD_REQUEST', + 'msg': 'Invalid message(s)', + 'result': 'error', }); - }); - - testWidgets('star request has an error', (tester) async { - final message = eg.streamMessage(flags: []); - await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message)); - final zulipLocalizations = GlobalLocalizations.zulipLocalizations; + await tapButton(tester, starred: true); + await tester.pump(Duration.zero); // error arrives; error dialog shows - connection.prepare(httpStatus: 400, json: { - 'code': 'BAD_REQUEST', - 'msg': 'Invalid message(s)', - 'result': 'error', + await tester.tap(find.byWidget(checkErrorDialog(tester, + expectedTitle: zulipLocalizations.errorUnstarMessageFailedTitle, + expectedMessage: 'Invalid message(s)'))); }); - await tapButton(tester); - await tester.pump(Duration.zero); // error arrives; error dialog shows - - await tester.tap(find.byWidget(checkErrorDialog(tester, - expectedTitle: zulipLocalizations.errorStarMessageFailedTitle, - expectedMessage: 'Invalid message(s)'))); }); - testWidgets('unstar request has an error', (tester) async { - final message = eg.streamMessage(flags: [MessageFlag.starred]); - await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message)); - final zulipLocalizations = GlobalLocalizations.zulipLocalizations; + group('QuoteAndReplyButton', () { + ComposeBoxController? findComposeBoxController(WidgetTester tester) { + return tester.stateList(find.byType(ComposeBox)) + .singleOrNull?.controller; + } - connection.prepare(httpStatus: 400, json: { - 'code': 'BAD_REQUEST', - 'msg': 'Invalid message(s)', - 'result': 'error', - }); - await tapButton(tester, starred: true); - await tester.pump(Duration.zero); // error arrives; error dialog shows + Widget? findQuoteAndReplyButton(WidgetTester tester) { + return tester.widgetList(find.byIcon(ZulipIcons.format_quote)).singleOrNull; + } - await tester.tap(find.byWidget(checkErrorDialog(tester, - expectedTitle: zulipLocalizations.errorUnstarMessageFailedTitle, - expectedMessage: 'Invalid message(s)'))); - }); - }); + /// Simulates tapping the quote-and-reply button in the message action sheet. + /// + /// Checks that there is a quote-and-reply button. + Future tapQuoteAndReplyButton(WidgetTester tester) async { + await tester.ensureVisible(find.byIcon(ZulipIcons.format_quote, skipOffstage: false)); + final quoteAndReplyButton = findQuoteAndReplyButton(tester); + check(quoteAndReplyButton).isNotNull(); + TypingNotifier.debugEnable = false; + addTearDown(TypingNotifier.debugReset); + await tester.tap(find.byWidget(quoteAndReplyButton!)); + await tester.pump(); // [MenuItemButton.onPressed] called in a post-frame callback: flutter/flutter@e4a39fa2e + } - group('QuoteAndReplyButton', () { - ComposeBoxController? findComposeBoxController(WidgetTester tester) { - return tester.stateList(find.byType(ComposeBox)) - .singleOrNull?.controller; - } - - Widget? findQuoteAndReplyButton(WidgetTester tester) { - return tester.widgetList(find.byIcon(ZulipIcons.format_quote)).singleOrNull; - } - - /// Simulates tapping the quote-and-reply button in the message action sheet. - /// - /// Checks that there is a quote-and-reply button. - Future tapQuoteAndReplyButton(WidgetTester tester) async { - await tester.ensureVisible(find.byIcon(ZulipIcons.format_quote, skipOffstage: false)); - final quoteAndReplyButton = findQuoteAndReplyButton(tester); - check(quoteAndReplyButton).isNotNull(); - TypingNotifier.debugEnable = false; - addTearDown(TypingNotifier.debugReset); - await tester.tap(find.byWidget(quoteAndReplyButton!)); - await tester.pump(); // [MenuItemButton.onPressed] called in a post-frame callback: flutter/flutter@e4a39fa2e - } - - void checkLoadingState(PerAccountStore store, ComposeContentController contentController, { - required TextEditingValue valueBefore, - required Message message, - }) { - check(contentController).value.equals((ComposeContentController() - ..value = valueBefore - ..insertPadded(quoteAndReplyPlaceholder(store, message: message)) - ).value); - check(contentController).validationErrors.contains(ContentValidationError.quoteAndReplyInProgress); - } - - void checkSuccessState(PerAccountStore store, ComposeContentController contentController, { - required TextEditingValue valueBefore, - required Message message, - required String rawContent, - }) { - final builder = ComposeContentController() - ..value = valueBefore - ..insertPadded(quoteAndReply(store, message: message, rawContent: rawContent)); - if (!valueBefore.selection.isValid) { - // (At the end of the process, we focus the input, which puts a cursor - // at text's end, if there was no cursor at the time.) - builder.selection = TextSelection.collapsed(offset: builder.text.length); + void checkLoadingState(PerAccountStore store, ComposeContentController contentController, { + required TextEditingValue valueBefore, + required Message message, + }) { + check(contentController).value.equals((ComposeContentController() + ..value = valueBefore + ..insertPadded(quoteAndReplyPlaceholder(store, message: message)) + ).value); + check(contentController).validationErrors.contains(ContentValidationError.quoteAndReplyInProgress); + } + + void checkSuccessState(PerAccountStore store, ComposeContentController contentController, { + required TextEditingValue valueBefore, + required Message message, + required String rawContent, + }) { + final builder = ComposeContentController() + ..value = valueBefore + ..insertPadded(quoteAndReply(store, message: message, rawContent: rawContent)); + if (!valueBefore.selection.isValid) { + // (At the end of the process, we focus the input, which puts a cursor + // at text's end, if there was no cursor at the time.) + builder.selection = TextSelection.collapsed(offset: builder.text.length); + } + check(contentController).value.equals(builder.value); + check(contentController).not((it) => it.validationErrors.contains(ContentValidationError.quoteAndReplyInProgress)); } - check(contentController).value.equals(builder.value); - check(contentController).not((it) => it.validationErrors.contains(ContentValidationError.quoteAndReplyInProgress)); - } - - testWidgets('in channel narrow', (tester) async { - final message = eg.streamMessage(); - await setupToMessageActionSheet(tester, message: message, narrow: ChannelNarrow(message.streamId)); - - final composeBoxController = findComposeBoxController(tester) as StreamComposeBoxController; - final contentController = composeBoxController.content; - - // Ensure channel-topics are loaded before testing quote & reply behavior - connection.prepare(body: - jsonEncode(GetStreamTopicsResult(topics: [eg.getStreamTopicsEntry()]).toJson())); - final topicController = composeBoxController.topic; - topicController.value = const TextEditingValue(text: kNoTopicTopic); - - final valueBefore = contentController.value; - prepareRawContentResponseSuccess(message: message, rawContent: 'Hello world'); - await tapQuoteAndReplyButton(tester); - checkLoadingState(store, contentController, valueBefore: valueBefore, message: message); - await tester.pump(Duration.zero); // message is fetched; compose box updates - check(composeBoxController.contentFocusNode.hasFocus).isTrue(); - checkSuccessState(store, contentController, - valueBefore: valueBefore, message: message, rawContent: 'Hello world'); - }); - group('in topic narrow', () { - testWidgets('smoke', (tester) async { + testWidgets('in channel narrow', (tester) async { final message = eg.streamMessage(); - await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message)); + await setupToMessageActionSheet(tester, message: message, narrow: ChannelNarrow(message.streamId)); - final composeBoxController = findComposeBoxController(tester)!; + final composeBoxController = findComposeBoxController(tester) as StreamComposeBoxController; final contentController = composeBoxController.content; + // Ensure channel-topics are loaded before testing quote & reply behavior + connection.prepare(body: + jsonEncode(GetStreamTopicsResult(topics: [eg.getStreamTopicsEntry()]).toJson())); + final topicController = composeBoxController.topic; + topicController.value = const TextEditingValue(text: kNoTopicTopic); + final valueBefore = contentController.value; prepareRawContentResponseSuccess(message: message, rawContent: 'Hello world'); await tapQuoteAndReplyButton(tester); @@ -648,383 +633,402 @@ void main() { valueBefore: valueBefore, message: message, rawContent: 'Hello world'); }); - testWidgets('no error if user lost posting permission after action sheet opened', (tester) async { - final stream = eg.stream(); - final message = eg.streamMessage(stream: stream); - await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message)); + group('in topic narrow', () { + testWidgets('smoke', (tester) async { + final message = eg.streamMessage(); + await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message)); + + final composeBoxController = findComposeBoxController(tester)!; + final contentController = composeBoxController.content; + + final valueBefore = contentController.value; + prepareRawContentResponseSuccess(message: message, rawContent: 'Hello world'); + await tapQuoteAndReplyButton(tester); + checkLoadingState(store, contentController, valueBefore: valueBefore, message: message); + await tester.pump(Duration.zero); // message is fetched; compose box updates + check(composeBoxController.contentFocusNode.hasFocus).isTrue(); + checkSuccessState(store, contentController, + valueBefore: valueBefore, message: message, rawContent: 'Hello world'); + }); - await store.handleEvent(RealmUserUpdateEvent(id: 1, userId: eg.selfUser.userId, - role: UserRole.guest)); - await store.handleEvent(eg.channelUpdateEvent(stream, - property: ChannelPropertyName.channelPostPolicy, - value: ChannelPostPolicy.administrators)); - await tester.pump(); + testWidgets('no error if user lost posting permission after action sheet opened', (tester) async { + final stream = eg.stream(); + final message = eg.streamMessage(stream: stream); + await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message)); - await tapQuoteAndReplyButton(tester); - // no error + await store.handleEvent(RealmUserUpdateEvent(id: 1, userId: eg.selfUser.userId, + role: UserRole.guest)); + await store.handleEvent(eg.channelUpdateEvent(stream, + property: ChannelPropertyName.channelPostPolicy, + value: ChannelPostPolicy.administrators)); + await tester.pump(); + + await tapQuoteAndReplyButton(tester); + // no error + }); + }); + + group('in DM narrow', () { + testWidgets('smoke', (tester) async { + final message = eg.dmMessage(from: eg.selfUser, to: [eg.otherUser]); + await setupToMessageActionSheet(tester, + message: message, narrow: DmNarrow.ofMessage(message, selfUserId: eg.selfUser.userId)); + + final composeBoxController = findComposeBoxController(tester)!; + final contentController = composeBoxController.content; + + final valueBefore = contentController.value; + prepareRawContentResponseSuccess(message: message, rawContent: 'Hello world'); + await tapQuoteAndReplyButton(tester); + checkLoadingState(store, contentController, valueBefore: valueBefore, message: message); + await tester.pump(Duration.zero); // message is fetched; compose box updates + check(composeBoxController.contentFocusNode.hasFocus).isTrue(); + checkSuccessState(store, contentController, + valueBefore: valueBefore, message: message, rawContent: 'Hello world'); + }); + + testWidgets('no error if recipient was deactivated while raw-content request in progress', (tester) async { + final message = eg.dmMessage(from: eg.selfUser, to: [eg.otherUser]); + await setupToMessageActionSheet(tester, + message: message, + narrow: DmNarrow.ofMessage(message, selfUserId: eg.selfUser.userId)); + + prepareRawContentResponseSuccess( + message: message, + rawContent: 'Hello world', + delay: const Duration(seconds: 5), + ); + await tapQuoteAndReplyButton(tester); + await tester.pump(const Duration(seconds: 1)); // message not yet fetched + + await store.handleEvent(RealmUserUpdateEvent(id: 1, userId: eg.otherUser.userId, + isActive: false)); + await tester.pump(); + // no error + await tester.pump(const Duration(seconds: 4)); + }); }); - }); - group('in DM narrow', () { - testWidgets('smoke', (tester) async { - final message = eg.dmMessage(from: eg.selfUser, to: [eg.otherUser]); - await setupToMessageActionSheet(tester, - message: message, narrow: DmNarrow.ofMessage(message, selfUserId: eg.selfUser.userId)); + testWidgets('request has an error', (tester) async { + final message = eg.streamMessage(); + await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message)); final composeBoxController = findComposeBoxController(tester)!; final contentController = composeBoxController.content; - final valueBefore = contentController.value; - prepareRawContentResponseSuccess(message: message, rawContent: 'Hello world'); + final valueBefore = contentController.value = TextEditingValue.empty; + prepareRawContentResponseError(); await tapQuoteAndReplyButton(tester); checkLoadingState(store, contentController, valueBefore: valueBefore, message: message); - await tester.pump(Duration.zero); // message is fetched; compose box updates - check(composeBoxController.contentFocusNode.hasFocus).isTrue(); - checkSuccessState(store, contentController, - valueBefore: valueBefore, message: message, rawContent: 'Hello world'); + await tester.pump(Duration.zero); // error arrives; error dialog shows + + await tester.tap(find.byWidget(checkErrorDialog(tester, + expectedTitle: 'Quotation failed', + expectedMessage: 'That message does not seem to exist.', + ))); + + check(contentController.value).equals(const TextEditingValue( + // The placeholder was removed. (A newline from the placeholder's + // insertPadded remains; I guess ideally we'd try to prevent that.) + text: '\n', + + // (At the end of the process, we focus the input.) + selection: TextSelection.collapsed(offset: 1), // + )); }); - testWidgets('no error if recipient was deactivated while raw-content request in progress', (tester) async { - final message = eg.dmMessage(from: eg.selfUser, to: [eg.otherUser]); - await setupToMessageActionSheet(tester, - message: message, - narrow: DmNarrow.ofMessage(message, selfUserId: eg.selfUser.userId)); + testWidgets('not offered in CombinedFeedNarrow (composing to reply is not yet supported)', (tester) async { + final message = eg.streamMessage(); + await setupToMessageActionSheet(tester, message: message, narrow: const CombinedFeedNarrow()); + check(findQuoteAndReplyButton(tester)).isNull(); + }); - prepareRawContentResponseSuccess( - message: message, - rawContent: 'Hello world', - delay: const Duration(seconds: 5), - ); - await tapQuoteAndReplyButton(tester); - await tester.pump(const Duration(seconds: 1)); // message not yet fetched + testWidgets('not offered in MentionsNarrow (composing to reply is not yet supported)', (tester) async { + final message = eg.streamMessage(flags: [MessageFlag.mentioned]); + await setupToMessageActionSheet(tester, message: message, narrow: const MentionsNarrow()); + check(findQuoteAndReplyButton(tester)).isNull(); + }); - await store.handleEvent(RealmUserUpdateEvent(id: 1, userId: eg.otherUser.userId, - isActive: false)); - await tester.pump(); - // no error - await tester.pump(const Duration(seconds: 4)); + testWidgets('not offered in StarredMessagesNarrow (composing to reply is not yet supported)', (tester) async { + final message = eg.streamMessage(flags: [MessageFlag.starred]); + await setupToMessageActionSheet(tester, message: message, narrow: const StarredMessagesNarrow()); + check(findQuoteAndReplyButton(tester)).isNull(); }); }); - testWidgets('request has an error', (tester) async { - final message = eg.streamMessage(); - await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message)); - - final composeBoxController = findComposeBoxController(tester)!; - final contentController = composeBoxController.content; - - final valueBefore = contentController.value = TextEditingValue.empty; - prepareRawContentResponseError(); - await tapQuoteAndReplyButton(tester); - checkLoadingState(store, contentController, valueBefore: valueBefore, message: message); - await tester.pump(Duration.zero); // error arrives; error dialog shows + group('MarkAsUnread', () { + testWidgets('not visible if message is not read', (tester) async { + final unreadMessage = eg.streamMessage(flags: []); + await setupToMessageActionSheet(tester, message: unreadMessage, narrow: TopicNarrow.ofMessage(unreadMessage)); - await tester.tap(find.byWidget(checkErrorDialog(tester, - expectedTitle: 'Quotation failed', - expectedMessage: 'That message does not seem to exist.', - ))); + check(find.byIcon(Icons.mark_chat_unread_outlined).evaluate()).isEmpty(); + }); - check(contentController.value).equals(const TextEditingValue( - // The placeholder was removed. (A newline from the placeholder's - // insertPadded remains; I guess ideally we'd try to prevent that.) - text: '\n', + testWidgets('visible if message is read', (tester) async { + final readMessage = eg.streamMessage(flags: [MessageFlag.read]); + await setupToMessageActionSheet(tester, message: readMessage, narrow: TopicNarrow.ofMessage(readMessage)); - // (At the end of the process, we focus the input.) - selection: TextSelection.collapsed(offset: 1), // - )); - }); + check(find.byIcon(Icons.mark_chat_unread_outlined).evaluate()).single; + }); - testWidgets('not offered in CombinedFeedNarrow (composing to reply is not yet supported)', (tester) async { - final message = eg.streamMessage(); - await setupToMessageActionSheet(tester, message: message, narrow: const CombinedFeedNarrow()); - check(findQuoteAndReplyButton(tester)).isNull(); - }); + group('onPressed', () { + testWidgets('smoke test', (tester) async { + final message = eg.streamMessage(flags: [MessageFlag.read]); + await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message)); + + connection.prepare(json: UpdateMessageFlagsForNarrowResult( + processedCount: 11, updatedCount: 3, + firstProcessedId: 1, lastProcessedId: 1980, + foundOldest: true, foundNewest: true).toJson()); + + await tester.ensureVisible(find.byIcon(Icons.mark_chat_unread_outlined, skipOffstage: false)); + await tester.tap(find.byIcon(Icons.mark_chat_unread_outlined, skipOffstage: false)); + await tester.pumpAndSettle(); + check(connection.lastRequest).isA() + ..method.equals('POST') + ..url.path.equals('/api/v1/messages/flags/narrow') + ..bodyFields.deepEquals({ + 'anchor': '${message.id}', + 'include_anchor': 'true', + 'num_before': '0', + 'num_after': '1000', + 'narrow': jsonEncode(TopicNarrow.ofMessage(message).apiEncode()), + 'op': 'remove', + 'flag': 'read', + }); + }); - testWidgets('not offered in MentionsNarrow (composing to reply is not yet supported)', (tester) async { - final message = eg.streamMessage(flags: [MessageFlag.mentioned]); - await setupToMessageActionSheet(tester, message: message, narrow: const MentionsNarrow()); - check(findQuoteAndReplyButton(tester)).isNull(); - }); + testWidgets('on topic move, acts on new topic', (tester) async { + final stream = eg.stream(); + const topic = 'old topic'; + final message = eg.streamMessage(flags: [MessageFlag.read], + stream: stream, topic: topic); + await setupToMessageActionSheet(tester, message: message, + narrow: TopicNarrow.ofMessage(message)); + + // Get the action sheet fully deployed while the old narrow applies. + // (This way we maximize the range of potential bugs this test can catch, + // by giving the code maximum opportunity to latch onto the old topic.) + await tester.pumpAndSettle(); + + final newStream = eg.stream(); + const newTopic = 'other topic'; + // This result isn't quite realistic for this request: it should get + // the updated channel/stream ID and topic, because we don't even + // start the request until after we get the move event. + // But constructing the right result is annoying at the moment, and + // it doesn't matter anyway: [MessageStoreImpl.reconcileMessages] will + // keep the version updated by the event. If that somehow changes in + // some future refactor, it'll cause this test to fail. + connection.prepare(json: eg.newestGetMessagesResult( + foundOldest: true, messages: [message]).toJson()); + await store.handleEvent(eg.updateMessageEventMoveFrom( + newStreamId: newStream.streamId, newTopicStr: newTopic, + propagateMode: PropagateMode.changeAll, + origMessages: [message])); + + connection.prepare(json: UpdateMessageFlagsForNarrowResult( + processedCount: 11, updatedCount: 3, + firstProcessedId: 1, lastProcessedId: 1980, + foundOldest: true, foundNewest: true).toJson()); + await tester.tap(find.byIcon(Icons.mark_chat_unread_outlined, skipOffstage: false)); + await tester.pumpAndSettle(); + check(connection.lastRequest).isA() + ..method.equals('POST') + ..url.path.equals('/api/v1/messages/flags/narrow') + ..bodyFields['narrow'].equals( + jsonEncode(eg.topicNarrow(newStream.streamId, newTopic).apiEncode())); + }); - testWidgets('not offered in StarredMessagesNarrow (composing to reply is not yet supported)', (tester) async { - final message = eg.streamMessage(flags: [MessageFlag.starred]); - await setupToMessageActionSheet(tester, message: message, narrow: const StarredMessagesNarrow()); - check(findQuoteAndReplyButton(tester)).isNull(); - }); - }); + testWidgets('shows error when fails', (tester) async { + final message = eg.streamMessage(flags: [MessageFlag.read]); + await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message)); - group('MarkAsUnread', () { - testWidgets('not visible if message is not read', (tester) async { - final unreadMessage = eg.streamMessage(flags: []); - await setupToMessageActionSheet(tester, message: unreadMessage, narrow: TopicNarrow.ofMessage(unreadMessage)); + connection.prepare(exception: http.ClientException('Oops')); + final zulipLocalizations = GlobalLocalizations.zulipLocalizations; - check(find.byIcon(Icons.mark_chat_unread_outlined).evaluate()).isEmpty(); + await tester.ensureVisible(find.byIcon(Icons.mark_chat_unread_outlined, skipOffstage: false)); + await tester.tap(find.byIcon(Icons.mark_chat_unread_outlined, skipOffstage: false)); + await tester.pumpAndSettle(); + checkErrorDialog(tester, + expectedTitle: zulipLocalizations.errorMarkAsUnreadFailedTitle, + expectedMessage: 'NetworkException: Oops (ClientException: Oops)'); + }); + }); }); - testWidgets('visible if message is read', (tester) async { - final readMessage = eg.streamMessage(flags: [MessageFlag.read]); - await setupToMessageActionSheet(tester, message: readMessage, narrow: TopicNarrow.ofMessage(readMessage)); + group('CopyMessageTextButton', () { + setUp(() async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + MockClipboard().handleMethodCall, + ); + }); - check(find.byIcon(Icons.mark_chat_unread_outlined).evaluate()).single; - }); + Future tapCopyMessageTextButton(WidgetTester tester) async { + await tester.ensureVisible(find.byIcon(ZulipIcons.copy, skipOffstage: false)); + await tester.tap(find.byIcon(ZulipIcons.copy)); + await tester.pump(); // [MenuItemButton.onPressed] called in a post-frame callback: flutter/flutter@e4a39fa2e + } - group('onPressed', () { - testWidgets('smoke test', (tester) async { - final message = eg.streamMessage(flags: [MessageFlag.read]); + testWidgets('success', (tester) async { + final message = eg.streamMessage(); await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message)); - connection.prepare(json: UpdateMessageFlagsForNarrowResult( - processedCount: 11, updatedCount: 3, - firstProcessedId: 1, lastProcessedId: 1980, - foundOldest: true, foundNewest: true).toJson()); - - await tester.ensureVisible(find.byIcon(Icons.mark_chat_unread_outlined, skipOffstage: false)); - await tester.tap(find.byIcon(Icons.mark_chat_unread_outlined, skipOffstage: false)); - await tester.pumpAndSettle(); - check(connection.lastRequest).isA() - ..method.equals('POST') - ..url.path.equals('/api/v1/messages/flags/narrow') - ..bodyFields.deepEquals({ - 'anchor': '${message.id}', - 'include_anchor': 'true', - 'num_before': '0', - 'num_after': '1000', - 'narrow': jsonEncode(TopicNarrow.ofMessage(message).apiEncode()), - 'op': 'remove', - 'flag': 'read', - }); + prepareRawContentResponseSuccess(message: message, rawContent: 'Hello world'); + await tapCopyMessageTextButton(tester); + await tester.pump(Duration.zero); + check(await Clipboard.getData('text/plain')).isNotNull().text.equals('Hello world'); }); - testWidgets('on topic move, acts on new topic', (tester) async { - final stream = eg.stream(); - const topic = 'old topic'; - final message = eg.streamMessage(flags: [MessageFlag.read], - stream: stream, topic: topic); - await setupToMessageActionSheet(tester, message: message, - narrow: TopicNarrow.ofMessage(message)); - - // Get the action sheet fully deployed while the old narrow applies. - // (This way we maximize the range of potential bugs this test can catch, - // by giving the code maximum opportunity to latch onto the old topic.) - await tester.pumpAndSettle(); + testWidgets('can show snackbar on success', (tester) async { + // Regression test for: https://github.com/zulip/zulip-flutter/issues/732 + testBinding.deviceInfoResult = const IosDeviceInfo(systemVersion: '16.0'); - final newStream = eg.stream(); - const newTopic = 'other topic'; - // This result isn't quite realistic for this request: it should get - // the updated channel/stream ID and topic, because we don't even - // start the request until after we get the move event. - // But constructing the right result is annoying at the moment, and - // it doesn't matter anyway: [MessageStoreImpl.reconcileMessages] will - // keep the version updated by the event. If that somehow changes in - // some future refactor, it'll cause this test to fail. - connection.prepare(json: eg.newestGetMessagesResult( - foundOldest: true, messages: [message]).toJson()); - await store.handleEvent(eg.updateMessageEventMoveFrom( - newStreamId: newStream.streamId, newTopicStr: newTopic, - propagateMode: PropagateMode.changeAll, - origMessages: [message])); - - connection.prepare(json: UpdateMessageFlagsForNarrowResult( - processedCount: 11, updatedCount: 3, - firstProcessedId: 1, lastProcessedId: 1980, - foundOldest: true, foundNewest: true).toJson()); - await tester.tap(find.byIcon(Icons.mark_chat_unread_outlined, skipOffstage: false)); - await tester.pumpAndSettle(); - check(connection.lastRequest).isA() - ..method.equals('POST') - ..url.path.equals('/api/v1/messages/flags/narrow') - ..bodyFields['narrow'].equals( - jsonEncode(eg.topicNarrow(newStream.streamId, newTopic).apiEncode())); - }); - - testWidgets('shows error when fails', (tester) async { - final message = eg.streamMessage(flags: [MessageFlag.read]); + final message = eg.streamMessage(); await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message)); - connection.prepare(exception: http.ClientException('Oops')); + // Make the request take a bit of time to complete… + prepareRawContentResponseSuccess(message: message, rawContent: 'Hello world', + delay: const Duration(milliseconds: 500)); + await tapCopyMessageTextButton(tester); + // … and pump a frame to finish the NavigationState.pop animation… + await tester.pump(const Duration(milliseconds: 250)); + // … before the request finishes. This is the repro condition for #732. + await tester.pump(const Duration(milliseconds: 250)); + + final snackbar = tester.widget(find.byType(SnackBar)); + check(snackbar.behavior).equals(SnackBarBehavior.floating); final zulipLocalizations = GlobalLocalizations.zulipLocalizations; - - await tester.ensureVisible(find.byIcon(Icons.mark_chat_unread_outlined, skipOffstage: false)); - await tester.tap(find.byIcon(Icons.mark_chat_unread_outlined, skipOffstage: false)); - await tester.pumpAndSettle(); - checkErrorDialog(tester, - expectedTitle: zulipLocalizations.errorMarkAsUnreadFailedTitle, - expectedMessage: 'NetworkException: Oops (ClientException: Oops)'); + tester.widget(find.descendant(matchRoot: true, + of: find.byWidget(snackbar.content), + matching: find.text(zulipLocalizations.successMessageTextCopied))); }); - }); - }); - group('CopyMessageTextButton', () { - setUp(() async { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( - SystemChannels.platform, - MockClipboard().handleMethodCall, - ); - }); - - Future tapCopyMessageTextButton(WidgetTester tester) async { - await tester.ensureVisible(find.byIcon(ZulipIcons.copy, skipOffstage: false)); - await tester.tap(find.byIcon(ZulipIcons.copy)); - await tester.pump(); // [MenuItemButton.onPressed] called in a post-frame callback: flutter/flutter@e4a39fa2e - } + testWidgets('request has an error', (tester) async { + final message = eg.streamMessage(); + await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message)); - testWidgets('success', (tester) async { - final message = eg.streamMessage(); - await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message)); + prepareRawContentResponseError(); + await tapCopyMessageTextButton(tester); + await tester.pump(Duration.zero); // error arrives; error dialog shows - prepareRawContentResponseSuccess(message: message, rawContent: 'Hello world'); - await tapCopyMessageTextButton(tester); - await tester.pump(Duration.zero); - check(await Clipboard.getData('text/plain')).isNotNull().text.equals('Hello world'); + await tester.tap(find.byWidget(checkErrorDialog(tester, + expectedTitle: 'Copying failed', + expectedMessage: 'That message does not seem to exist.', + ))); + check(await Clipboard.getData('text/plain')).isNull(); + }); }); - testWidgets('can show snackbar on success', (tester) async { - // Regression test for: https://github.com/zulip/zulip-flutter/issues/732 - testBinding.deviceInfoResult = const IosDeviceInfo(systemVersion: '16.0'); + group('CopyMessageLinkButton', () { + setUp(() async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + MockClipboard().handleMethodCall, + ); + }); - final message = eg.streamMessage(); - await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message)); + Future tapCopyMessageLinkButton(WidgetTester tester) async { + await tester.ensureVisible(find.byIcon(Icons.link, skipOffstage: false)); + await tester.tap(find.byIcon(Icons.link)); + await tester.pump(); // [MenuItemButton.onPressed] called in a post-frame callback: flutter/flutter@e4a39fa2e + } - // Make the request take a bit of time to complete… - prepareRawContentResponseSuccess(message: message, rawContent: 'Hello world', - delay: const Duration(milliseconds: 500)); - await tapCopyMessageTextButton(tester); - // … and pump a frame to finish the NavigationState.pop animation… - await tester.pump(const Duration(milliseconds: 250)); - // … before the request finishes. This is the repro condition for #732. - await tester.pump(const Duration(milliseconds: 250)); + testWidgets('copies message link to clipboard', (tester) async { + final message = eg.streamMessage(); + final narrow = TopicNarrow.ofMessage(message); + await setupToMessageActionSheet(tester, message: message, narrow: narrow); - final snackbar = tester.widget(find.byType(SnackBar)); - check(snackbar.behavior).equals(SnackBarBehavior.floating); - final zulipLocalizations = GlobalLocalizations.zulipLocalizations; - tester.widget(find.descendant(matchRoot: true, - of: find.byWidget(snackbar.content), - matching: find.text(zulipLocalizations.successMessageTextCopied))); + await tapCopyMessageLinkButton(tester); + await tester.pump(Duration.zero); + final expectedLink = narrowLink(store, narrow, nearMessageId: message.id).toString(); + check(await Clipboard.getData('text/plain')).isNotNull().text.equals(expectedLink); + }); }); - testWidgets('request has an error', (tester) async { - final message = eg.streamMessage(); - await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message)); - - prepareRawContentResponseError(); - await tapCopyMessageTextButton(tester); - await tester.pump(Duration.zero); // error arrives; error dialog shows + group('ShareButton', () { + // Tests should call this. + MockSharePlus setupMockSharePlus() { + final mock = MockSharePlus(); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( + MethodChannelShare.channel, + mock.handleMethodCall, + ); + return mock; + } - await tester.tap(find.byWidget(checkErrorDialog(tester, - expectedTitle: 'Copying failed', - expectedMessage: 'That message does not seem to exist.', - ))); - check(await Clipboard.getData('text/plain')).isNull(); - }); - }); + Future tapShareButton(WidgetTester tester) async { + await tester.ensureVisible(find.byIcon(ZulipIcons.share, skipOffstage: false)); + await tester.tap(find.byIcon(ZulipIcons.share)); + await tester.pump(); // [MenuItemButton.onPressed] called in a post-frame callback: flutter/flutter@e4a39fa2e + } - group('CopyMessageLinkButton', () { - setUp(() async { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( - SystemChannels.platform, - MockClipboard().handleMethodCall, - ); - }); + testWidgets('request succeeds; sharing succeeds', (tester) async { + final mockSharePlus = setupMockSharePlus(); + final message = eg.streamMessage(); + await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message)); - Future tapCopyMessageLinkButton(WidgetTester tester) async { - await tester.ensureVisible(find.byIcon(Icons.link, skipOffstage: false)); - await tester.tap(find.byIcon(Icons.link)); - await tester.pump(); // [MenuItemButton.onPressed] called in a post-frame callback: flutter/flutter@e4a39fa2e - } - - testWidgets('copies message link to clipboard', (tester) async { - final message = eg.streamMessage(); - final narrow = TopicNarrow.ofMessage(message); - await setupToMessageActionSheet(tester, message: message, narrow: narrow); - - await tapCopyMessageLinkButton(tester); - await tester.pump(Duration.zero); - final expectedLink = narrowLink(store, narrow, nearMessageId: message.id).toString(); - check(await Clipboard.getData('text/plain')).isNotNull().text.equals(expectedLink); - }); - }); + prepareRawContentResponseSuccess(message: message, rawContent: 'Hello world'); + await tapShareButton(tester); + await tester.pump(Duration.zero); + check(mockSharePlus.sharedString).equals('Hello world'); + }); - group('ShareButton', () { - // Tests should call this. - MockSharePlus setupMockSharePlus() { - final mock = MockSharePlus(); - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( - MethodChannelShare.channel, - mock.handleMethodCall, - ); - return mock; - } - - Future tapShareButton(WidgetTester tester) async { - await tester.ensureVisible(find.byIcon(ZulipIcons.share, skipOffstage: false)); - await tester.tap(find.byIcon(ZulipIcons.share)); - await tester.pump(); // [MenuItemButton.onPressed] called in a post-frame callback: flutter/flutter@e4a39fa2e - } - - testWidgets('request succeeds; sharing succeeds', (tester) async { - final mockSharePlus = setupMockSharePlus(); - final message = eg.streamMessage(); - await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message)); - - prepareRawContentResponseSuccess(message: message, rawContent: 'Hello world'); - await tapShareButton(tester); - await tester.pump(Duration.zero); - check(mockSharePlus.sharedString).equals('Hello world'); - }); + testWidgets('request succeeds; sharing fails', (tester) async { + final mockSharePlus = setupMockSharePlus(); + final message = eg.streamMessage(); + await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message)); - testWidgets('request succeeds; sharing fails', (tester) async { - final mockSharePlus = setupMockSharePlus(); - final message = eg.streamMessage(); - await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message)); - - prepareRawContentResponseSuccess(message: message, rawContent: 'Hello world'); - mockSharePlus.resultString = 'dev.fluttercommunity.plus/share/unavailable'; - await tapShareButton(tester); - await tester.pump(Duration.zero); - check(mockSharePlus.sharedString).equals('Hello world'); - await tester.pump(); - await tester.tap(find.byWidget(checkErrorDialog(tester, - expectedTitle: 'Sharing failed'))); - }); + prepareRawContentResponseSuccess(message: message, rawContent: 'Hello world'); + mockSharePlus.resultString = 'dev.fluttercommunity.plus/share/unavailable'; + await tapShareButton(tester); + await tester.pump(Duration.zero); + check(mockSharePlus.sharedString).equals('Hello world'); + await tester.pump(); + await tester.tap(find.byWidget(checkErrorDialog(tester, + expectedTitle: 'Sharing failed'))); + }); - testWidgets('request has an error', (tester) async { - final mockSharePlus = setupMockSharePlus(); - final message = eg.streamMessage(); - await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message)); + testWidgets('request has an error', (tester) async { + final mockSharePlus = setupMockSharePlus(); + final message = eg.streamMessage(); + await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message)); - prepareRawContentResponseError(); - await tapShareButton(tester); - await tester.pump(Duration.zero); // error arrives; error dialog shows + prepareRawContentResponseError(); + await tapShareButton(tester); + await tester.pump(Duration.zero); // error arrives; error dialog shows - await tester.tap(find.byWidget(checkErrorDialog(tester, - expectedTitle: 'Sharing failed', - expectedMessage: 'That message does not seem to exist.', - ))); + await tester.tap(find.byWidget(checkErrorDialog(tester, + expectedTitle: 'Sharing failed', + expectedMessage: 'That message does not seem to exist.', + ))); - check(mockSharePlus.sharedString).isNull(); + check(mockSharePlus.sharedString).isNull(); + }); }); - }); - group('MessageActionSheetCancelButton', () { - final zulipLocalizations = GlobalLocalizations.zulipLocalizations; + group('MessageActionSheetCancelButton', () { + final zulipLocalizations = GlobalLocalizations.zulipLocalizations; - void checkActionSheet(WidgetTester tester, {required bool isShown}) { - check(find.text(zulipLocalizations.actionSheetOptionStarMessage) - .evaluate().length).equals(isShown ? 1 : 0); + void checkActionSheet(WidgetTester tester, {required bool isShown}) { + check(find.text(zulipLocalizations.actionSheetOptionStarMessage) + .evaluate().length).equals(isShown ? 1 : 0); - final findCancelButton = find.text(zulipLocalizations.dialogCancel); - check(findCancelButton.evaluate().length).equals(isShown ? 1 : 0); - } + final findCancelButton = find.text(zulipLocalizations.dialogCancel); + check(findCancelButton.evaluate().length).equals(isShown ? 1 : 0); + } - testWidgets('pressing the button dismisses the action sheet', (tester) async { - final message = eg.streamMessage(); - await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message)); - checkActionSheet(tester, isShown: true); + testWidgets('pressing the button dismisses the action sheet', (tester) async { + final message = eg.streamMessage(); + await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message)); + checkActionSheet(tester, isShown: true); - final findCancelButton = find.text(zulipLocalizations.dialogCancel); - await tester.tap(findCancelButton); - await tester.pumpAndSettle(); - checkActionSheet(tester, isShown: false); + final findCancelButton = find.text(zulipLocalizations.dialogCancel); + await tester.tap(findCancelButton); + await tester.pumpAndSettle(); + checkActionSheet(tester, isShown: false); + }); }); }); } From 4ff98428530d8e33efc06de9cda62526adf21c0f Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Thu, 23 Jan 2025 13:26:34 -0800 Subject: [PATCH 04/19] action_sheet test: Make some show-from-inbox setup optional in a `prepare` To test showing the topic action sheet from the message list (app bar or recipient header), we don't need `store.addMessage`; in those cases, the message data comes via MessageListView's message fetch. To test showing the action sheet from the Inbox, we do need a message ID to be in the Unreads data. So, arrange that, but only in the tests (one test) that use the Inbox. While we're at it, also make this setup represent the common case where we don't have the full Message object because a message list containing the message hasn't been opened yet. When we write tests for the upcoming resolve/unresolve button in the topic action sheet, that's a case we'll want to exercise, in addition to the case where we do have the message details. --- test/widgets/action_sheet_test.dart | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/test/widgets/action_sheet_test.dart b/test/widgets/action_sheet_test.dart index a930535f03..05003cbedc 100644 --- a/test/widgets/action_sheet_test.dart +++ b/test/widgets/action_sheet_test.dart @@ -7,6 +7,7 @@ import 'package:flutter_checks/flutter_checks.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; import 'package:zulip/api/model/events.dart'; +import 'package:zulip/api/model/initial_snapshot.dart'; import 'package:zulip/api/model/model.dart'; import 'package:zulip/api/route/channels.dart'; import 'package:zulip/api/route/messages.dart'; @@ -113,17 +114,18 @@ void main() { final message = eg.streamMessage( stream: channel, topic: topic, sender: eg.otherUser); - Future prepare() async { + Future prepare({ + UnreadMessagesSnapshot? unreadMsgs, + }) async { addTearDown(testBinding.reset); await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot( realmUsers: [eg.selfUser, eg.otherUser], streams: [channel], - subscriptions: [eg.subscription(channel)])); + subscriptions: [eg.subscription(channel)], + unreadMsgs: unreadMsgs)); store = await testBinding.globalStore.perAccount(eg.selfAccount.id); connection = store.connection as FakeApiConnection; - - await store.addMessage(message); } void checkButtons() { @@ -140,7 +142,12 @@ void main() { } testWidgets('show from inbox', (tester) async { - await prepare(); + await prepare(unreadMsgs: eg.unreadMsgs(count: 1, + channels: [eg.unreadChannelMsgs( + streamId: channel.streamId, + topic: topic, + unreadMessageIds: [message.id], + )])); await tester.pumpWidget(TestZulipApp(accountId: eg.selfAccount.id, child: const HomePage())); await tester.pump(); From cdbd96791b36ba1c3fa639bfc26ed4c4441c9492 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Thu, 23 Jan 2025 14:09:37 -0800 Subject: [PATCH 05/19] action_sheet test [nfc]: Parameterize a `prepare` by channel and topic This `prepare` function would also be useful for some tests outside the 'showTopicActionSheet' group. Give it these params to start getting it ready to move to the outer scope. We'll add more params in the next few commits. --- test/widgets/action_sheet_test.dart | 29 ++++++++++++++++++----------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/test/widgets/action_sheet_test.dart b/test/widgets/action_sheet_test.dart index 05003cbedc..78e9874d75 100644 --- a/test/widgets/action_sheet_test.dart +++ b/test/widgets/action_sheet_test.dart @@ -108,21 +108,26 @@ void main() { } group('topic action sheet', () { + final someChannel = eg.stream(); + const someTopic = 'my topic'; + group('showTopicActionSheet', () { - final channel = eg.stream(); - const topic = 'my topic'; final message = eg.streamMessage( - stream: channel, topic: topic, sender: eg.otherUser); + stream: someChannel, topic: someTopic, sender: eg.otherUser); Future prepare({ + ZulipStream? channel, + String topic = someTopic, UnreadMessagesSnapshot? unreadMsgs, }) async { + final effectiveChannel = channel ?? someChannel; + addTearDown(testBinding.reset); await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot( realmUsers: [eg.selfUser, eg.otherUser], - streams: [channel], - subscriptions: [eg.subscription(channel)], + streams: [effectiveChannel], + subscriptions: [eg.subscription(effectiveChannel)], unreadMsgs: unreadMsgs)); store = await testBinding.globalStore.perAccount(eg.selfAccount.id); connection = store.connection as FakeApiConnection; @@ -144,8 +149,8 @@ void main() { testWidgets('show from inbox', (tester) async { await prepare(unreadMsgs: eg.unreadMsgs(count: 1, channels: [eg.unreadChannelMsgs( - streamId: channel.streamId, - topic: topic, + streamId: someChannel.streamId, + topic: someTopic, unreadMessageIds: [message.id], )])); await tester.pumpWidget(TestZulipApp(accountId: eg.selfAccount.id, @@ -153,7 +158,7 @@ void main() { await tester.pump(); check(find.byType(InboxPageBody)).findsOne(); - await tester.longPress(find.text(topic)); + await tester.longPress(find.text(someTopic)); // sheet appears onscreen; default duration of bottom-sheet enter animation await tester.pump(const Duration(milliseconds: 250)); checkButtons(); @@ -165,11 +170,13 @@ void main() { foundOldest: true, messages: [message]).toJson()); await tester.pumpWidget(TestZulipApp(accountId: eg.selfAccount.id, child: MessageListPage( - initNarrow: eg.topicNarrow(channel.streamId, topic)))); + initNarrow: eg.topicNarrow(someChannel.streamId, someTopic)))); // global store, per-account store, and message list get loaded await tester.pumpAndSettle(); - final topicRow = find.descendant(of: find.byType(ZulipAppBar), matching: find.text(topic)); + final topicRow = find.descendant( + of: find.byType(ZulipAppBar), + matching: find.text(someTopic)); await tester.longPress(topicRow); // sheet appears onscreen; default duration of bottom-sheet enter animation await tester.pump(const Duration(milliseconds: 250)); @@ -186,7 +193,7 @@ void main() { await tester.pumpAndSettle(); await tester.longPress(find.descendant( - of: find.byType(RecipientHeader), matching: find.text(topic))); + of: find.byType(RecipientHeader), matching: find.text(someTopic))); // sheet appears onscreen; default duration of bottom-sheet enter animation await tester.pump(const Duration(milliseconds: 250)); checkButtons(); From 74a1f48920fce353797fd784428fad718c090adb Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Thu, 23 Jan 2025 14:10:21 -0800 Subject: [PATCH 06/19] action_sheet test [nfc]: Pull out `someMessage` variable --- test/widgets/action_sheet_test.dart | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/test/widgets/action_sheet_test.dart b/test/widgets/action_sheet_test.dart index 78e9874d75..431b1afc20 100644 --- a/test/widgets/action_sheet_test.dart +++ b/test/widgets/action_sheet_test.dart @@ -110,11 +110,10 @@ void main() { group('topic action sheet', () { final someChannel = eg.stream(); const someTopic = 'my topic'; + final someMessage = eg.streamMessage( + stream: someChannel, topic: someTopic, sender: eg.otherUser); group('showTopicActionSheet', () { - final message = eg.streamMessage( - stream: someChannel, topic: someTopic, sender: eg.otherUser); - Future prepare({ ZulipStream? channel, String topic = someTopic, @@ -151,7 +150,7 @@ void main() { channels: [eg.unreadChannelMsgs( streamId: someChannel.streamId, topic: someTopic, - unreadMessageIds: [message.id], + unreadMessageIds: [someMessage.id], )])); await tester.pumpWidget(TestZulipApp(accountId: eg.selfAccount.id, child: const HomePage())); @@ -167,7 +166,7 @@ void main() { testWidgets('show from app bar', (tester) async { await prepare(); connection.prepare(json: eg.newestGetMessagesResult( - foundOldest: true, messages: [message]).toJson()); + foundOldest: true, messages: [someMessage]).toJson()); await tester.pumpWidget(TestZulipApp(accountId: eg.selfAccount.id, child: MessageListPage( initNarrow: eg.topicNarrow(someChannel.streamId, someTopic)))); @@ -186,7 +185,7 @@ void main() { testWidgets('show from recipient header', (tester) async { await prepare(); connection.prepare(json: eg.newestGetMessagesResult( - foundOldest: true, messages: [message]).toJson()); + foundOldest: true, messages: [someMessage]).toJson()); await tester.pumpWidget(TestZulipApp(accountId: eg.selfAccount.id, child: const MessageListPage(initNarrow: CombinedFeedNarrow()))); // global store, per-account store, and message list get loaded From 1e8c916fcf1c79816032b49d808858f77778607e Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Fri, 13 Dec 2024 17:14:41 -0800 Subject: [PATCH 07/19] action_sheet test [nfc]: Add zulipFeatureLevel param to a `prepare` For the sake of some new callers when we move this function definition to an outer scope. --- test/widgets/action_sheet_test.dart | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/test/widgets/action_sheet_test.dart b/test/widgets/action_sheet_test.dart index 431b1afc20..d0a32a4995 100644 --- a/test/widgets/action_sheet_test.dart +++ b/test/widgets/action_sheet_test.dart @@ -118,16 +118,19 @@ void main() { ZulipStream? channel, String topic = someTopic, UnreadMessagesSnapshot? unreadMsgs, + int? zulipFeatureLevel, }) async { final effectiveChannel = channel ?? someChannel; addTearDown(testBinding.reset); - await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot( + final account = eg.selfAccount.copyWith(zulipFeatureLevel: zulipFeatureLevel); + await testBinding.globalStore.add(account, eg.initialSnapshot( realmUsers: [eg.selfUser, eg.otherUser], streams: [effectiveChannel], subscriptions: [eg.subscription(effectiveChannel)], - unreadMsgs: unreadMsgs)); + unreadMsgs: unreadMsgs, + zulipFeatureLevel: zulipFeatureLevel)); store = await testBinding.globalStore.perAccount(eg.selfAccount.id); connection = store.connection as FakeApiConnection; } From 1dc180838853a6a12290fa2085ccba12d488964a Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Fri, 13 Dec 2024 17:39:39 -0800 Subject: [PATCH 08/19] action_sheet test [nfc]: Add subscription-related params to a `prepare` For the sake of some new callers when we move this function definition to an outer scope. --- test/widgets/action_sheet_test.dart | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/test/widgets/action_sheet_test.dart b/test/widgets/action_sheet_test.dart index d0a32a4995..c28ecf14cf 100644 --- a/test/widgets/action_sheet_test.dart +++ b/test/widgets/action_sheet_test.dart @@ -117,10 +117,13 @@ void main() { Future prepare({ ZulipStream? channel, String topic = someTopic, + bool isChannelSubscribed = true, + bool? isChannelMuted, UnreadMessagesSnapshot? unreadMsgs, int? zulipFeatureLevel, }) async { final effectiveChannel = channel ?? someChannel; + assert(isChannelSubscribed || isChannelMuted == null); addTearDown(testBinding.reset); @@ -128,7 +131,9 @@ void main() { await testBinding.globalStore.add(account, eg.initialSnapshot( realmUsers: [eg.selfUser, eg.otherUser], streams: [effectiveChannel], - subscriptions: [eg.subscription(effectiveChannel)], + subscriptions: isChannelSubscribed + ? [eg.subscription(effectiveChannel, isMuted: isChannelMuted ?? false)] + : null, unreadMsgs: unreadMsgs, zulipFeatureLevel: zulipFeatureLevel)); store = await testBinding.globalStore.perAccount(eg.selfAccount.id); From 449f3d7d793bb639199d3263dadc81cfb8146b11 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Fri, 13 Dec 2024 17:42:56 -0800 Subject: [PATCH 09/19] action_sheet test [nfc]: Add `visibilityPolicy` to a `prepare` For the sake of some new callers when we move this function definition to an outer scope. --- test/widgets/action_sheet_test.dart | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/test/widgets/action_sheet_test.dart b/test/widgets/action_sheet_test.dart index c28ecf14cf..78bd628dde 100644 --- a/test/widgets/action_sheet_test.dart +++ b/test/widgets/action_sheet_test.dart @@ -119,6 +119,7 @@ void main() { String topic = someTopic, bool isChannelSubscribed = true, bool? isChannelMuted, + UserTopicVisibilityPolicy? visibilityPolicy, UnreadMessagesSnapshot? unreadMsgs, int? zulipFeatureLevel, }) async { @@ -134,6 +135,9 @@ void main() { subscriptions: isChannelSubscribed ? [eg.subscription(effectiveChannel, isMuted: isChannelMuted ?? false)] : null, + userTopics: visibilityPolicy != null + ? [eg.userTopicItem(effectiveChannel, topic, visibilityPolicy)] + : null, unreadMsgs: unreadMsgs, zulipFeatureLevel: zulipFeatureLevel)); store = await testBinding.globalStore.perAccount(eg.selfAccount.id); From c27b9f4a080acd3e582747304f67c4c7e6971e6a Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Fri, 13 Dec 2024 17:18:59 -0800 Subject: [PATCH 10/19] action_sheet test: Use eg.otherUser for a sender in setupToTopicActionSheet Just to make this logic more similar to a `prepare` function in a different group, to prepare (heh) for moving that `prepare` function out and reusing it here. --- test/widgets/action_sheet_test.dart | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/test/widgets/action_sheet_test.dart b/test/widgets/action_sheet_test.dart index 78bd628dde..05a715c6f6 100644 --- a/test/widgets/action_sheet_test.dart +++ b/test/widgets/action_sheet_test.dart @@ -238,7 +238,7 @@ void main() { final subscriptions = isChannelMuted == null ? [] : [eg.subscription(channel, isMuted: isChannelMuted)]; await testBinding.globalStore.add(account, eg.initialSnapshot( - realmUsers: [eg.selfUser], + realmUsers: [eg.selfUser, eg.otherUser], streams: [channel], subscriptions: subscriptions, userTopics: [eg.userTopicItem(channel, topic, visibilityPolicy)], @@ -246,9 +246,10 @@ void main() { store = await testBinding.globalStore.perAccount(account.id); connection = store.connection as FakeApiConnection; + final message = eg.streamMessage( + stream: channel, topic: topic, sender: eg.otherUser); connection.prepare(json: eg.newestGetMessagesResult( - foundOldest: true, messages: [ - eg.streamMessage(stream: channel, topic: topic)]).toJson()); + foundOldest: true, messages: [message]).toJson()); await tester.pumpWidget(TestZulipApp(accountId: account.id, child: MessageListPage( initNarrow: eg.topicNarrow(channel.streamId, topic)))); From 1991556d7b6e1d8de1c8e9b639623a768a836f10 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Fri, 13 Dec 2024 17:58:05 -0800 Subject: [PATCH 11/19] action_sheet test [nfc]: Pull out a `prepare` function and reuse Any of this function's new params (isChannelSubscribed, isChannelMuted, visibilityPolicy, and zulipFeatureLevel) might come in useful as we continue to add to the topic action sheet. --- test/widgets/action_sheet_test.dart | 82 ++++++++++++++--------------- 1 file changed, 39 insertions(+), 43 deletions(-) diff --git a/test/widgets/action_sheet_test.dart b/test/widgets/action_sheet_test.dart index 05a715c6f6..15c9c7978c 100644 --- a/test/widgets/action_sheet_test.dart +++ b/test/widgets/action_sheet_test.dart @@ -113,37 +113,37 @@ void main() { final someMessage = eg.streamMessage( stream: someChannel, topic: someTopic, sender: eg.otherUser); - group('showTopicActionSheet', () { - Future prepare({ - ZulipStream? channel, - String topic = someTopic, - bool isChannelSubscribed = true, - bool? isChannelMuted, - UserTopicVisibilityPolicy? visibilityPolicy, - UnreadMessagesSnapshot? unreadMsgs, - int? zulipFeatureLevel, - }) async { - final effectiveChannel = channel ?? someChannel; - assert(isChannelSubscribed || isChannelMuted == null); - - addTearDown(testBinding.reset); - - final account = eg.selfAccount.copyWith(zulipFeatureLevel: zulipFeatureLevel); - await testBinding.globalStore.add(account, eg.initialSnapshot( - realmUsers: [eg.selfUser, eg.otherUser], - streams: [effectiveChannel], - subscriptions: isChannelSubscribed - ? [eg.subscription(effectiveChannel, isMuted: isChannelMuted ?? false)] - : null, - userTopics: visibilityPolicy != null - ? [eg.userTopicItem(effectiveChannel, topic, visibilityPolicy)] - : null, - unreadMsgs: unreadMsgs, - zulipFeatureLevel: zulipFeatureLevel)); - store = await testBinding.globalStore.perAccount(eg.selfAccount.id); - connection = store.connection as FakeApiConnection; - } + Future prepare({ + ZulipStream? channel, + String topic = someTopic, + bool isChannelSubscribed = true, + bool? isChannelMuted, + UserTopicVisibilityPolicy? visibilityPolicy, + UnreadMessagesSnapshot? unreadMsgs, + int? zulipFeatureLevel, + }) async { + final effectiveChannel = channel ?? someChannel; + assert(isChannelSubscribed || isChannelMuted == null); + + addTearDown(testBinding.reset); + + final account = eg.selfAccount.copyWith(zulipFeatureLevel: zulipFeatureLevel); + await testBinding.globalStore.add(account, eg.initialSnapshot( + realmUsers: [eg.selfUser, eg.otherUser], + streams: [effectiveChannel], + subscriptions: isChannelSubscribed + ? [eg.subscription(effectiveChannel, isMuted: isChannelMuted ?? false)] + : null, + userTopics: visibilityPolicy != null + ? [eg.userTopicItem(effectiveChannel, topic, visibilityPolicy)] + : null, + unreadMsgs: unreadMsgs, + zulipFeatureLevel: zulipFeatureLevel)); + store = await testBinding.globalStore.perAccount(eg.selfAccount.id); + connection = store.connection as FakeApiConnection; + } + group('showTopicActionSheet', () { void checkButtons() { final actionSheetFinder = find.byType(BottomSheet); check(actionSheetFinder).findsOne(); @@ -233,24 +233,20 @@ void main() { channel = eg.stream(); topic = 'isChannelMuted: $isChannelMuted, policy: $visibilityPolicy'; - - final account = eg.selfAccount.copyWith(zulipFeatureLevel: zulipFeatureLevel); - final subscriptions = isChannelMuted == null ? [] - : [eg.subscription(channel, isMuted: isChannelMuted)]; - await testBinding.globalStore.add(account, eg.initialSnapshot( - realmUsers: [eg.selfUser, eg.otherUser], - streams: [channel], - subscriptions: subscriptions, - userTopics: [eg.userTopicItem(channel, topic, visibilityPolicy)], - zulipFeatureLevel: zulipFeatureLevel)); - store = await testBinding.globalStore.perAccount(account.id); - connection = store.connection as FakeApiConnection; + await prepare( + channel: channel, + topic: topic, + isChannelSubscribed: isChannelMuted != null, // shorthand; see dartdoc + isChannelMuted: isChannelMuted, + visibilityPolicy: visibilityPolicy, + zulipFeatureLevel: zulipFeatureLevel, + ); final message = eg.streamMessage( stream: channel, topic: topic, sender: eg.otherUser); connection.prepare(json: eg.newestGetMessagesResult( foundOldest: true, messages: [message]).toJson()); - await tester.pumpWidget(TestZulipApp(accountId: account.id, + await tester.pumpWidget(TestZulipApp(accountId: eg.selfAccount.id, child: MessageListPage( initNarrow: eg.topicNarrow(channel.streamId, topic)))); await tester.pumpAndSettle(); From 4498df6dc373602572d3731d8a7833f1d4697baf Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Wed, 22 Jan 2025 18:30:32 -0800 Subject: [PATCH 12/19] action_sheet test [nfc]: Simplify some setup by using someChannel Which is given the value `eg.stream()`, too, anyway. --- test/widgets/action_sheet_test.dart | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/test/widgets/action_sheet_test.dart b/test/widgets/action_sheet_test.dart index 15c9c7978c..6a124d0448 100644 --- a/test/widgets/action_sheet_test.dart +++ b/test/widgets/action_sheet_test.dart @@ -212,7 +212,6 @@ void main() { }); group('UserTopicUpdateButton', () { - late ZulipStream channel; late String topic; final mute = find.text('Mute topic'); @@ -231,10 +230,9 @@ void main() { }) async { addTearDown(testBinding.reset); - channel = eg.stream(); topic = 'isChannelMuted: $isChannelMuted, policy: $visibilityPolicy'; await prepare( - channel: channel, + channel: someChannel, topic: topic, isChannelSubscribed: isChannelMuted != null, // shorthand; see dartdoc isChannelMuted: isChannelMuted, @@ -243,12 +241,12 @@ void main() { ); final message = eg.streamMessage( - stream: channel, topic: topic, sender: eg.otherUser); + stream: someChannel, topic: topic, sender: eg.otherUser); connection.prepare(json: eg.newestGetMessagesResult( foundOldest: true, messages: [message]).toJson()); await tester.pumpWidget(TestZulipApp(accountId: eg.selfAccount.id, child: MessageListPage( - initNarrow: eg.topicNarrow(channel.streamId, topic)))); + initNarrow: eg.topicNarrow(someChannel.streamId, topic)))); await tester.pumpAndSettle(); await tester.longPress(find.descendant( @@ -275,7 +273,7 @@ void main() { check(connection.lastRequest).isA() ..url.path.equals('/api/v1/user_topics') ..bodyFields.deepEquals({ - 'stream_id': '${channel.streamId}', + 'stream_id': '${someChannel.streamId}', 'topic': topic, 'visibility_policy': jsonEncode(expectedPolicy), }); From 212afe58286eeb949b515f53369dd694c270062f Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Fri, 13 Dec 2024 18:14:11 -0800 Subject: [PATCH 13/19] action_sheet test [nfc]: Pull out showFromInbox helper --- test/widgets/action_sheet_test.dart | 36 ++++++++++++++++++++++------- 1 file changed, 28 insertions(+), 8 deletions(-) diff --git a/test/widgets/action_sheet_test.dart b/test/widgets/action_sheet_test.dart index 6a124d0448..b1b634edd8 100644 --- a/test/widgets/action_sheet_test.dart +++ b/test/widgets/action_sheet_test.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import 'package:checks/checks.dart'; +import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_checks/flutter_checks.dart'; @@ -143,6 +144,32 @@ void main() { connection = store.connection as FakeApiConnection; } + Future showFromInbox(WidgetTester tester, { + String topic = someTopic, + }) async { + final channelIdsWithUnreads = store.unreads.streams.keys; + final hasTopicWithUnreads = channelIdsWithUnreads.any((streamId) => + store.unreads.countInTopicNarrow(streamId, TopicName(topic)) > 0); + if (!hasTopicWithUnreads) { + throw FlutterError.fromParts([ + ErrorSummary('showFromInbox called without an unread message'), + ErrorHint( + 'Before calling showFromInbox, ensure that [Unreads] ' + 'has an unread message in the relevant topic. ', + ), + ]); + } + + await tester.pumpWidget(TestZulipApp(accountId: eg.selfAccount.id, + child: const HomePage())); + await tester.pump(); + check(find.byType(InboxPageBody)).findsOne(); + + await tester.longPress(find.text(topic)); + // sheet appears onscreen; default duration of bottom-sheet enter animation + await tester.pump(const Duration(milliseconds: 250)); + } + group('showTopicActionSheet', () { void checkButtons() { final actionSheetFinder = find.byType(BottomSheet); @@ -164,14 +191,7 @@ void main() { topic: someTopic, unreadMessageIds: [someMessage.id], )])); - await tester.pumpWidget(TestZulipApp(accountId: eg.selfAccount.id, - child: const HomePage())); - await tester.pump(); - check(find.byType(InboxPageBody)).findsOne(); - - await tester.longPress(find.text(someTopic)); - // sheet appears onscreen; default duration of bottom-sheet enter animation - await tester.pump(const Duration(milliseconds: 250)); + await showFromInbox(tester); checkButtons(); }); From fa8a07ca6c50f722a11f8c9c27a11be0a348c6d0 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Fri, 13 Dec 2024 18:17:07 -0800 Subject: [PATCH 14/19] action_sheet test [nfc]: Pull out showFromAppBar helper --- test/widgets/action_sheet_test.dart | 40 +++++++++++++++++++---------- 1 file changed, 26 insertions(+), 14 deletions(-) diff --git a/test/widgets/action_sheet_test.dart b/test/widgets/action_sheet_test.dart index b1b634edd8..db825a5ba3 100644 --- a/test/widgets/action_sheet_test.dart +++ b/test/widgets/action_sheet_test.dart @@ -170,6 +170,31 @@ void main() { await tester.pump(const Duration(milliseconds: 250)); } + Future showFromAppBar(WidgetTester tester, { + ZulipStream? channel, + String topic = someTopic, + StreamMessage? message, + }) async { + final effectiveChannel = channel ?? someChannel; + final effectiveMessage = message ?? someMessage; + assert(effectiveMessage.topic.apiName == topic); + + connection.prepare(json: eg.newestGetMessagesResult( + foundOldest: true, messages: [effectiveMessage]).toJson()); + await tester.pumpWidget(TestZulipApp(accountId: eg.selfAccount.id, + child: MessageListPage( + initNarrow: eg.topicNarrow(effectiveChannel.streamId, topic)))); + // global store, per-account store, and message list get loaded + await tester.pumpAndSettle(); + + final topicRow = find.descendant( + of: find.byType(ZulipAppBar), + matching: find.text(topic)); + await tester.longPress(topicRow); + // sheet appears onscreen; default duration of bottom-sheet enter animation + await tester.pump(const Duration(milliseconds: 250)); + } + group('showTopicActionSheet', () { void checkButtons() { final actionSheetFinder = find.byType(BottomSheet); @@ -197,20 +222,7 @@ void main() { testWidgets('show from app bar', (tester) async { await prepare(); - connection.prepare(json: eg.newestGetMessagesResult( - foundOldest: true, messages: [someMessage]).toJson()); - await tester.pumpWidget(TestZulipApp(accountId: eg.selfAccount.id, - child: MessageListPage( - initNarrow: eg.topicNarrow(someChannel.streamId, someTopic)))); - // global store, per-account store, and message list get loaded - await tester.pumpAndSettle(); - - final topicRow = find.descendant( - of: find.byType(ZulipAppBar), - matching: find.text(someTopic)); - await tester.longPress(topicRow); - // sheet appears onscreen; default duration of bottom-sheet enter animation - await tester.pump(const Duration(milliseconds: 250)); + await showFromAppBar(tester); checkButtons(); }); From ea9ec76c3097cfe2804d0c001c19abe0b7b20a6b Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Wed, 15 Jan 2025 18:54:33 -0800 Subject: [PATCH 15/19] action_sheet test: Cut assumption of topic recipient header in topic narrow With #1039 "Fuse recipient header into app bar for topic narrows", this setup will likely become invalid. It assumes that a topic recipient header can be found in a topic-narrow message list. Switching to the combined-feed narrow would break tests that use a muted topic, because the topic's message(s) are excluded in that narrow, so there wouldn't be a recipient header to tap. Instead, trigger the topic action sheet from the topic-narrow app bar. --- test/widgets/action_sheet_test.dart | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/test/widgets/action_sheet_test.dart b/test/widgets/action_sheet_test.dart index db825a5ba3..12ce7c203c 100644 --- a/test/widgets/action_sheet_test.dart +++ b/test/widgets/action_sheet_test.dart @@ -274,17 +274,7 @@ void main() { final message = eg.streamMessage( stream: someChannel, topic: topic, sender: eg.otherUser); - connection.prepare(json: eg.newestGetMessagesResult( - foundOldest: true, messages: [message]).toJson()); - await tester.pumpWidget(TestZulipApp(accountId: eg.selfAccount.id, - child: MessageListPage( - initNarrow: eg.topicNarrow(someChannel.streamId, topic)))); - await tester.pumpAndSettle(); - - await tester.longPress(find.descendant( - of: find.byType(RecipientHeader), matching: find.text(topic))); - // sheet appears onscreen; default duration of bottom-sheet enter animation - await tester.pump(const Duration(milliseconds: 250)); + await showFromAppBar(tester, channel: someChannel, topic: topic, message: message); } void checkButtons(List expectedButtonFinders) { From 210f1d9bdb56ca82a032e9b43362fe97c49e5e36 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Wed, 15 Jan 2025 18:31:44 -0800 Subject: [PATCH 16/19] action_sheet test [nfc]: Pull out showFromRecipientHeader helper --- test/widgets/action_sheet_test.dart | 31 +++++++++++++++++++---------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/test/widgets/action_sheet_test.dart b/test/widgets/action_sheet_test.dart index 12ce7c203c..2852a60878 100644 --- a/test/widgets/action_sheet_test.dart +++ b/test/widgets/action_sheet_test.dart @@ -195,6 +195,25 @@ void main() { await tester.pump(const Duration(milliseconds: 250)); } + Future showFromRecipientHeader(WidgetTester tester, { + StreamMessage? message, + }) async { + final effectiveMessage = message ?? someMessage; + + connection.prepare(json: eg.newestGetMessagesResult( + foundOldest: true, messages: [effectiveMessage]).toJson()); + await tester.pumpWidget(TestZulipApp(accountId: eg.selfAccount.id, + child: const MessageListPage(initNarrow: CombinedFeedNarrow()))); + // global store, per-account store, and message list get loaded + await tester.pumpAndSettle(); + + await tester.longPress(find.descendant( + of: find.byType(RecipientHeader), + matching: find.text(effectiveMessage.topic.displayName))); + // sheet appears onscreen; default duration of bottom-sheet enter animation + await tester.pump(const Duration(milliseconds: 250)); + } + group('showTopicActionSheet', () { void checkButtons() { final actionSheetFinder = find.byType(BottomSheet); @@ -228,17 +247,7 @@ void main() { testWidgets('show from recipient header', (tester) async { await prepare(); - connection.prepare(json: eg.newestGetMessagesResult( - foundOldest: true, messages: [someMessage]).toJson()); - await tester.pumpWidget(TestZulipApp(accountId: eg.selfAccount.id, - child: const MessageListPage(initNarrow: CombinedFeedNarrow()))); - // global store, per-account store, and message list get loaded - await tester.pumpAndSettle(); - - await tester.longPress(find.descendant( - of: find.byType(RecipientHeader), matching: find.text(someTopic))); - // sheet appears onscreen; default duration of bottom-sheet enter animation - await tester.pump(const Duration(milliseconds: 250)); + await showFromRecipientHeader(tester); checkButtons(); }); }); From f4e345706144e9f5ccb01ded0856c9cf83af35b3 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Wed, 22 Jan 2025 16:03:55 -0800 Subject: [PATCH 17/19] test [nfc]: Comment on where to find topic-action-sheet tests These are natural places for someone to look for such tests. --- test/widgets/inbox_test.dart | 2 ++ test/widgets/message_list_test.dart | 4 ++++ 2 files changed, 6 insertions(+) diff --git a/test/widgets/inbox_test.dart b/test/widgets/inbox_test.dart index b7af250d03..3fa3713d5d 100644 --- a/test/widgets/inbox_test.dart +++ b/test/widgets/inbox_test.dart @@ -206,6 +206,8 @@ void main() { // TODO test that tapping a conversation row opens the message list // for the conversation + // Tests for the topic action sheet are in test/widgets/action_sheet_test.dart. + group('muting', () { // aka topic visibility testWidgets('baseline', (tester) async { final stream = eg.stream(); diff --git a/test/widgets/message_list_test.dart b/test/widgets/message_list_test.dart index aadb2ffc77..53a16cf6c3 100644 --- a/test/widgets/message_list_test.dart +++ b/test/widgets/message_list_test.dart @@ -133,6 +133,8 @@ void main() { }); group('app bar', () { + // Tests for the topic action sheet are in test/widgets/action_sheet_test.dart. + testWidgets('has channel-feed action for topic narrows', (tester) async { final pushedRoutes = >[]; final navObserver = TestNavigatorObserver() @@ -748,6 +750,8 @@ void main() { group('recipient headers', () { group('StreamMessageRecipientHeader', () { + // Tests for the topic action sheet are in test/widgets/action_sheet_test.dart. + final stream = eg.stream(name: 'stream name'); const topic = 'topic name'; final message = eg.streamMessage(stream: stream, topic: topic); From faed229feec48f963d00b8fa7bb4c76f04572233 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Wed, 22 Jan 2025 16:11:11 -0800 Subject: [PATCH 18/19] action_sheet test: Move some logic out of checkButtons helper Upcoming tests for the resolve/unresolve button can use this to test an edge case where the button isn't offered. While we're at it, replace finder logic in some existing checks, making some a bit more targeted (so a not-NFC change): when finding `UserTopicUpdateButton`s by their label text, we now require the text to be under a BottomSheet widget. --- test/widgets/action_sheet_test.dart | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/test/widgets/action_sheet_test.dart b/test/widgets/action_sheet_test.dart index 2852a60878..4a693aad54 100644 --- a/test/widgets/action_sheet_test.dart +++ b/test/widgets/action_sheet_test.dart @@ -214,15 +214,16 @@ void main() { await tester.pump(const Duration(milliseconds: 250)); } + final actionSheetFinder = find.byType(BottomSheet); + Finder findButtonForLabel(String label) => + find.descendant(of: actionSheetFinder, matching: find.text(label)); + group('showTopicActionSheet', () { void checkButtons() { - final actionSheetFinder = find.byType(BottomSheet); check(actionSheetFinder).findsOne(); void checkButton(String label) { - check( - find.descendant(of: actionSheetFinder, matching: find.text(label)) - ).findsOne(); + check(findButtonForLabel(label)).findsOne(); } checkButton('Follow topic'); @@ -255,10 +256,10 @@ void main() { group('UserTopicUpdateButton', () { late String topic; - final mute = find.text('Mute topic'); - final unmute = find.text('Unmute topic'); - final follow = find.text('Follow topic'); - final unfollow = find.text('Unfollow topic'); + final mute = findButtonForLabel('Mute topic'); + final unmute = findButtonForLabel('Unmute topic'); + final follow = findButtonForLabel('Follow topic'); + final unfollow = findButtonForLabel('Unfollow topic'); /// Prepare store and bring up a topic action sheet. /// @@ -288,10 +289,10 @@ void main() { void checkButtons(List expectedButtonFinders) { if (expectedButtonFinders.isEmpty) { - check(find.byType(BottomSheet)).findsNothing(); + check(actionSheetFinder).findsNothing(); return; } - check(find.byType(BottomSheet)).findsOne(); + check(actionSheetFinder).findsOne(); for (final buttonFinder in expectedButtonFinders) { check(buttonFinder).findsOne(); From 55fdfd305def6d0f0e50bf71155d40ab38e89983 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Wed, 22 Jan 2025 16:48:57 -0800 Subject: [PATCH 19/19] action_sheet test [nfc]: Have showFromAppBar take `messages`, not `message` When we add the resolve/unresolve button, coming up, we'll use this to test the edge case where the button isn't shown in the message-list app bar if the message list is empty. (For that case we'll just pass an empty Iterable for this.) --- test/widgets/action_sheet_test.dart | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/test/widgets/action_sheet_test.dart b/test/widgets/action_sheet_test.dart index 4a693aad54..e6b48384b8 100644 --- a/test/widgets/action_sheet_test.dart +++ b/test/widgets/action_sheet_test.dart @@ -173,14 +173,14 @@ void main() { Future showFromAppBar(WidgetTester tester, { ZulipStream? channel, String topic = someTopic, - StreamMessage? message, + List? messages, }) async { final effectiveChannel = channel ?? someChannel; - final effectiveMessage = message ?? someMessage; - assert(effectiveMessage.topic.apiName == topic); + final effectiveMessages = messages ?? [someMessage]; + assert(effectiveMessages.every((m) => m.topic.apiName == topic)); connection.prepare(json: eg.newestGetMessagesResult( - foundOldest: true, messages: [effectiveMessage]).toJson()); + foundOldest: true, messages: effectiveMessages).toJson()); await tester.pumpWidget(TestZulipApp(accountId: eg.selfAccount.id, child: MessageListPage( initNarrow: eg.topicNarrow(effectiveChannel.streamId, topic)))); @@ -284,7 +284,8 @@ void main() { final message = eg.streamMessage( stream: someChannel, topic: topic, sender: eg.otherUser); - await showFromAppBar(tester, channel: someChannel, topic: topic, message: message); + await showFromAppBar(tester, + channel: someChannel, topic: topic, messages: [message]); } void checkButtons(List expectedButtonFinders) {