From 25c46715a3b61f3d62d34faef32c6029d09a3d3d Mon Sep 17 00:00:00 2001 From: Abhisheksainii Date: Wed, 19 Feb 2025 13:44:12 +0530 Subject: [PATCH] message-list: write test for guest user DM warning banner guestUserDmWarningBannerFinder will give us the label that needs to be evaluated --- test/widgets/message_list_test.dart | 1580 +++++++++++++++++++-------- 1 file changed, 1117 insertions(+), 463 deletions(-) diff --git a/test/widgets/message_list_test.dart b/test/widgets/message_list_test.dart index 1aa1af81c5..e374d795b9 100644 --- a/test/widgets/message_list_test.dart +++ b/test/widgets/message_list_test.dart @@ -7,6 +7,7 @@ import 'package:flutter/rendering.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/exception.dart'; import 'package:zulip/api/model/events.dart'; import 'package:zulip/api/model/initial_snapshot.dart'; import 'package:zulip/api/model/model.dart'; @@ -47,7 +48,8 @@ void main() { late PerAccountStore store; late FakeApiConnection connection; - Future setupMessageListPage(WidgetTester tester, { + Future setupMessageListPage( + WidgetTester tester, { Narrow narrow = const CombinedFeedNarrow(), bool foundOldest = true, int? messageCount, @@ -62,9 +64,18 @@ void main() { TypingNotifier.debugEnable = false; addTearDown(TypingNotifier.debugReset); addTearDown(testBinding.reset); - streams ??= subscriptions ??= [eg.subscription(eg.stream(streamId: eg.defaultStreamMessageStreamId))]; - await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot( - streams: streams, subscriptions: subscriptions, unreadMsgs: unreadMsgs)); + streams ??= + subscriptions ??= [ + eg.subscription(eg.stream(streamId: eg.defaultStreamMessageStreamId)), + ]; + await testBinding.globalStore.add( + eg.selfAccount, + eg.initialSnapshot( + streams: streams, + subscriptions: subscriptions, + unreadMsgs: unreadMsgs, + ), + ); store = await testBinding.globalStore.perAccount(eg.selfAccount.id); connection = store.connection as FakeApiConnection; @@ -75,66 +86,108 @@ void main() { messages ??= List.generate(messageCount!, (index) { return eg.streamMessage(sender: eg.selfUser); }); - connection.prepare(json: - eg.newestGetMessagesResult(foundOldest: foundOldest, messages: messages).toJson()); + connection.prepare( + json: + eg + .newestGetMessagesResult( + foundOldest: foundOldest, + messages: messages, + ) + .toJson(), + ); - await tester.pumpWidget(TestZulipApp(accountId: eg.selfAccount.id, - skipAssertAccountExists: skipAssertAccountExists, - navigatorObservers: navObservers, - child: MessageListPage(initNarrow: narrow))); + await tester.pumpWidget( + TestZulipApp( + accountId: eg.selfAccount.id, + skipAssertAccountExists: skipAssertAccountExists, + navigatorObservers: navObservers, + child: MessageListPage(initNarrow: narrow), + ), + ); // global store, per-account store, and message list get loaded await tester.pumpAndSettle(); } ScrollController? findMessageListScrollController(WidgetTester tester) { - final scrollView = tester.widget(find.byType(CustomScrollView)); + final scrollView = tester.widget( + find.byType(CustomScrollView), + ); return scrollView.controller; } group('MessageListPage', () { testWidgets('ancestorOf finds page state from message', (tester) async { - await setupMessageListPage(tester, - messages: [eg.streamMessage(content: "

a message

")]); + await setupMessageListPage( + tester, + messages: [eg.streamMessage(content: "

a message

")], + ); final expectedState = tester.state(find.byType(MessageListPage)); - check(MessageListPage.ancestorOf(tester.element(find.text("a message")))) - .identicalTo(expectedState as MessageListPageState); + check( + MessageListPage.ancestorOf(tester.element(find.text("a message"))), + ).identicalTo(expectedState as MessageListPageState); }); - testWidgets('ancestorOf throws when not a descendant of MessageListPage', (tester) async { - await setupMessageListPage(tester, - messages: [eg.streamMessage(content: "

a message

")]); + testWidgets('ancestorOf throws when not a descendant of MessageListPage', ( + tester, + ) async { + await setupMessageListPage( + tester, + messages: [eg.streamMessage(content: "

a message

")], + ); final element = tester.element(find.byType(PerAccountStoreWidget)); - check(() => MessageListPage.ancestorOf(element)) - .throws(); + check(() => MessageListPage.ancestorOf(element)).throws(); }); testWidgets('MessageListPageState.narrow', (tester) async { final stream = eg.stream(); - await setupMessageListPage(tester, narrow: ChannelNarrow(stream.streamId), + await setupMessageListPage( + tester, + narrow: ChannelNarrow(stream.streamId), streams: [stream], - messages: [eg.streamMessage(stream: stream, content: "

a message

")]); - final state = MessageListPage.ancestorOf(tester.element(find.text("a message"))); + messages: [ + eg.streamMessage(stream: stream, content: "

a message

"), + ], + ); + final state = MessageListPage.ancestorOf( + tester.element(find.text("a message")), + ); check(state.narrow).equals(ChannelNarrow(stream.streamId)); }); testWidgets('composeBoxController finds compose box', (tester) async { final stream = eg.stream(); - await setupMessageListPage(tester, narrow: ChannelNarrow(stream.streamId), + await setupMessageListPage( + tester, + narrow: ChannelNarrow(stream.streamId), streams: [stream], - messages: [eg.streamMessage(stream: stream, content: "

a message

")]); - final state = MessageListPage.ancestorOf(tester.element(find.text("a message"))); + messages: [ + eg.streamMessage(stream: stream, content: "

a message

"), + ], + ); + final state = MessageListPage.ancestorOf( + tester.element(find.text("a message")), + ); check(state.composeBoxController).isNotNull(); }); - testWidgets('composeBoxController null when no compose box', (tester) async { - await setupMessageListPage(tester, narrow: const CombinedFeedNarrow(), - messages: [eg.streamMessage(content: "

a message

")]); - final state = MessageListPage.ancestorOf(tester.element(find.text("a message"))); + testWidgets('composeBoxController null when no compose box', ( + tester, + ) async { + await setupMessageListPage( + tester, + narrow: const CombinedFeedNarrow(), + messages: [eg.streamMessage(content: "

a message

")], + ); + final state = MessageListPage.ancestorOf( + tester.element(find.text("a message")), + ); check(state.composeBoxController).isNull(); }); - testWidgets('dispose MessageListView when event queue expired', (tester) async { + testWidgets('dispose MessageListView when event queue expired', ( + tester, + ) async { final message = eg.streamMessage(); await setupMessageListPage(tester, messages: [message]); final oldViewModel = store.debugMessageListViews.single; @@ -143,29 +196,48 @@ void main() { updateMachine.poll(); updateMachine.debugPrepareLoopError( - eg.apiExceptionBadEventQueueId(queueId: updateMachine.queueId)); + ZulipApiException( + routeName: 'events', + httpStatus: 400, + code: 'BAD_EVENT_QUEUE_ID', + data: {'queue_id': updateMachine.queueId}, + message: 'Bad event queue ID.', + ), + ); updateMachine.debugAdvanceLoop(); await tester.pump(); // Event queue has been replaced; but the [MessageList] hasn't been // rebuilt yet. - final newStore = testBinding.globalStore.perAccountSync(eg.selfAccount.id)!; - check(connection.isOpen).isFalse(); // indicates that the old store has been disposed + final newStore = + testBinding.globalStore.perAccountSync(eg.selfAccount.id)!; + check( + connection.isOpen, + ).isFalse(); // indicates that the old store has been disposed check(store.debugMessageListViews).single.equals(oldViewModel); check(newStore.debugMessageListViews).isEmpty(); - (newStore.connection as FakeApiConnection).prepare(json: eg.newestGetMessagesResult( - foundOldest: true, messages: [message]).toJson()); + (newStore.connection as FakeApiConnection).prepare( + json: + eg + .newestGetMessagesResult(foundOldest: true, messages: [message]) + .toJson(), + ); await tester.pump(); await tester.pump(Duration.zero); // As [MessageList] rebuilds, the old view model gets disposed and // replaced with a fresh one. check(store.debugMessageListViews).isEmpty(); - check(newStore.debugMessageListViews).single.not((it) => it.equals(oldViewModel)); + check( + newStore.debugMessageListViews, + ).single.not((it) => it.equals(oldViewModel)); }); testWidgets('dispose MessageListView when logged out', (tester) async { - await setupMessageListPage(tester, - messages: [eg.streamMessage()], skipAssertAccountExists: true); + await setupMessageListPage( + tester, + messages: [eg.streamMessage()], + skipAssertAccountExists: true, + ); check(store.debugMessageListViews).single; final future = logOutAccount(testBinding.globalStore, eg.selfAccount.id); @@ -180,12 +252,17 @@ void main() { testWidgets('has channel-feed action for topic narrows', (tester) async { final pushedRoutes = >[]; - final navObserver = TestNavigatorObserver() - ..onPushed = (route, prevRoute) => pushedRoutes.add(route); + final navObserver = + TestNavigatorObserver() + ..onPushed = (route, prevRoute) => pushedRoutes.add(route); final channel = eg.stream(); - await setupMessageListPage(tester, narrow: eg.topicNarrow(channel.streamId, 'hi'), + await setupMessageListPage( + tester, + narrow: eg.topicNarrow(channel.streamId, 'hi'), navObservers: [navObserver], - streams: [channel], messageCount: 1); + streams: [channel], + messageCount: 1, + ); // Clear out initial route. assert(pushedRoutes.length == 1); @@ -193,47 +270,78 @@ void main() { // Tap button; it works. await tester.tap(find.byIcon(ZulipIcons.message_feed)); - check(pushedRoutes).single.isA() - .page.isA().initNarrow + check(pushedRoutes).single + .isA() + .page + .isA() + .initNarrow .equals(ChannelNarrow(channel.streamId)); }); - testWidgets('show topic visibility policy for topic narrows', (tester) async { + testWidgets('show topic visibility policy for topic narrows', ( + tester, + ) async { final channel = eg.stream(); const topic = 'topic'; - await setupMessageListPage(tester, + await setupMessageListPage( + tester, narrow: eg.topicNarrow(channel.streamId, topic), - streams: [channel], subscriptions: [eg.subscription(channel)], - messageCount: 1); - await store.handleEvent(eg.userTopicEvent( - channel.streamId, topic, UserTopicVisibilityPolicy.muted)); + streams: [channel], + subscriptions: [eg.subscription(channel)], + messageCount: 1, + ); + await store.handleEvent( + eg.userTopicEvent( + channel.streamId, + topic, + UserTopicVisibilityPolicy.muted, + ), + ); await tester.pump(); - check(find.descendant( - of: find.byType(MessageListAppBarTitle), - matching: find.byIcon(ZulipIcons.mute))).findsOne(); + check( + find.descendant( + of: find.byType(MessageListAppBarTitle), + matching: find.byIcon(ZulipIcons.mute), + ), + ).findsOne(); }); }); group('presents message content appropriately', () { - testWidgets('content not asked to consume insets (including bottom), even without compose box', (tester) async { - // Regression test for: https://github.com/zulip/zulip-flutter/issues/736 - const fakePadding = FakeViewPadding(left: 10, top: 10, right: 10, bottom: 10); - tester.view.viewInsets = fakePadding; - tester.view.padding = fakePadding; - - await setupMessageListPage(tester, narrow: const CombinedFeedNarrow(), - messages: [eg.streamMessage(content: ContentExample.codeBlockPlain.html)]); - - // Verify this message list lacks a compose box. - // (The original bug wouldn't reproduce with a compose box present.) - final state = MessageListPage.ancestorOf(tester.element(find.text("verb\natim"))); - check(state.composeBoxController).isNull(); - - final element = tester.element(find.byType(CodeBlock)); - final padding = MediaQuery.of(element).padding; - check(padding).equals(EdgeInsets.zero); - }); + testWidgets( + 'content not asked to consume insets (including bottom), even without compose box', + (tester) async { + // Regression test for: https://github.com/zulip/zulip-flutter/issues/736 + const fakePadding = FakeViewPadding( + left: 10, + top: 10, + right: 10, + bottom: 10, + ); + tester.view.viewInsets = fakePadding; + tester.view.padding = fakePadding; + + await setupMessageListPage( + tester, + narrow: const CombinedFeedNarrow(), + messages: [ + eg.streamMessage(content: ContentExample.codeBlockPlain.html), + ], + ); + + // Verify this message list lacks a compose box. + // (The original bug wouldn't reproduce with a compose box present.) + final state = MessageListPage.ancestorOf( + tester.element(find.text("verb\natim")), + ); + check(state.composeBoxController).isNull(); + + final element = tester.element(find.byType(CodeBlock)); + final padding = MediaQuery.of(element).padding; + check(padding).equals(EdgeInsets.zero); + }, + ); }); testWidgets('smoke test for light/dark/lerped', (tester) async { @@ -245,91 +353,181 @@ void main() { Color backgroundColor() { final coloredBoxFinder = find.descendant( - of: find.byWidgetPredicate((w) => w is MessageItem && w.item.message.id == message.id), + of: find.byWidgetPredicate( + (w) => w is MessageItem && w.item.message.id == message.id, + ), matching: find.byType(ColoredBox), ); final widget = tester.widget(coloredBoxFinder); return widget.color; } - check(backgroundColor()).isSameColorAs(MessageListTheme.light.streamMessageBgDefault); + check( + backgroundColor(), + ).isSameColorAs(MessageListTheme.light.streamMessageBgDefault); tester.platformDispatcher.platformBrightnessTestValue = Brightness.dark; await tester.pump(); await tester.pump(kThemeAnimationDuration * 0.4); - final expectedLerped = MessageListTheme.light.lerp(MessageListTheme.dark, 0.4); - check(backgroundColor()).isSameColorAs(expectedLerped.streamMessageBgDefault); + final expectedLerped = MessageListTheme.light.lerp( + MessageListTheme.dark, + 0.4, + ); + check( + backgroundColor(), + ).isSameColorAs(expectedLerped.streamMessageBgDefault); await tester.pump(kThemeAnimationDuration * 0.6); - check(backgroundColor()).isSameColorAs(MessageListTheme.dark.streamMessageBgDefault); + check( + backgroundColor(), + ).isSameColorAs(MessageListTheme.dark.streamMessageBgDefault); }); group('fetch older messages on scroll', () { int? itemCount(WidgetTester tester) => - tester.widget(find.byType(CustomScrollView)).semanticChildCount; + tester + .widget(find.byType(CustomScrollView)) + .semanticChildCount; testWidgets('basic', (tester) async { - await setupMessageListPage(tester, foundOldest: false, - messages: List.generate(300, (i) => eg.streamMessage(id: 950 + i, sender: eg.selfUser))); + await setupMessageListPage( + tester, + foundOldest: false, + messages: List.generate( + 300, + (i) => eg.streamMessage(id: 950 + i, sender: eg.selfUser), + ), + ); check(itemCount(tester)).equals(303); // Fling-scroll upward... - await tester.fling(find.byType(MessageListPage), const Offset(0, 300), 8000); + await tester.fling( + find.byType(MessageListPage), + const Offset(0, 300), + 8000, + ); await tester.pump(); // ... and we should fetch more messages as we go. - connection.prepare(json: eg.olderGetMessagesResult(anchor: 950, foundOldest: false, - messages: List.generate(100, (i) => eg.streamMessage(id: 850 + i, sender: eg.selfUser))).toJson()); - await tester.pump(const Duration(seconds: 3)); // Fast-forward to end of fling. - await tester.pump(Duration.zero); // Allow a frame for the response to arrive. + connection.prepare( + json: + eg + .olderGetMessagesResult( + anchor: 950, + foundOldest: false, + messages: List.generate( + 100, + (i) => eg.streamMessage(id: 850 + i, sender: eg.selfUser), + ), + ) + .toJson(), + ); + await tester.pump( + const Duration(seconds: 3), + ); // Fast-forward to end of fling. + await tester.pump( + Duration.zero, + ); // Allow a frame for the response to arrive. // Now we have more messages. check(itemCount(tester)).equals(403); }); - testWidgets('observe double-fetch glitch', (tester) async { - await setupMessageListPage(tester, foundOldest: false, - messages: List.generate(100, (i) => eg.streamMessage(id: 950 + i, sender: eg.selfUser))); - check(itemCount(tester)).equals(101); - - // Fling-scroll upward... - await tester.fling(find.byType(MessageListPage), const Offset(0, 300), 8000); - await tester.pump(); - - // ... and we fetch more messages as we go. - connection.prepare(json: eg.olderGetMessagesResult(anchor: 950, foundOldest: false, - messages: List.generate(100, (i) => eg.streamMessage(id: 850 + i, sender: eg.selfUser))).toJson()); - for (int i = 0; i < 30; i++) { - // Find the point in the fling where the fetch starts. - await tester.pump(const Duration(milliseconds: 100)); - if (itemCount(tester)! > 101) break; // The loading indicator appeared. - } - await tester.pump(Duration.zero); // Allow a frame for the response to arrive. - check(itemCount(tester)).equals(201); - - // On the next frame, we promptly fetch *another* batch. - // This is a glitch and it'd be nicer if we didn't. - connection.prepare(json: eg.olderGetMessagesResult(anchor: 850, foundOldest: false, - messages: List.generate(100, (i) => eg.streamMessage(id: 750 + i, sender: eg.selfUser))).toJson()); - await tester.pump(const Duration(milliseconds: 1)); - await tester.pump(Duration.zero); - check(itemCount(tester)).equals(301); - }, skip: true); // TODO this still reproduces manually, still needs debugging, - // but has become harder to reproduce in a test. + testWidgets( + 'observe double-fetch glitch', + (tester) async { + await setupMessageListPage( + tester, + foundOldest: false, + messages: List.generate( + 100, + (i) => eg.streamMessage(id: 950 + i, sender: eg.selfUser), + ), + ); + check(itemCount(tester)).equals(101); + + // Fling-scroll upward... + await tester.fling( + find.byType(MessageListPage), + const Offset(0, 300), + 8000, + ); + await tester.pump(); - testWidgets("avoid getting distracted by nested viewports' metrics", (tester) async { + // ... and we fetch more messages as we go. + connection.prepare( + json: + eg + .olderGetMessagesResult( + anchor: 950, + foundOldest: false, + messages: List.generate( + 100, + (i) => eg.streamMessage(id: 850 + i, sender: eg.selfUser), + ), + ) + .toJson(), + ); + for (int i = 0; i < 30; i++) { + // Find the point in the fling where the fetch starts. + await tester.pump(const Duration(milliseconds: 100)); + if (itemCount(tester)! > 101) + break; // The loading indicator appeared. + } + await tester.pump( + Duration.zero, + ); // Allow a frame for the response to arrive. + check(itemCount(tester)).equals(201); + + // On the next frame, we promptly fetch *another* batch. + // This is a glitch and it'd be nicer if we didn't. + connection.prepare( + json: + eg + .olderGetMessagesResult( + anchor: 850, + foundOldest: false, + messages: List.generate( + 100, + (i) => eg.streamMessage(id: 750 + i, sender: eg.selfUser), + ), + ) + .toJson(), + ); + await tester.pump(const Duration(milliseconds: 1)); + await tester.pump(Duration.zero); + check(itemCount(tester)).equals(301); + }, + skip: true, + ); // TODO this still reproduces manually, still needs debugging, + // but has become harder to reproduce in a test. + + testWidgets("avoid getting distracted by nested viewports' metrics", ( + tester, + ) async { // Regression test for: https://github.com/zulip/zulip-flutter/issues/507 - await setupMessageListPage(tester, foundOldest: false, messages: [ - ...List.generate(300, (i) => eg.streamMessage(id: 1000 + i)), - eg.streamMessage(id: 1301, content: ContentExample.codeBlockPlain.html), - ...List.generate(100, (i) => eg.streamMessage(id: 1302 + i)), - ]); + await setupMessageListPage( + tester, + foundOldest: false, + messages: [ + ...List.generate(300, (i) => eg.streamMessage(id: 1000 + i)), + eg.streamMessage( + id: 1301, + content: ContentExample.codeBlockPlain.html, + ), + ...List.generate(100, (i) => eg.streamMessage(id: 1302 + i)), + ], + ); final lastRequest = connection.lastRequest; check(itemCount(tester)).equals(404); // Fling-scroll upward... - await tester.fling(find.byType(MessageListPage), const Offset(0, 300), 8000); + await tester.fling( + find.byType(MessageListPage), + const Offset(0, 300), + 8000, + ); await tester.pump(); // ... in particular past the message with a [CodeBlockNode]... @@ -352,9 +550,12 @@ void main() { group('ScrollToBottomButton interactions', () { bool isButtonVisible(WidgetTester tester) { - return tester.any(find.descendant( - of: find.byType(ScrollToBottomButton), - matching: find.byTooltip("Scroll to bottom"))); + return tester.any( + find.descendant( + of: find.byType(ScrollToBottomButton), + matching: find.byTooltip("Scroll to bottom"), + ), + ); } testWidgets('scrolling changes visibility', (tester) async { @@ -419,46 +620,69 @@ void main() { final users = [eg.selfUser, eg.otherUser, eg.thirdUser, eg.fourthUser]; final finder = find.descendant( of: find.byType(TypingStatusWidget), - matching: find.byType(Text) + matching: find.byType(Text), ); - Future checkTyping(WidgetTester tester, TypingEvent event, {required String expected}) async { + Future checkTyping( + WidgetTester tester, + TypingEvent event, { + required String expected, + }) async { await store.handleEvent(event); await tester.pump(); check(tester.widget(finder)).data.equals(expected); } final dmMessage = eg.dmMessage( - from: eg.selfUser, to: [eg.otherUser, eg.thirdUser, eg.fourthUser]); - final dmNarrow = DmNarrow.ofMessage(dmMessage, selfUserId: eg.selfUser.userId); + from: eg.selfUser, + to: [eg.otherUser, eg.thirdUser, eg.fourthUser], + ); + final dmNarrow = DmNarrow.ofMessage( + dmMessage, + selfUserId: eg.selfUser.userId, + ); final streamMessage = eg.streamMessage(); final topicNarrow = TopicNarrow.ofMessage(streamMessage); for (final (description, message, narrow) in [ - ('typing in dm', dmMessage, dmNarrow), - ('typing in topic', streamMessage, topicNarrow), + ('typing in dm', dmMessage, dmNarrow), + ('typing in topic', streamMessage, topicNarrow), ]) { testWidgets(description, (tester) async { - await setupMessageListPage(tester, - narrow: narrow, users: users, messages: [message]); + await setupMessageListPage( + tester, + narrow: narrow, + users: users, + messages: [message], + ); await tester.pump(); check(finder.evaluate()).isEmpty(); - await checkTyping(tester, + await checkTyping( + tester, eg.typingEvent(narrow, TypingOp.start, eg.otherUser.userId), - expected: 'Other User is typing…'); - await checkTyping(tester, + expected: 'Other User is typing…', + ); + await checkTyping( + tester, eg.typingEvent(narrow, TypingOp.start, eg.selfUser.userId), - expected: 'Other User is typing…'); - await checkTyping(tester, + expected: 'Other User is typing…', + ); + await checkTyping( + tester, eg.typingEvent(narrow, TypingOp.start, eg.thirdUser.userId), - expected: 'Other User and Third User are typing…'); - await checkTyping(tester, + expected: 'Other User and Third User are typing…', + ); + await checkTyping( + tester, eg.typingEvent(narrow, TypingOp.start, eg.fourthUser.userId), - expected: 'Several people are typing…'); - await checkTyping(tester, + expected: 'Several people are typing…', + ); + await checkTyping( + tester, eg.typingEvent(narrow, TypingOp.stop, eg.otherUser.userId), - expected: 'Third User and Fourth User are typing…'); + expected: 'Third User and Fourth User are typing…', + ); // Verify that typing indicators expire after a set duration. await tester.pump(const Duration(seconds: 15)); check(finder.evaluate()).isEmpty(); @@ -468,9 +692,14 @@ void main() { testWidgets('unknown user typing', (tester) async { final streamMessage = eg.streamMessage(); final narrow = TopicNarrow.ofMessage(streamMessage); - await setupMessageListPage(tester, - narrow: narrow, users: [], messages: [streamMessage]); - await checkTyping(tester, + await setupMessageListPage( + tester, + narrow: narrow, + users: [], + messages: [streamMessage], + ); + await checkTyping( + tester, eg.typingEvent(narrow, TypingOp.start, 1000), expected: '(unknown user) is typing…', ); @@ -482,8 +711,8 @@ void main() { group('MarkAsReadWidget', () { bool isMarkAsReadButtonVisible(WidgetTester tester) { final zulipLocalizations = GlobalLocalizations.zulipLocalizations; - final finder = find.text( - zulipLocalizations.markAllAsReadLabel).hitTestable(); + final finder = + find.text(zulipLocalizations.markAllAsReadLabel).hitTestable(); return finder.evaluate().isNotEmpty; } @@ -492,48 +721,71 @@ void main() { await setupMessageListPage(tester, messages: [message]); check(isMarkAsReadButtonVisible(tester)).isFalse(); - await store.handleEvent(eg.updateMessageFlagsRemoveEvent( - MessageFlag.read, [message])); + await store.handleEvent( + eg.updateMessageFlagsRemoveEvent(MessageFlag.read, [message]), + ); await tester.pumpAndSettle(); check(isMarkAsReadButtonVisible(tester)).isTrue(); }); testWidgets('from unread to read', (tester) async { final message = eg.streamMessage(flags: []); - final unreadMsgs = eg.unreadMsgs(channels:[ - UnreadChannelSnapshot(topic: message.topic, streamId: message.streamId, unreadMessageIds: [message.id]) - ]); - await setupMessageListPage(tester, messages: [message], unreadMsgs: unreadMsgs); + final unreadMsgs = eg.unreadMsgs( + channels: [ + UnreadChannelSnapshot( + topic: message.topic, + streamId: message.streamId, + unreadMessageIds: [message.id], + ), + ], + ); + await setupMessageListPage( + tester, + messages: [message], + unreadMsgs: unreadMsgs, + ); check(isMarkAsReadButtonVisible(tester)).isTrue(); - await store.handleEvent(UpdateMessageFlagsAddEvent( - id: 1, - flag: MessageFlag.read, - messages: [message.id], - all: false, - )); + await store.handleEvent( + UpdateMessageFlagsAddEvent( + id: 1, + flag: MessageFlag.read, + messages: [message.id], + all: false, + ), + ); await tester.pumpAndSettle(); check(isMarkAsReadButtonVisible(tester)).isFalse(); }); testWidgets("messages don't shift position", (tester) async { final message = eg.streamMessage(flags: []); - final unreadMsgs = eg.unreadMsgs(channels:[ - UnreadChannelSnapshot(topic: message.topic, streamId: message.streamId, - unreadMessageIds: [message.id]) - ]); - await setupMessageListPage(tester, - messages: [message], unreadMsgs: unreadMsgs); + final unreadMsgs = eg.unreadMsgs( + channels: [ + UnreadChannelSnapshot( + topic: message.topic, + streamId: message.streamId, + unreadMessageIds: [message.id], + ), + ], + ); + await setupMessageListPage( + tester, + messages: [message], + unreadMsgs: unreadMsgs, + ); check(isMarkAsReadButtonVisible(tester)).isTrue(); check(tester.widgetList(find.byType(MessageItem))).length.equals(1); final before = tester.getTopLeft(find.byType(MessageItem)).dy; - await store.handleEvent(UpdateMessageFlagsAddEvent( - id: 1, - flag: MessageFlag.read, - messages: [message.id], - all: false, - )); + await store.handleEvent( + UpdateMessageFlagsAddEvent( + id: 1, + flag: MessageFlag.read, + messages: [message.id], + all: false, + ), + ); await tester.pumpAndSettle(); check(isMarkAsReadButtonVisible(tester)).isFalse(); check(tester.widgetList(find.byType(MessageItem))).length.equals(1); @@ -547,36 +799,57 @@ void main() { // and a couple of smoke tests showing this button is wired up to it. final message = eg.streamMessage(flags: []); - final unreadMsgs = eg.unreadMsgs(channels: [ - UnreadChannelSnapshot(streamId: message.streamId, topic: message.topic, - unreadMessageIds: [message.id]), - ]); + final unreadMsgs = eg.unreadMsgs( + channels: [ + UnreadChannelSnapshot( + streamId: message.streamId, + topic: message.topic, + unreadMessageIds: [message.id], + ), + ], + ); group('MarkAsReadAnimation', () { void checkAppearsLoading(WidgetTester tester, bool expected) { - final semantics = tester.firstWidget(find.descendant( - of: find.byType(MarkAsReadWidget), - matching: find.byType(Semantics))); + final semantics = tester.firstWidget( + find.descendant( + of: find.byType(MarkAsReadWidget), + matching: find.byType(Semantics), + ), + ); check(semantics.properties.enabled).equals(!expected); - final opacity = tester.widget(find.descendant( - of: find.byType(MarkAsReadWidget), - matching: find.byType(AnimatedOpacity))); + final opacity = tester.widget( + find.descendant( + of: find.byType(MarkAsReadWidget), + matching: find.byType(AnimatedOpacity), + ), + ); check(opacity.opacity).equals(expected ? 0.5 : 1.0); } testWidgets('loading is changed correctly', (tester) async { final narrow = TopicNarrow.ofMessage(message); - await setupMessageListPage(tester, - narrow: narrow, messages: [message], unreadMsgs: unreadMsgs); + await setupMessageListPage( + tester, + narrow: narrow, + messages: [message], + unreadMsgs: unreadMsgs, + ); check(isMarkAsReadButtonVisible(tester)).isTrue(); connection.prepare( delay: const Duration(milliseconds: 2000), - json: UpdateMessageFlagsForNarrowResult( - processedCount: 11, updatedCount: 3, - firstProcessedId: null, lastProcessedId: null, - foundOldest: true, foundNewest: true).toJson()); + json: + UpdateMessageFlagsForNarrowResult( + processedCount: 11, + updatedCount: 3, + firstProcessedId: null, + lastProcessedId: null, + foundOldest: true, + foundNewest: true, + ).toJson(), + ); checkAppearsLoading(tester, false); @@ -588,11 +861,27 @@ void main() { checkAppearsLoading(tester, false); }); - testWidgets('loading is changed correctly if request fails', (tester) async { + testWidgets('loading is changed correctly if request fails', ( + tester, + ) async { final narrow = TopicNarrow.ofMessage(message); - await setupMessageListPage(tester, - narrow: narrow, messages: [message], unreadMsgs: unreadMsgs); + await setupMessageListPage( + tester, + narrow: narrow, + messages: [message], + unreadMsgs: unreadMsgs, + ); check(isMarkAsReadButtonVisible(tester)).isTrue(); + + connection.prepare( + httpStatus: 400, + json: { + 'code': 'BAD_REQUEST', + 'msg': 'Invalid message(s)', + 'result': 'error', + }, + ); + checkAppearsLoading(tester, false); connection.prepare( @@ -608,28 +897,40 @@ void main() { testWidgets('smoke test on modern server', (tester) async { final narrow = TopicNarrow.ofMessage(message); - await setupMessageListPage(tester, - narrow: narrow, messages: [message], unreadMsgs: unreadMsgs); + await setupMessageListPage( + tester, + narrow: narrow, + messages: [message], + unreadMsgs: unreadMsgs, + ); check(isMarkAsReadButtonVisible(tester)).isTrue(); - connection.prepare(json: UpdateMessageFlagsForNarrowResult( - processedCount: 11, updatedCount: 3, - firstProcessedId: null, lastProcessedId: null, - foundOldest: true, foundNewest: true).toJson()); + connection.prepare( + json: + UpdateMessageFlagsForNarrowResult( + processedCount: 11, + updatedCount: 3, + firstProcessedId: null, + lastProcessedId: null, + foundOldest: true, + foundNewest: true, + ).toJson(), + ); await tester.tap(find.byType(MarkAsReadWidget)); - final apiNarrow = narrow.apiEncode()..add(ApiNarrowIs(IsOperand.unread)); + final apiNarrow = + narrow.apiEncode()..add(ApiNarrowIs(IsOperand.unread)); check(connection.lastRequest).isA() ..method.equals('POST') ..url.path.equals('/api/v1/messages/flags/narrow') ..bodyFields.deepEquals({ - 'anchor': 'oldest', - 'include_anchor': 'false', - 'num_before': '0', - 'num_after': '1000', - 'narrow': jsonEncode(apiNarrow), - 'op': 'add', - 'flag': 'read', - }); + 'anchor': 'oldest', + 'include_anchor': 'false', + 'num_before': '0', + 'num_after': '1000', + 'narrow': jsonEncode(apiNarrow), + 'op': 'add', + 'flag': 'read', + }); await tester.pumpAndSettle(); // process pending timers }); @@ -638,24 +939,42 @@ void main() { // Check that `lastProcessedId` returned from an initial // response is used as `anchorId` for the subsequent request. final narrow = TopicNarrow.ofMessage(message); - await setupMessageListPage(tester, - narrow: narrow, messages: [message], unreadMsgs: unreadMsgs); + await setupMessageListPage( + tester, + narrow: narrow, + messages: [message], + unreadMsgs: unreadMsgs, + ); check(isMarkAsReadButtonVisible(tester)).isTrue(); - connection.prepare(json: UpdateMessageFlagsForNarrowResult( - processedCount: 1000, updatedCount: 890, - firstProcessedId: 1, lastProcessedId: 1989, - foundOldest: true, foundNewest: false).toJson()); + connection.prepare( + json: + UpdateMessageFlagsForNarrowResult( + processedCount: 1000, + updatedCount: 890, + firstProcessedId: 1, + lastProcessedId: 1989, + foundOldest: true, + foundNewest: false, + ).toJson(), + ); await tester.tap(find.byType(MarkAsReadWidget)); check(connection.lastRequest).isA() ..method.equals('POST') ..url.path.equals('/api/v1/messages/flags/narrow') ..bodyFields['anchor'].equals('oldest'); - connection.prepare(json: UpdateMessageFlagsForNarrowResult( - processedCount: 20, updatedCount: 10, - firstProcessedId: 2000, lastProcessedId: 2023, - foundOldest: false, foundNewest: true).toJson()); + connection.prepare( + json: + UpdateMessageFlagsForNarrowResult( + processedCount: 20, + updatedCount: 10, + firstProcessedId: 2000, + lastProcessedId: 2023, + foundOldest: false, + foundNewest: true, + ).toJson(), + ); await tester.pumpAndSettle(); check(find.bySubtype().evaluate()).length.equals(1); check(connection.lastRequest).isA() @@ -664,35 +983,55 @@ void main() { ..bodyFields['anchor'].equals('1989'); }); - testWidgets('markNarrowAsRead on mark-all-as-read when Unreads.oldUnreadsMissing: true', (tester) async { - const narrow = CombinedFeedNarrow(); - await setupMessageListPage(tester, - narrow: narrow, messages: [message], unreadMsgs: unreadMsgs); - check(isMarkAsReadButtonVisible(tester)).isTrue(); - store.unreads.oldUnreadsMissing = true; + testWidgets( + 'markNarrowAsRead on mark-all-as-read when Unreads.oldUnreadsMissing: true', + (tester) async { + const narrow = CombinedFeedNarrow(); + await setupMessageListPage( + tester, + narrow: narrow, + messages: [message], + unreadMsgs: unreadMsgs, + ); + check(isMarkAsReadButtonVisible(tester)).isTrue(); + store.unreads.oldUnreadsMissing = true; - connection.prepare(json: UpdateMessageFlagsForNarrowResult( - processedCount: 11, updatedCount: 3, - firstProcessedId: null, lastProcessedId: null, - foundOldest: true, foundNewest: true).toJson()); - await tester.tap(find.byType(MarkAsReadWidget)); - await tester.pumpAndSettle(); - check(store.unreads.oldUnreadsMissing).isFalse(); - }); + connection.prepare( + json: + UpdateMessageFlagsForNarrowResult( + processedCount: 11, + updatedCount: 3, + firstProcessedId: null, + lastProcessedId: null, + foundOldest: true, + foundNewest: true, + ).toJson(), + ); + await tester.tap(find.byType(MarkAsReadWidget)); + await tester.pumpAndSettle(); + check(store.unreads.oldUnreadsMissing).isFalse(); + }, + ); testWidgets('catch-all api errors', (tester) async { final zulipLocalizations = GlobalLocalizations.zulipLocalizations; const narrow = CombinedFeedNarrow(); - await setupMessageListPage(tester, - narrow: narrow, messages: [message], unreadMsgs: unreadMsgs); + await setupMessageListPage( + tester, + narrow: narrow, + messages: [message], + unreadMsgs: unreadMsgs, + ); check(isMarkAsReadButtonVisible(tester)).isTrue(); connection.prepare(httpException: http.ClientException('Oops')); await tester.tap(find.byType(MarkAsReadWidget)); await tester.pumpAndSettle(); - checkErrorDialog(tester, + checkErrorDialog( + tester, expectedTitle: zulipLocalizations.errorMarkAsReadFailedTitle, - expectedMessage: 'NetworkException: Oops (ClientException: Oops)'); + expectedMessage: 'NetworkException: Oops (ClientException: Oops)', + ); }); }); }); @@ -704,36 +1043,65 @@ void main() { final narrow = eg.topicNarrow(channel.streamId, topic); void prepareGetMessageResponse(List messages) { - connection.prepare(json: eg.newestGetMessagesResult( - foundOldest: false, messages: messages).toJson()); + connection.prepare( + json: + eg + .newestGetMessagesResult(foundOldest: false, messages: messages) + .toJson(), + ); } - void handleMessageMoveEvent(List messages, String newTopic, {int? newChannelId}) { - store.handleEvent(eg.updateMessageEventMoveFrom( - origMessages: messages, - newTopicStr: newTopic, - newStreamId: newChannelId, - propagateMode: PropagateMode.changeAll)); + void handleMessageMoveEvent( + List messages, + String newTopic, { + int? newChannelId, + }) { + store.handleEvent( + eg.updateMessageEventMoveFrom( + origMessages: messages, + newTopicStr: newTopic, + newStreamId: newChannelId, + propagateMode: PropagateMode.changeAll, + ), + ); } testWidgets('compose box send message after move', (tester) async { - final message = eg.streamMessage(stream: channel, topic: topic, content: 'Message to move'); - await setupMessageListPage(tester, narrow: narrow, messages: [message], streams: [channel, otherChannel]); + final message = eg.streamMessage( + stream: channel, + topic: topic, + content: 'Message to move', + ); + await setupMessageListPage( + tester, + narrow: narrow, + messages: [message], + streams: [channel, otherChannel], + ); final channelContentInputFinder = find.descendant( of: find.byType(ComposeAutocomplete), - matching: find.byType(TextField)); + matching: find.byType(TextField), + ); await tester.enterText(channelContentInputFinder, 'Some text'); check(tester.widget(channelContentInputFinder)) - ..decoration.isNotNull().hintText.equals('Message #${channel.name} > $topic') + ..decoration.isNotNull().hintText.equals( + 'Message #${channel.name} > $topic', + ) ..controller.isNotNull().text.equals('Some text'); prepareGetMessageResponse([message]); - handleMessageMoveEvent([message], 'new topic', newChannelId: otherChannel.streamId); + handleMessageMoveEvent( + [message], + 'new topic', + newChannelId: otherChannel.streamId, + ); await tester.pump(const Duration(seconds: 1)); check(tester.widget(channelContentInputFinder)) - ..decoration.isNotNull().hintText.equals('Message #${otherChannel.name} > new topic') + ..decoration.isNotNull().hintText.equals( + 'Message #${otherChannel.name} > new topic', + ) ..controller.isNotNull().text.equals('Some text'); connection.prepare(json: SendMessageResult(id: 1).toJson()); @@ -747,41 +1115,75 @@ void main() { 'to': '${otherChannel.streamId}', 'topic': 'new topic', 'content': 'Some text', - 'read_by_sender': 'true'}); + 'read_by_sender': 'true', + }); await tester.pumpAndSettle(); }); testWidgets('Move to narrow with existing messages', (tester) async { - final message = eg.streamMessage(stream: channel, topic: topic, content: 'Message to move'); - await setupMessageListPage(tester, narrow: narrow, messages: [message], streams: [channel]); - check(find.textContaining('Existing message').evaluate()).length.equals(0); + final message = eg.streamMessage( + stream: channel, + topic: topic, + content: 'Message to move', + ); + await setupMessageListPage( + tester, + narrow: narrow, + messages: [message], + streams: [channel], + ); + check( + find.textContaining('Existing message').evaluate(), + ).length.equals(0); check(find.textContaining('Message to move').evaluate()).length.equals(1); final existingMessage = eg.streamMessage( - stream: eg.stream(), topic: 'new topic', content: 'Existing message'); + stream: eg.stream(), + topic: 'new topic', + content: 'Existing message', + ); prepareGetMessageResponse([existingMessage, message]); handleMessageMoveEvent([message], 'new topic'); await tester.pump(const Duration(seconds: 1)); - check(find.textContaining('Existing message').evaluate()).length.equals(1); + check( + find.textContaining('Existing message').evaluate(), + ).length.equals(1); check(find.textContaining('Message to move').evaluate()).length.equals(1); }); testWidgets('show new topic in TopicNarrow after move', (tester) async { - final message = eg.streamMessage(stream: channel, topic: topic, content: 'Message to move'); - await setupMessageListPage(tester, narrow: narrow, messages: [message], streams: [channel]); + final message = eg.streamMessage( + stream: channel, + topic: topic, + content: 'Message to move', + ); + await setupMessageListPage( + tester, + narrow: narrow, + messages: [message], + streams: [channel], + ); prepareGetMessageResponse([message]); handleMessageMoveEvent([message], 'new topic'); await tester.pump(const Duration(seconds: 1)); - check(find.descendant( - of: find.byType(RecipientHeader), - matching: find.text('new topic')).evaluate() + check( + find + .descendant( + of: find.byType(RecipientHeader), + matching: find.text('new topic'), + ) + .evaluate(), ).length.equals(1); - check(find.descendant( - of: find.byType(MessageListAppBarTitle), - matching: find.text('new topic')).evaluate() + check( + find + .descendant( + of: find.byType(MessageListAppBarTitle), + matching: find.text('new topic'), + ) + .evaluate(), ).length.equals(1); }); }); @@ -796,220 +1198,320 @@ void main() { FinderResult findInMessageList(String text) { // Stream name shows up in [AppBar] so need to avoid matching that - return find.descendant( - of: find.byType(MessageList), - matching: find.text(text)).evaluate(); + return find + .descendant(of: find.byType(MessageList), matching: find.text(text)) + .evaluate(); } testWidgets('show stream name in CombinedFeedNarrow', (tester) async { - await setupMessageListPage(tester, + await setupMessageListPage( + tester, narrow: const CombinedFeedNarrow(), - messages: [message], subscriptions: [eg.subscription(stream)]); + messages: [message], + subscriptions: [eg.subscription(stream)], + ); await tester.pump(); check(findInMessageList('stream name')).length.equals(1); check(findInMessageList('topic name')).length.equals(1); }); testWidgets('show channel name in MentionsNarrow', (tester) async { - await setupMessageListPage(tester, + await setupMessageListPage( + tester, narrow: const MentionsNarrow(), - messages: [message], subscriptions: [eg.subscription(stream)]); + messages: [message], + subscriptions: [eg.subscription(stream)], + ); await tester.pump(); check(findInMessageList('stream name')).length.equals(1); check(findInMessageList('topic name')).length.equals(1); }); testWidgets('show channel name in StarredMessagesNarrow', (tester) async { - await setupMessageListPage(tester, + await setupMessageListPage( + tester, narrow: const StarredMessagesNarrow(), - messages: [message], subscriptions: [eg.subscription(stream)]); + messages: [message], + subscriptions: [eg.subscription(stream)], + ); await tester.pump(); check(findInMessageList('stream name')).length.equals(1); check(findInMessageList('topic name')).length.equals(1); }); testWidgets('do not show channel name in ChannelNarrow', (tester) async { - await setupMessageListPage(tester, + await setupMessageListPage( + tester, narrow: ChannelNarrow(stream.streamId), - messages: [message], streams: [stream]); + messages: [message], + streams: [stream], + ); await tester.pump(); check(findInMessageList('stream name')).length.equals(0); check(findInMessageList('topic name')).length.equals(1); }); testWidgets('do not show stream name in TopicNarrow', (tester) async { - await setupMessageListPage(tester, + await setupMessageListPage( + tester, narrow: TopicNarrow.ofMessage(message), - messages: [message], streams: [stream]); + messages: [message], + streams: [stream], + ); await tester.pump(); check(findInMessageList('stream name')).length.equals(0); check(findInMessageList('topic name')).length.equals(1); }); testWidgets('show topic visibility icon when followed', (tester) async { - await setupMessageListPage(tester, + await setupMessageListPage( + tester, narrow: const CombinedFeedNarrow(), - messages: [message], subscriptions: [eg.subscription(stream)]); - await store.handleEvent(eg.userTopicEvent( - stream.streamId, topic, UserTopicVisibilityPolicy.followed)); + messages: [message], + subscriptions: [eg.subscription(stream)], + ); + await store.handleEvent( + eg.userTopicEvent( + stream.streamId, + topic, + UserTopicVisibilityPolicy.followed, + ), + ); await tester.pump(); - check(find.descendant( - of: find.byType(MessageList), - matching: find.byIcon(ZulipIcons.follow))).findsOne(); + check( + find.descendant( + of: find.byType(MessageList), + matching: find.byIcon(ZulipIcons.follow), + ), + ).findsOne(); }); testWidgets('show topic visibility icon when unmuted', (tester) async { - await setupMessageListPage(tester, + await setupMessageListPage( + tester, narrow: TopicNarrow.ofMessage(message), - messages: [message], subscriptions: [eg.subscription(stream, isMuted: true)]); - await store.handleEvent(eg.userTopicEvent( - stream.streamId, topic, UserTopicVisibilityPolicy.unmuted)); + messages: [message], + subscriptions: [eg.subscription(stream, isMuted: true)], + ); + await store.handleEvent( + eg.userTopicEvent( + stream.streamId, + topic, + UserTopicVisibilityPolicy.unmuted, + ), + ); await tester.pump(); - check(find.descendant( - of: find.byType(MessageList), - matching: find.byIcon(ZulipIcons.unmute))).findsOne(); + check( + find.descendant( + of: find.byType(MessageList), + matching: find.byIcon(ZulipIcons.unmute), + ), + ).findsOne(); }); testWidgets('color of recipient header background', (tester) async { final subscription = eg.subscription(stream, color: Colors.red.argbInt); final swatch = ChannelColorSwatch.light(subscription.color); - await setupMessageListPage(tester, + await setupMessageListPage( + tester, messages: [eg.streamMessage(stream: subscription)], - subscriptions: [subscription]); + subscriptions: [subscription], + ); await tester.pump(); - check(tester.widget( - find.descendant( - of: find.byType(StreamMessageRecipientHeader), - matching: find.byType(ColoredBox), - ))).color.isNotNull().isSameColorAs(swatch.barBackground); + check( + tester.widget( + find.descendant( + of: find.byType(StreamMessageRecipientHeader), + matching: find.byType(ColoredBox), + ), + ), + ).color.isNotNull().isSameColorAs(swatch.barBackground); }); testWidgets('color of stream icon', (tester) async { final stream = eg.stream(isWebPublic: true); final subscription = eg.subscription(stream, color: Colors.red.argbInt); final swatch = ChannelColorSwatch.light(subscription.color); - await setupMessageListPage(tester, + await setupMessageListPage( + tester, messages: [eg.streamMessage(stream: subscription)], - subscriptions: [subscription]); + subscriptions: [subscription], + ); await tester.pump(); - check(tester.widget(find.byIcon(ZulipIcons.globe))) - .color.isNotNull().isSameColorAs(swatch.iconOnBarBackground); + check( + tester.widget(find.byIcon(ZulipIcons.globe)), + ).color.isNotNull().isSameColorAs(swatch.iconOnBarBackground); }); testWidgets('normal streams show hash icon', (tester) async { final stream = eg.stream(isWebPublic: false, inviteOnly: false); - await setupMessageListPage(tester, + await setupMessageListPage( + tester, messages: [eg.streamMessage(stream: stream)], - subscriptions: [eg.subscription(stream)]); + subscriptions: [eg.subscription(stream)], + ); await tester.pump(); - check(find.descendant( - of: find.byType(StreamMessageRecipientHeader), - matching: find.byIcon(ZulipIcons.hash_sign), - ).evaluate()).length.equals(1); + check( + find + .descendant( + of: find.byType(StreamMessageRecipientHeader), + matching: find.byIcon(ZulipIcons.hash_sign), + ) + .evaluate(), + ).length.equals(1); }); testWidgets('public streams show globe icon', (tester) async { final stream = eg.stream(isWebPublic: true); - await setupMessageListPage(tester, + await setupMessageListPage( + tester, messages: [eg.streamMessage(stream: stream)], - subscriptions: [eg.subscription(stream)]); + subscriptions: [eg.subscription(stream)], + ); await tester.pump(); - check(find.descendant( - of: find.byType(StreamMessageRecipientHeader), - matching: find.byIcon(ZulipIcons.globe), - ).evaluate()).length.equals(1); + check( + find + .descendant( + of: find.byType(StreamMessageRecipientHeader), + matching: find.byIcon(ZulipIcons.globe), + ) + .evaluate(), + ).length.equals(1); }); testWidgets('private streams show lock icon', (tester) async { final stream = eg.stream(inviteOnly: true); - await setupMessageListPage(tester, + await setupMessageListPage( + tester, messages: [eg.streamMessage(stream: stream)], - subscriptions: [eg.subscription(stream)]); + subscriptions: [eg.subscription(stream)], + ); await tester.pump(); - check(find.descendant( - of: find.byType(StreamMessageRecipientHeader), - matching: find.byIcon(ZulipIcons.lock), - ).evaluate()).length.equals(1); + check( + find + .descendant( + of: find.byType(StreamMessageRecipientHeader), + matching: find.byIcon(ZulipIcons.lock), + ) + .evaluate(), + ).length.equals(1); }); - testWidgets('show stream name from message when stream unknown', (tester) async { - // This can perfectly well happen, because message fetches can race - // with events. - // … Though not actually with CombinedFeedNarrow, because that shows - // stream messages only in subscribed streams, hence only known streams. - // See skip comment below. - final stream = eg.stream(name: 'stream name'); - await setupMessageListPage(tester, - narrow: const CombinedFeedNarrow(), - subscriptions: [], - messages: [ - eg.streamMessage(stream: stream), - ]); - await tester.pump(); - tester.widget(find.text('stream name')); - }, skip: true); // TODO(#252) could repro this with search narrows, once we have those + testWidgets( + 'show stream name from message when stream unknown', + (tester) async { + // This can perfectly well happen, because message fetches can race + // with events. + // … Though not actually with CombinedFeedNarrow, because that shows + // stream messages only in subscribed streams, hence only known streams. + // See skip comment below. + final stream = eg.stream(name: 'stream name'); + await setupMessageListPage( + tester, + narrow: const CombinedFeedNarrow(), + subscriptions: [], + messages: [eg.streamMessage(stream: stream)], + ); + await tester.pump(); + tester.widget(find.text('stream name')); + }, + skip: true, + ); // TODO(#252) could repro this with search narrows, once we have those - testWidgets('show stream name from stream data when known', (tester) async { + testWidgets('show stream name from stream data when known', ( + tester, + ) async { final streamBefore = eg.stream(name: 'old stream name'); // TODO(#182) this test would be more realistic using a ChannelUpdateEvent final streamAfter = ZulipStream.fromJson({ ...(deepToJson(streamBefore) as Map), 'name': 'new stream name', }); - await setupMessageListPage(tester, + await setupMessageListPage( + tester, narrow: const CombinedFeedNarrow(), subscriptions: [eg.subscription(streamAfter)], - messages: [ - eg.streamMessage(stream: streamBefore), - ]); + messages: [eg.streamMessage(stream: streamBefore)], + ); await tester.pump(); tester.widget(find.text('new stream name')); }); - testWidgets('navigates to TopicNarrow on tapping topic in ChannelNarrow', (tester) async { - final pushedRoutes = >[]; - final navObserver = TestNavigatorObserver() - ..onPushed = (route, prevRoute) => pushedRoutes.add(route); - final channel = eg.stream(); - final message = eg.streamMessage(stream: channel, topic: 'topic name'); - await setupMessageListPage(tester, - narrow: ChannelNarrow(channel.streamId), - streams: [channel], - messages: [message], - navObservers: [navObserver]); + testWidgets( + 'navigates to TopicNarrow on tapping topic in ChannelNarrow', + (tester) async { + final pushedRoutes = >[]; + final navObserver = + TestNavigatorObserver() + ..onPushed = (route, prevRoute) => pushedRoutes.add(route); + final channel = eg.stream(); + final message = eg.streamMessage( + stream: channel, + topic: 'topic name', + ); + await setupMessageListPage( + tester, + narrow: ChannelNarrow(channel.streamId), + streams: [channel], + messages: [message], + navObservers: [navObserver], + ); + + assert(pushedRoutes.length == 1); + pushedRoutes.clear(); - assert(pushedRoutes.length == 1); - pushedRoutes.clear(); - - connection.prepare(json: eg.newestGetMessagesResult( - foundOldest: true, messages: [message]).toJson()); - await tester.tap(find.descendant( - of: find.byType(StreamMessageRecipientHeader), - matching: find.text('topic name'))); - await tester.pump(); - check(pushedRoutes).single.isA().page.isA() - .initNarrow.equals(TopicNarrow.ofMessage(message)); - await tester.pumpAndSettle(); - }); + connection.prepare( + json: + eg + .newestGetMessagesResult( + foundOldest: true, + messages: [message], + ) + .toJson(), + ); + await tester.tap( + find.descendant( + of: find.byType(StreamMessageRecipientHeader), + matching: find.text('topic name'), + ), + ); + await tester.pump(); + check(pushedRoutes).single + .isA() + .page + .isA() + .initNarrow + .equals(TopicNarrow.ofMessage(message)); + await tester.pumpAndSettle(); + }, + ); - testWidgets('does not navigate on tapping topic in TopicNarrow', (tester) async { + testWidgets('does not navigate on tapping topic in TopicNarrow', ( + tester, + ) async { final pushedRoutes = >[]; - final navObserver = TestNavigatorObserver() - ..onPushed = (route, prevRoute) => pushedRoutes.add(route); + final navObserver = + TestNavigatorObserver() + ..onPushed = (route, prevRoute) => pushedRoutes.add(route); final channel = eg.stream(); final message = eg.streamMessage(stream: channel, topic: 'topic name'); - await setupMessageListPage(tester, + await setupMessageListPage( + tester, narrow: TopicNarrow.ofMessage(message), streams: [channel], messages: [message], - navObservers: [navObserver]); + navObservers: [navObserver], + ); assert(pushedRoutes.length == 1); pushedRoutes.clear(); - await tester.tap(find.descendant( - of: find.byType(StreamMessageRecipientHeader), - matching: find.text('topic name'))); + await tester.tap( + find.descendant( + of: find.byType(StreamMessageRecipientHeader), + matching: find.text('topic name'), + ), + ); await tester.pump(); check(pushedRoutes).isEmpty(); }); @@ -1018,54 +1520,97 @@ void main() { group('DmRecipientHeader', () { testWidgets('show names', (tester) async { final zulipLocalizations = GlobalLocalizations.zulipLocalizations; - await setupMessageListPage(tester, messages: [ - eg.dmMessage(from: eg.selfUser, to: []), - eg.dmMessage(from: eg.otherUser, to: [eg.selfUser]), - eg.dmMessage(from: eg.thirdUser, to: [eg.selfUser, eg.otherUser]), - ]); + await setupMessageListPage( + tester, + messages: [ + eg.dmMessage(from: eg.selfUser, to: []), + eg.dmMessage(from: eg.otherUser, to: [eg.selfUser]), + eg.dmMessage(from: eg.thirdUser, to: [eg.selfUser, eg.otherUser]), + ], + ); await store.addUser(eg.otherUser); await store.addUser(eg.thirdUser); await tester.pump(); - tester.widget(find.text(zulipLocalizations.messageListGroupYouWithYourself)); - tester.widget(find.text(zulipLocalizations.messageListGroupYouAndOthers( - eg.otherUser.fullName))); - tester.widget(find.text(zulipLocalizations.messageListGroupYouAndOthers( - "${eg.otherUser.fullName}, ${eg.thirdUser.fullName}"))); + tester.widget( + find.text(zulipLocalizations.messageListGroupYouWithYourself), + ); + tester.widget( + find.text( + zulipLocalizations.messageListGroupYouAndOthers( + eg.otherUser.fullName, + ), + ), + ); + tester.widget( + find.text( + zulipLocalizations.messageListGroupYouAndOthers( + "${eg.otherUser.fullName}, ${eg.thirdUser.fullName}", + ), + ), + ); }); testWidgets('show names: smoothly handle unknown users', (tester) async { final zulipLocalizations = GlobalLocalizations.zulipLocalizations; - await setupMessageListPage(tester, messages: [ - eg.dmMessage(from: eg.otherUser, to: [eg.selfUser]), - eg.dmMessage(from: eg.thirdUser, to: [eg.selfUser, eg.otherUser]), - ]); + await setupMessageListPage( + tester, + messages: [ + eg.dmMessage(from: eg.otherUser, to: [eg.selfUser]), + eg.dmMessage(from: eg.thirdUser, to: [eg.selfUser, eg.otherUser]), + ], + ); await store.addUser(eg.thirdUser); await tester.pump(); - tester.widget(find.text(zulipLocalizations.messageListGroupYouAndOthers( - zulipLocalizations.unknownUserName))); - tester.widget(find.text(zulipLocalizations.messageListGroupYouAndOthers( - "${zulipLocalizations.unknownUserName}, ${eg.thirdUser.fullName}"))); + tester.widget( + find.text( + zulipLocalizations.messageListGroupYouAndOthers( + zulipLocalizations.unknownUserName, + ), + ), + ); + tester.widget( + find.text( + zulipLocalizations.messageListGroupYouAndOthers( + "${zulipLocalizations.unknownUserName}, ${eg.thirdUser.fullName}", + ), + ), + ); }); testWidgets('icon color matches text color', (tester) async { final zulipLocalizations = GlobalLocalizations.zulipLocalizations; - await setupMessageListPage(tester, messages: [ - eg.dmMessage(from: eg.otherUser, to: [eg.selfUser]), - ]); + await setupMessageListPage( + tester, + messages: [ + eg.dmMessage(from: eg.otherUser, to: [eg.selfUser]), + ], + ); await tester.pump(); - final textSpan = tester.renderObject(find.text( - zulipLocalizations.messageListGroupYouAndOthers( - zulipLocalizations.unknownUserName))).text; + final textSpan = + tester + .renderObject( + find.text( + zulipLocalizations.messageListGroupYouAndOthers( + zulipLocalizations.unknownUserName, + ), + ), + ) + .text; final icon = tester.widget(find.byIcon(ZulipIcons.user)); - check(textSpan).style.isNotNull().color.isNotNull().isSameColorAs(icon.color!); + check( + textSpan, + ).style.isNotNull().color.isNotNull().isSameColorAs(icon.color!); }); }); testWidgets('show dates', (tester) async { - await setupMessageListPage(tester, messages: [ - eg.streamMessage(timestamp: 1671409088), - eg.dmMessage(timestamp: 1661219322, from: eg.selfUser, to: []), - ]); + await setupMessageListPage( + tester, + messages: [ + eg.streamMessage(timestamp: 1671409088), + eg.dmMessage(timestamp: 1661219322, from: eg.selfUser, to: []), + ], + ); // We show the dates in the user's timezone. Dart's standard library // doesn't give us a way to control which timezone is used — only to // choose between UTC and the user's timezone, whatever that may be. @@ -1079,37 +1624,67 @@ void main() { tester.widget(find.textContaining(RegExp("Aug 2[23], 2022"))); }); - testWidgets('navigates to DmNarrow on tapping recipient header in CombinedFeedNarrow', (tester) async { - final pushedRoutes = >[]; - final navObserver = TestNavigatorObserver() - ..onPushed = (route, prevRoute) => pushedRoutes.add(route); - final dmMessage = eg.dmMessage(from: eg.selfUser, to: [eg.otherUser]); - await setupMessageListPage(tester, - narrow: const CombinedFeedNarrow(), - messages: [dmMessage], - navObservers: [navObserver]); + testWidgets( + 'navigates to DmNarrow on tapping recipient header in CombinedFeedNarrow', + (tester) async { + final pushedRoutes = >[]; + final navObserver = + TestNavigatorObserver() + ..onPushed = (route, prevRoute) => pushedRoutes.add(route); + final dmMessage = eg.dmMessage(from: eg.selfUser, to: [eg.otherUser]); + await setupMessageListPage( + tester, + narrow: const CombinedFeedNarrow(), + messages: [dmMessage], + navObservers: [navObserver], + ); - assert(pushedRoutes.length == 1); - pushedRoutes.clear(); + assert(pushedRoutes.length == 1); + pushedRoutes.clear(); - connection.prepare(json: eg.newestGetMessagesResult( - foundOldest: true, messages: [dmMessage]).toJson()); - await tester.tap(find.byType(DmRecipientHeader)); - await tester.pump(); - check(pushedRoutes).single.isA().page.isA() - .initNarrow.equals(DmNarrow.withUser(eg.otherUser.userId, selfUserId: eg.selfUser.userId)); - await tester.pumpAndSettle(); - }); - - testWidgets('does not navigate on tapping recipient header in DmNarrow', (tester) async { + connection.prepare( + json: + eg + .newestGetMessagesResult( + foundOldest: true, + messages: [dmMessage], + ) + .toJson(), + ); + await tester.tap(find.byType(DmRecipientHeader)); + await tester.pump(); + check(pushedRoutes).single + .isA() + .page + .isA() + .initNarrow + .equals( + DmNarrow.withUser( + eg.otherUser.userId, + selfUserId: eg.selfUser.userId, + ), + ); + await tester.pumpAndSettle(); + }, + ); + + testWidgets('does not navigate on tapping recipient header in DmNarrow', ( + tester, + ) async { final pushedRoutes = >[]; - final navObserver = TestNavigatorObserver() - ..onPushed = (route, prevRoute) => pushedRoutes.add(route); + final navObserver = + TestNavigatorObserver() + ..onPushed = (route, prevRoute) => pushedRoutes.add(route); final dmMessage = eg.dmMessage(from: eg.selfUser, to: [eg.otherUser]); - await setupMessageListPage(tester, - narrow: DmNarrow.withUser(eg.otherUser.userId, selfUserId: eg.selfUser.userId), + await setupMessageListPage( + tester, + narrow: DmNarrow.withUser( + eg.otherUser.userId, + selfUserId: eg.selfUser.userId, + ), messages: [dmMessage], - navObservers: [navObserver]); + navObservers: [navObserver], + ); assert(pushedRoutes.length == 1); pushedRoutes.clear(); @@ -1137,8 +1712,13 @@ void main() { ]; for (final (dateTime, expected) in testCases) { test('$dateTime returns $expected', () { - check(formatHeaderDate(zulipLocalizations, DateTime.parse(dateTime), now: now)) - .equals(expected); + check( + formatHeaderDate( + zulipLocalizations, + DateTime.parse(dateTime), + now: now, + ), + ).equals(expected); }); } }); @@ -1150,23 +1730,37 @@ void main() { // TODO recognize avatar more reliably: // https://github.com/zulip/zulip-flutter/pull/246#discussion_r1282516308 RealmContentNetworkImage? findAvatarImageWidget(WidgetTester tester) { - return tester.widgetList( - find.descendant( - of: find.byType(MessageWithPossibleSender), - matching: find.byType(RealmContentNetworkImage))).firstOrNull; + return tester + .widgetList( + find.descendant( + of: find.byType(MessageWithPossibleSender), + matching: find.byType(RealmContentNetworkImage), + ), + ) + .firstOrNull; } void checkResultForSender(String? avatarUrl) { if (avatarUrl == null) { check(findAvatarImageWidget(tester)).isNull(); } else { - check(findAvatarImageWidget(tester)).isNotNull() - .src.equals(eg.selfAccount.realmUrl.resolve(avatarUrl)); + check( + findAvatarImageWidget(tester), + ).isNotNull().src.equals(eg.selfAccount.realmUrl.resolve(avatarUrl)); } } - Future handleNewAvatarEventAndPump(WidgetTester tester, String avatarUrl) async { - await store.handleEvent(RealmUserUpdateEvent(id: 1, userId: eg.selfUser.userId, avatarUrl: avatarUrl)); + Future handleNewAvatarEventAndPump( + WidgetTester tester, + String avatarUrl, + ) async { + await store.handleEvent( + RealmUserUpdateEvent( + id: 1, + userId: eg.selfUser.userId, + avatarUrl: avatarUrl, + ), + ); await tester.pump(); } @@ -1196,14 +1790,12 @@ void main() { final userFinder = find.ancestor( of: nameFinder, - matching: find.ancestor( - of: botFinder, - matching: find.byType(Row), - )); + matching: find.ancestor(of: botFinder, matching: find.byType(Row)), + ); isBot - ? check(userFinder.evaluate()).isNotEmpty() - : check(userFinder.evaluate()).isEmpty(); + ? check(userFinder.evaluate()).isNotEmpty() + : check(userFinder.evaluate()).isEmpty(); } prepareBoringImageHttpClient(); @@ -1260,16 +1852,24 @@ void main() { await setupMessageListPage(tester, messages: [message, message2]); checkMarkersCount(edited: 0, moved: 0); - await store.handleEvent(eg.updateMessageEditEvent(message, renderedContent: 'edited')); + await store.handleEvent( + eg.updateMessageEditEvent(message, renderedContent: 'edited'), + ); await tester.pump(); checkMarkersCount(edited: 1, moved: 0); - await store.handleEvent(eg.updateMessageEventMoveFrom( - origMessages: [message, message2], newTopicStr: 'new')); + await store.handleEvent( + eg.updateMessageEventMoveFrom( + origMessages: [message, message2], + newTopicStr: 'new', + ), + ); await tester.pump(); checkMarkersCount(edited: 1, moved: 1); - await store.handleEvent(eg.updateMessageEditEvent(message2, renderedContent: 'edited')); + await store.handleEvent( + eg.updateMessageEditEvent(message2, renderedContent: 'edited'), + ); await tester.pump(); checkMarkersCount(edited: 2, moved: 0); }); @@ -1280,9 +1880,12 @@ void main() { // implementation details and more focused on output, see: // https://chat.zulip.org/#narrow/stream/243-mobile-team/topic/robust.20widget.20finders.20in.20tests/near/1671738 Animation getAnimation(WidgetTester tester, int messageId) { - final widget = tester.widget(find.descendant( - of: find.byKey(ValueKey(messageId)), - matching: find.byType(FadeTransition))); + final widget = tester.widget( + find.descendant( + of: find.byKey(ValueKey(messageId)), + matching: find.byType(FadeTransition), + ), + ); return widget.opacity; } @@ -1293,8 +1896,9 @@ void main() { ..value.equals(0.0) ..status.equals(AnimationStatus.dismissed); - await store.handleEvent(eg.updateMessageFlagsRemoveEvent( - MessageFlag.read, [message])); + await store.handleEvent( + eg.updateMessageFlagsRemoveEvent(MessageFlag.read, [message]), + ); await tester.pump(); // process handleEvent check(getAnimation(tester, message.id)) ..value.equals(0.0) @@ -1313,12 +1917,14 @@ void main() { ..value.equals(1.0) ..status.equals(AnimationStatus.dismissed); - await store.handleEvent(UpdateMessageFlagsAddEvent( - id: 1, - flag: MessageFlag.read, - messages: [message.id], - all: false, - )); + await store.handleEvent( + UpdateMessageFlagsAddEvent( + id: 1, + flag: MessageFlag.read, + messages: [message.id], + all: false, + ), + ); await tester.pump(); // process handleEvent check(getAnimation(tester, message.id)) ..value.equals(1.0) @@ -1341,12 +1947,14 @@ void main() { ..value.equals(1.0) ..status.equals(AnimationStatus.dismissed); - await store.handleEvent(UpdateMessageFlagsAddEvent( - id: 0, - flag: MessageFlag.read, - messages: [message.id], - all: false, - )); + await store.handleEvent( + UpdateMessageFlagsAddEvent( + id: 0, + flag: MessageFlag.read, + messages: [message.id], + all: false, + ), + ); await tester.pump(); // process handleEvent check(getAnimation(tester, message.id)) ..value.equals(1.0) @@ -1360,7 +1968,7 @@ void main() { ..status.equals(AnimationStatus.forward); // introduce new message - final newMessage = eg.streamMessage(flags:[MessageFlag.read]); + final newMessage = eg.streamMessage(flags: [MessageFlag.read]); await store.handleEvent(MessageEvent(id: 0, message: newMessage)); await tester.pump(); // process handleEvent check(find.byType(MessageItem).evaluate()).length.equals(2); @@ -1382,4 +1990,50 @@ void main() { ..status.equals(AnimationStatus.dismissed); }); }); + + group('Guest warning banner', () { + Finder guestUserDmWarningBannerFinder(String label) => + find.textContaining(label); + + void checkGuestUserWarningBanner(WidgetTester tester, String label) { + final state = MessageListPage.ancestorOf( + tester.element(find.text("a message")), + ); + bool shouldShow = + store.connection.zulipFeatureLevel! >= 348 && + state.narrow is DmNarrow && + (store.realmEnableGuestUserDmWarning ?? false); + + check( + guestUserDmWarningBannerFinder(label).evaluate().length, + ).equals(shouldShow ? 1 : 0); + } + + void checkDmGuestUserWarning(WidgetTester tester, String label) => + checkGuestUserWarningBanner(tester, label); + + testWidgets('guest user DM warning banner text', (tester) async { + // check if DM warning banner shows text + // if the recipient is a guest user + final zulipLocalizations = GlobalLocalizations.zulipLocalizations; + await setupMessageListPage( + tester, + users: [ + eg.user(userId: 1, fullName: 'User 1') + ], + narrow: DmNarrow( + allRecipientIds: [eg.selfUser.userId], + selfUserId: eg.selfUser.userId, + ), + messages: [eg.streamMessage(content: "

a message

")], + ); + checkDmGuestUserWarning( + tester, + zulipLocalizations.bannerText( + 1, + 'User 1', + ), + ); + }); + }); }