diff --git a/test/widgets/action_sheet_test.dart b/test/widgets/action_sheet_test.dart index c10957363e..e6b48384b8 100644 --- a/test/widgets/action_sheet_test.dart +++ b/test/widgets/action_sheet_test.dart @@ -1,12 +1,14 @@ 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'; 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'; @@ -106,27 +108,58 @@ 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); + group('topic action sheet', () { + final someChannel = eg.stream(); + const someTopic = 'my topic'; + final someMessage = eg.streamMessage( + stream: someChannel, topic: someTopic, sender: eg.otherUser); + + 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); - Future prepare() async { 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: [channel], - subscriptions: [eg.subscription(channel)])); + 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; - - await store.addMessage(message); } - testWidgets('show from inbox', (tester) async { - await prepare(); + 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(); @@ -135,271 +168,379 @@ 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(); - }); + } + + Future showFromAppBar(WidgetTester tester, { + ZulipStream? channel, + String topic = someTopic, + List? messages, + }) async { + final effectiveChannel = channel ?? someChannel; + final effectiveMessages = messages ?? [someMessage]; + assert(effectiveMessages.every((m) => m.topic.apiName == topic)); - testWidgets('show from app bar', (tester) async { - await prepare(); connection.prepare(json: eg.newestGetMessagesResult( - foundOldest: true, messages: [message]).toJson()); + foundOldest: true, messages: effectiveMessages).toJson()); await tester.pumpWidget(TestZulipApp(accountId: eg.selfAccount.id, child: MessageListPage( - initNarrow: eg.topicNarrow(channel.streamId, topic)))); + initNarrow: eg.topicNarrow(effectiveChannel.streamId, topic)))); // 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(); - check(find.text('Follow topic')).findsOne(); - }); + } + + Future showFromRecipientHeader(WidgetTester tester, { + StreamMessage? message, + }) async { + final effectiveMessage = message ?? someMessage; - testWidgets('show from recipient header', (tester) async { - await prepare(); connection.prepare(json: eg.newestGetMessagesResult( - foundOldest: true, messages: [message]).toJson()); + 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(topic))); + 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)); - check(find.byType(BottomSheet)).findsOne(); - check(find.text('Follow topic')).findsOne(); - }); - }); + } - 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); + final actionSheetFinder = find.byType(BottomSheet); + Finder findButtonForLabel(String label) => + find.descendant(of: actionSheetFinder, matching: find.text(label)); - channel = eg.stream(); - topic = 'isChannelMuted: $isChannelMuted, policy: $visibilityPolicy'; + group('showTopicActionSheet', () { + void checkButtons() { + check(actionSheetFinder).findsOne(); - 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; + void checkButton(String label) { + check(findButtonForLabel(label)).findsOne(); + } - 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(); + checkButton('Follow topic'); + } - 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)); - } + testWidgets('show from inbox', (tester) async { + await prepare(unreadMsgs: eg.unreadMsgs(count: 1, + channels: [eg.unreadChannelMsgs( + streamId: someChannel.streamId, + topic: someTopic, + unreadMessageIds: [someMessage.id], + )])); + await showFromInbox(tester); + checkButtons(); + }); + + testWidgets('show from app bar', (tester) async { + await prepare(); + await showFromAppBar(tester); + checkButtons(); + }); + + testWidgets('show from recipient header', (tester) async { + await prepare(); + await showFromRecipientHeader(tester); + checkButtons(); + }); + }); + + group('UserTopicUpdateButton', () { + late String 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. + /// + /// 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); + + topic = 'isChannelMuted: $isChannelMuted, policy: $visibilityPolicy'; + await prepare( + channel: someChannel, + topic: topic, + isChannelSubscribed: isChannelMuted != null, // shorthand; see dartdoc + isChannelMuted: isChannelMuted, + visibilityPolicy: visibilityPolicy, + zulipFeatureLevel: zulipFeatureLevel, + ); - void checkButtons(List expectedButtonFinders) { - if (expectedButtonFinders.isEmpty) { - check(find.byType(BottomSheet)).findsNothing(); - return; + final message = eg.streamMessage( + stream: someChannel, topic: topic, sender: eg.otherUser); + await showFromAppBar(tester, + channel: someChannel, topic: topic, messages: [message]); } - check(find.byType(BottomSheet)).findsOne(); - for (final buttonFinder in expectedButtonFinders) { - check(buttonFinder).findsOne(); + void checkButtons(List expectedButtonFinders) { + if (expectedButtonFinders.isEmpty) { + check(actionSheetFinder).findsNothing(); + return; + } + check(actionSheetFinder).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), - }); - } + void checkUpdateUserTopicRequest(UserTopicVisibilityPolicy expectedPolicy) async { + check(connection.lastRequest).isA() + ..url.path.equals('/api/v1/user_topics') + ..bodyFields.deepEquals({ + 'stream_id': '${someChannel.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('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); - }); + testWidgets('unmute', (tester) async { + await setupToTopicActionSheet(tester, + isChannelMuted: false, + visibilityPolicy: UserTopicVisibilityPolicy.muted); + await tester.tap(unmute); + 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('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('follow', (tester) async { + await setupToTopicActionSheet(tester, + isChannelMuted: false, + visibilityPolicy: UserTopicVisibilityPolicy.none); + await tester.tap(follow); + await tester.pump(); + checkUpdateUserTopicRequest(UserTopicVisibilityPolicy.followed); + }); - testWidgets('unfollow', (tester) async { - await setupToTopicActionSheet(tester, - isChannelMuted: false, - visibilityPolicy: UserTopicVisibilityPolicy.followed); - await tester.tap(unfollow); - 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('request fails with an error dialog', (tester) async { - await setupToTopicActionSheet(tester, - isChannelMuted: false, - visibilityPolicy: UserTopicVisibilityPolicy.followed); + 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(); + 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: {}); @@ -408,41 +549,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', @@ -453,180 +589,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; - - 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: 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; + 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, starred: true); - await tester.pump(Duration.zero); // error arrives; error dialog shows - - await tester.tap(find.byWidget(checkErrorDialog(tester, - expectedTitle: zulipLocalizations.errorUnstarMessageFailedTitle, - expectedMessage: 'Invalid message(s)'))); }); - }); - - 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; - } + group('QuoteAndReplyButton', () { + ComposeBoxController? findComposeBoxController(WidgetTester tester) { + return tester.stateList(find.byType(ComposeBox)) + .singleOrNull?.controller; + } - /// 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 - } + Widget? findQuoteAndReplyButton(WidgetTester tester) { + return tester.widgetList(find.byIcon(ZulipIcons.format_quote)).singleOrNull; + } - 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); - } + /// 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 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); } - 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'); - }); + 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)); + } - 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); @@ -637,383 +686,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; + 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)); - 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 + check(find.byIcon(Icons.mark_chat_unread_outlined).evaluate()).isEmpty(); + }); - await tester.tap(find.byWidget(checkErrorDialog(tester, - expectedTitle: 'Quotation failed', - expectedMessage: 'That message does not seem to exist.', - ))); + testWidgets('visible if message is read', (tester) async { + final readMessage = eg.streamMessage(flags: [MessageFlag.read]); + await setupToMessageActionSheet(tester, message: readMessage, narrow: TopicNarrow.ofMessage(readMessage)); - 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', + check(find.byIcon(Icons.mark_chat_unread_outlined).evaluate()).single; + }); - // (At the end of the process, we focus the input.) - selection: TextSelection.collapsed(offset: 1), // - )); - }); + 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 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(); - }); + 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 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('shows error when fails', (tester) async { + final message = eg.streamMessage(flags: [MessageFlag.read]); + await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message)); - 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(); - }); - }); + connection.prepare(exception: http.ClientException('Oops')); + final zulipLocalizations = GlobalLocalizations.zulipLocalizations; - 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)); - - 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', - }); - }); - - 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())); + 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('shows error when fails', (tester) async { - final message = eg.streamMessage(flags: [MessageFlag.read]); + 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 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('success', (tester) async { - final message = eg.streamMessage(); - await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message)); - - 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('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 message = eg.streamMessage(); - await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message)); - - // 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; - tester.widget(find.descendant(matchRoot: true, - of: find.byWidget(snackbar.content), - matching: find.text(zulipLocalizations.successMessageTextCopied))); - }); - testWidgets('request has an error', (tester) async { - final message = eg.streamMessage(); - await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message)); + 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 + prepareRawContentResponseError(); + await tapCopyMessageTextButton(tester); + await tester.pump(Duration.zero); // error arrives; error dialog shows - 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(); + 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(); + }); }); - }); - group('CopyMessageLinkButton', () { - setUp(() async { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( - SystemChannels.platform, - MockClipboard().handleMethodCall, - ); - }); + group('CopyMessageLinkButton', () { + setUp(() async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + MockClipboard().handleMethodCall, + ); + }); - 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 - } + 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); + 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); + 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); + }); }); - }); - group('ShareButton', () { - // Tests should call this. - MockSharePlus setupMockSharePlus() { - final mock = MockSharePlus(); - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( - MethodChannelShare.channel, - mock.handleMethodCall, - ); - return mock; - } + 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 - } + 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)); + 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'); - }); + 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); + }); }); }); } 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);