diff --git a/lib/widgets/autocomplete.dart b/lib/widgets/autocomplete.dart index 526c7edfb1..7445370e7a 100644 --- a/lib/widgets/autocomplete.dart +++ b/lib/widgets/autocomplete.dart @@ -223,7 +223,7 @@ class ComposeAutocomplete extends AutocompleteField _MentionAutocompleteItem( + MentionAutocompleteResult() => MentionAutocompleteItem( option: option, narrow: narrow), EmojiAutocompleteResult() => _EmojiAutocompleteItem(option: option), }; @@ -238,8 +238,13 @@ class ComposeAutocomplete extends AutocompleteField { subscriptionListHeaderLine: const HSLColor.fromAHSL(0.2, 240, 0.1, 0.5).toColor(), subscriptionListHeaderText: const HSLColor.fromAHSL(1.0, 240, 0.1, 0.5).toColor(), unreadCountBadgeTextForChannel: Colors.black.withValues(alpha: 0.9), + userStatusText: const Color(0xff808080), ); static final dark = DesignVariables._( @@ -309,6 +310,8 @@ class DesignVariables extends ThemeExtension { // TODO(design-dark) need proper dark-theme color (this is ad hoc) subscriptionListHeaderText: const HSLColor.fromAHSL(1.0, 240, 0.1, 0.75).toColor(), unreadCountBadgeTextForChannel: Colors.white.withValues(alpha: 0.9), + // TODO(design-dark) unchanged in dark theme? + userStatusText: const Color(0xff808080), ); DesignVariables._({ @@ -388,6 +391,7 @@ class DesignVariables extends ThemeExtension { required this.subscriptionListHeaderLine, required this.subscriptionListHeaderText, required this.unreadCountBadgeTextForChannel, + required this.userStatusText, }); /// The [DesignVariables] from the context's active theme. @@ -480,6 +484,7 @@ class DesignVariables extends ThemeExtension { final Color subscriptionListHeaderLine; final Color subscriptionListHeaderText; final Color unreadCountBadgeTextForChannel; + final Color userStatusText; // In Figma, but unnamed. @override DesignVariables copyWith({ @@ -559,6 +564,7 @@ class DesignVariables extends ThemeExtension { Color? subscriptionListHeaderLine, Color? subscriptionListHeaderText, Color? unreadCountBadgeTextForChannel, + Color? userStatusText, }) { return DesignVariables._( background: background ?? this.background, @@ -637,6 +643,7 @@ class DesignVariables extends ThemeExtension { subscriptionListHeaderLine: subscriptionListHeaderLine ?? this.subscriptionListHeaderLine, subscriptionListHeaderText: subscriptionListHeaderText ?? this.subscriptionListHeaderText, unreadCountBadgeTextForChannel: unreadCountBadgeTextForChannel ?? this.unreadCountBadgeTextForChannel, + userStatusText: userStatusText ?? this.userStatusText, ); } @@ -722,6 +729,7 @@ class DesignVariables extends ThemeExtension { subscriptionListHeaderLine: Color.lerp(subscriptionListHeaderLine, other.subscriptionListHeaderLine, t)!, subscriptionListHeaderText: Color.lerp(subscriptionListHeaderText, other.subscriptionListHeaderText, t)!, unreadCountBadgeTextForChannel: Color.lerp(unreadCountBadgeTextForChannel, other.unreadCountBadgeTextForChannel, t)!, + userStatusText: Color.lerp(userStatusText, other.userStatusText, t)!, ); } } diff --git a/test/model/test_store.dart b/test/model/test_store.dart index e77b5fc2a0..18e41bcfb5 100644 --- a/test/model/test_store.dart +++ b/test/model/test_store.dart @@ -275,8 +275,8 @@ extension PerAccountStoreTestExtension on PerAccountStore { await handleEvent(UserStatusEvent(id: 1, userId: userId, change: change)); } - Future changeUserStatuses(List<(int userId, UserStatusChange change)> changes) async { - for (final (userId, change) in changes) { + Future changeUserStatuses(Map changes) async { + for (final MapEntry(key: userId, value: change) in changes.entries) { await changeUserStatus(userId, change); } } diff --git a/test/widgets/autocomplete_test.dart b/test/widgets/autocomplete_test.dart index 573921b663..118db51c18 100644 --- a/test/widgets/autocomplete_test.dart +++ b/test/widgets/autocomplete_test.dart @@ -7,12 +7,14 @@ import 'package:zulip/api/model/model.dart'; import 'package:zulip/api/route/messages.dart'; import 'package:zulip/api/route/channels.dart'; import 'package:zulip/api/route/realm.dart'; +import 'package:zulip/basic.dart'; import 'package:zulip/model/compose.dart'; import 'package:zulip/model/emoji.dart'; import 'package:zulip/model/localizations.dart'; import 'package:zulip/model/narrow.dart'; import 'package:zulip/model/store.dart'; import 'package:zulip/model/typing_status.dart'; +import 'package:zulip/widgets/autocomplete.dart'; import 'package:zulip/widgets/compose_box.dart'; import 'package:zulip/widgets/content.dart'; import 'package:zulip/widgets/message_list.dart'; @@ -25,6 +27,8 @@ import '../model/test_store.dart'; import '../test_images.dart'; import 'test_app.dart'; +late PerAccountStore store; + /// Simulates loading a [MessageListPage] and tapping to focus the compose input. /// /// Also adds [users] to the [PerAccountStore], @@ -44,7 +48,7 @@ Future setupToComposeInput(WidgetTester tester, { addTearDown(testBinding.reset); await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); - final store = await testBinding.globalStore.perAccount(eg.selfAccount.id); + store = await testBinding.globalStore.perAccount(eg.selfAccount.id); await store.addUsers([eg.selfUser, eg.otherUser]); await store.addUsers(users); final connection = store.connection as FakeApiConnection; @@ -202,6 +206,55 @@ void main() { debugNetworkImageHttpClientProvider = null; }); + group('User status', () { + void checkFindsStatusEmoji(WidgetTester tester, Finder emojiFinder) { + final statusEmojiFinder = find.ancestor(of: emojiFinder, + matching: find.byType(UserStatusEmoji)); + check(statusEmojiFinder).findsOne(); + check(tester.widget(statusEmojiFinder) + .neverAnimate).isTrue(); + check(find.ancestor(of: statusEmojiFinder, + matching: find.byType(MentionAutocompleteItem))).findsOne(); + } + + testWidgets('emoji & text are set -> emoji is displayed, text is not', (tester) async { + final user = eg.user(fullName: 'User'); + final composeInputFinder = await setupToComposeInput(tester, users: [user]); + await store.changeUserStatus(user.userId, UserStatusChange( + text: OptionSome('Busy'), + emoji: OptionSome(StatusEmoji(emojiName: 'working_on_it', + emojiCode: '1f6e0', reactionType: ReactionType.unicodeEmoji)))); + await tester.pump(); + + // // TODO(#226): Remove this extra edit when this bug is fixed. + await tester.enterText(composeInputFinder, 'hello @u'); + await tester.enterText(composeInputFinder, 'hello @'); + await tester.pumpAndSettle(); // async computation; options appear + + checkFindsStatusEmoji(tester, find.text('\u{1f6e0}')); + check(find.textContaining('Busy')).findsNothing(); + + debugNetworkImageHttpClientProvider = null; + }); + + testWidgets('emoji is not set, text is set -> text is not displayed', (tester) async { + final user = eg.user(fullName: 'User'); + final composeInputFinder = await setupToComposeInput(tester, users: [user]); + await store.changeUserStatus(user.userId, UserStatusChange( + text: OptionSome('Busy'), emoji: OptionNone())); + await tester.pump(); + + // // TODO(#226): Remove this extra edit when this bug is fixed. + await tester.enterText(composeInputFinder, 'hello @u'); + await tester.enterText(composeInputFinder, 'hello @'); + await tester.pumpAndSettle(); // async computation; options appear + + check(find.textContaining('Busy')).findsNothing(); + + debugNetworkImageHttpClientProvider = null; + }); + }); + void checkWildcardShown(WildcardMentionOption wildcard, {required bool expected}) { check(find.text(wildcard.canonicalString)).findsExactly(expected ? 1 : 0); } diff --git a/test/widgets/finders.dart b/test/widgets/finders.dart new file mode 100644 index 0000000000..fe4111cdbe --- /dev/null +++ b/test/widgets/finders.dart @@ -0,0 +1,102 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Like `find.text` from flutter_test upstream, but with +/// the `includePlaceholders` option. +/// +/// When `includePlaceholders` is true, any [PlaceholderSpan] (for example, +/// any [WidgetSpan]) in the tree will be represented as +/// an "object replacement character", U+FFFC. +/// When `includePlaceholders` is false, such spans will be omitted. +/// +/// TODO(upstream): get `find.text` to accept includePlaceholders +Finder findText(String text, { + bool findRichText = false, + bool includePlaceholders = true, + bool skipOffstage = true, +}) { + return _TextWidgetFinder(text, + findRichText: findRichText, + includePlaceholders: includePlaceholders, + skipOffstage: skipOffstage); +} + +// (Compare the implementation in `package:flutter_test/src/finders.dart`.) +abstract class _MatchTextFinder extends MatchFinder { + _MatchTextFinder({this.findRichText = false, this.includePlaceholders = true, + super.skipOffstage}); + + /// Whether standalone [RichText] widgets should be found or not. + /// + /// Defaults to `false`. + /// + /// If disabled, only [Text] widgets will be matched. [RichText] widgets + /// *without* a [Text] ancestor will be ignored. + /// If enabled, only [RichText] widgets will be matched. This *implicitly* + /// matches [Text] widgets as well since they always insert a [RichText] + /// child. + /// + /// In either case, [EditableText] widgets will also be matched. + final bool findRichText; + + final bool includePlaceholders; + + bool matchesText(String textToMatch); + + @override + bool matches(Element candidate) { + final Widget widget = candidate.widget; + if (widget is EditableText) { + return _matchesEditableText(widget); + } + + if (!findRichText) { + return _matchesNonRichText(widget); + } + // It would be sufficient to always use _matchesRichText if we wanted to + // match both standalone RichText widgets as well as Text widgets. However, + // the find.text() finder used to always ignore standalone RichText widgets, + // which is why we need the _matchesNonRichText method in order to not be + // backwards-compatible and not break existing tests. + return _matchesRichText(widget); + } + + bool _matchesRichText(Widget widget) { + if (widget is RichText) { + return matchesText(widget.text.toPlainText( + includePlaceholders: includePlaceholders)); + } + return false; + } + + bool _matchesNonRichText(Widget widget) { + if (widget is Text) { + if (widget.data != null) { + return matchesText(widget.data!); + } + assert(widget.textSpan != null); + return matchesText(widget.textSpan!.toPlainText( + includePlaceholders: includePlaceholders)); + } + return false; + } + + bool _matchesEditableText(EditableText widget) { + return matchesText(widget.controller.text); + } +} + +class _TextWidgetFinder extends _MatchTextFinder { + _TextWidgetFinder(this.text, {super.findRichText, super.includePlaceholders, + super.skipOffstage}); + + final String text; + + @override + String get description => 'text "$text"'; + + @override + bool matchesText(String textToMatch) { + return textToMatch == text; + } +} diff --git a/test/widgets/message_list_test.dart b/test/widgets/message_list_test.dart index 54c714b34d..87dff86b2a 100644 --- a/test/widgets/message_list_test.dart +++ b/test/widgets/message_list_test.dart @@ -14,6 +14,7 @@ import 'package:zulip/api/model/model.dart'; import 'package:zulip/api/model/narrow.dart'; import 'package:zulip/api/route/channels.dart'; import 'package:zulip/api/route/messages.dart'; +import 'package:zulip/basic.dart'; import 'package:zulip/model/actions.dart'; import 'package:zulip/model/localizations.dart'; import 'package:zulip/model/message.dart'; @@ -1772,6 +1773,74 @@ void main() { debugNetworkImageHttpClientProvider = null; }); + group('User status', () { + void checkFindsStatusEmoji(WidgetTester tester, Finder emojiFinder) { + final statusEmojiFinder = find.ancestor(of: emojiFinder, + matching: find.byType(UserStatusEmoji)); + check(statusEmojiFinder).findsOne(); + check(tester.widget(statusEmojiFinder) + .neverAnimate).isTrue(); + check(find.ancestor(of: statusEmojiFinder, + matching: find.byType(SenderRow))).findsOne(); + } + + testWidgets('emoji (unicode) & text are set -> emoji is displayed, text is not', (tester) async { + final user = eg.user(); + await setupMessageListPage(tester, + users: [user], messages: [eg.streamMessage(sender: user)]); + await store.changeUserStatus(user.userId, UserStatusChange( + text: OptionSome('Busy'), + emoji: OptionSome(StatusEmoji(emojiName: 'working_on_it', + emojiCode: '1f6e0', reactionType: ReactionType.unicodeEmoji)))); + await tester.pump(); + + checkFindsStatusEmoji(tester, find.text('\u{1f6e0}')); + check(find.textContaining('Busy')).findsNothing(); + }); + + testWidgets('emoji (image) & text are set -> emoji is displayed, text is not', (tester) async { + prepareBoringImageHttpClient(); + + final user = eg.user(); + await setupMessageListPage(tester, + users: [user], messages: [eg.streamMessage(sender: user)]); + await store.changeUserStatus(user.userId, UserStatusChange( + text: OptionSome('Coding'), + emoji: OptionSome(StatusEmoji(emojiName: 'zulip', + emojiCode: 'zulip', reactionType: ReactionType.zulipExtraEmoji)))); + await tester.pump(); + + checkFindsStatusEmoji(tester, find.byType(Image)); + check(find.textContaining('Coding')).findsNothing(); + + debugNetworkImageHttpClientProvider = null; + }); + + testWidgets('longer user name -> emoji stays visible', (tester) async { + final user = eg.user(fullName: 'User with a very very very long name to check if emoji is still visible'); + await setupMessageListPage(tester, + users: [user], messages: [eg.streamMessage(sender: user)]); + await store.changeUserStatus(user.userId, UserStatusChange( + text: OptionNone(), + emoji: OptionSome(StatusEmoji(emojiName: 'working_on_it', + emojiCode: '1f6e0', reactionType: ReactionType.unicodeEmoji)))); + await tester.pump(); + + checkFindsStatusEmoji(tester, find.text('\u{1f6e0}')); + }); + + testWidgets('emoji is not set, text is set -> text is not displayed', (tester) async { + final user = eg.user(); + await setupMessageListPage(tester, + users: [user], messages: [eg.streamMessage(sender: user)]); + await store.changeUserStatus(user.userId, UserStatusChange( + text: OptionSome('Busy'), emoji: OptionNone())); + await tester.pump(); + + check(find.textContaining('Busy')).findsNothing(); + }); + }); + group('Muted sender', () { void checkMessage(Message message, {required bool expectIsMuted}) { final mutedLabel = 'Muted user'; diff --git a/test/widgets/new_dm_sheet_test.dart b/test/widgets/new_dm_sheet_test.dart index fc9567d78d..72a0ecab18 100644 --- a/test/widgets/new_dm_sheet_test.dart +++ b/test/widgets/new_dm_sheet_test.dart @@ -3,6 +3,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_checks/flutter_checks.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:zulip/api/model/model.dart'; +import 'package:zulip/basic.dart'; +import 'package:zulip/model/store.dart'; import 'package:zulip/widgets/app_bar.dart'; import 'package:zulip/widgets/compose_box.dart'; import 'package:zulip/widgets/content.dart'; @@ -17,8 +19,11 @@ import '../flutter_checks.dart'; import '../model/binding.dart'; import '../model/test_store.dart'; import '../test_navigation.dart'; +import 'finders.dart'; import 'test_app.dart'; +late PerAccountStore store; + Future setupSheet(WidgetTester tester, { required List users, List? mutedUserIds, @@ -30,7 +35,7 @@ Future setupSheet(WidgetTester tester, { ..onPushed = (route, _) => lastPushedRoute = route; await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); - final store = await testBinding.globalStore.perAccount(eg.selfAccount.id); + store = await testBinding.globalStore.perAccount(eg.selfAccount.id); await store.addUsers(users); if (mutedUserIds != null) { await store.setMutedUsers(mutedUserIds); @@ -65,7 +70,8 @@ void main() { } Finder findUserTile(User user) => - find.widgetWithText(InkWell, user.fullName).first; + find.ancestor(of: findText(user.fullName, includePlaceholders: false), + matching: find.byType(InkWell)).first; Finder findUserChip(User user) { final findAvatar = find.byWidgetPredicate((widget) => @@ -120,23 +126,23 @@ void main() { testWidgets('shows all non-muted users initially', (tester) async { await setupSheet(tester, users: testUsers, mutedUserIds: [mutedUser.userId]); - check(find.text('Alice Anderson')).findsOne(); - check(find.text('Bob Brown')).findsOne(); - check(find.text('Charlie Carter')).findsOne(); + check(findText(includePlaceholders: false, 'Alice Anderson')).findsOne(); + check(findText(includePlaceholders: false, 'Bob Brown')).findsOne(); + check(findText(includePlaceholders: false, 'Charlie Carter')).findsOne(); check(find.byIcon(ZulipIcons.check_circle_unchecked)).findsExactly(3); check(find.byIcon(ZulipIcons.check_circle_checked)).findsNothing(); - check(find.text('Someone Muted')).findsNothing(); - check(find.text('Muted user')).findsNothing(); + check(findText(includePlaceholders: false, 'Someone Muted')).findsNothing(); + check(findText(includePlaceholders: false, 'Muted user')).findsNothing(); }); testWidgets('shows filtered users based on search', (tester) async { await setupSheet(tester, users: testUsers); await tester.enterText(find.byType(TextField), 'Alice'); await tester.pump(); - check(find.text('Alice Anderson')).findsOne(); - check(find.text('Charlie Carter')).findsNothing(); - check(find.text('Bob Brown')).findsNothing(); + check(findText(includePlaceholders: false, 'Alice Anderson')).findsOne(); + check(findText(includePlaceholders: false, 'Charlie Carter')).findsNothing(); + check(findText(includePlaceholders: false, 'Bob Brown')).findsNothing(); }); // TODO test sorting by recent-DMs @@ -146,11 +152,11 @@ void main() { await setupSheet(tester, users: testUsers); await tester.enterText(find.byType(TextField), 'alice'); await tester.pump(); - check(find.text('Alice Anderson')).findsOne(); + check(findText(includePlaceholders: false, 'Alice Anderson')).findsOne(); await tester.enterText(find.byType(TextField), 'ALICE'); await tester.pump(); - check(find.text('Alice Anderson')).findsOne(); + check(findText(includePlaceholders: false, 'Alice Anderson')).findsOne(); }); testWidgets('partial name and last name search handling', (tester) async { @@ -158,31 +164,31 @@ void main() { await tester.enterText(find.byType(TextField), 'Ali'); await tester.pump(); - check(find.text('Alice Anderson')).findsOne(); - check(find.text('Bob Brown')).findsNothing(); - check(find.text('Charlie Carter')).findsNothing(); + check(findText(includePlaceholders: false, 'Alice Anderson')).findsOne(); + check(findText(includePlaceholders: false, 'Bob Brown')).findsNothing(); + check(findText(includePlaceholders: false, 'Charlie Carter')).findsNothing(); await tester.enterText(find.byType(TextField), 'Anderson'); await tester.pump(); - check(find.text('Alice Anderson')).findsOne(); - check(find.text('Charlie Carter')).findsNothing(); - check(find.text('Bob Brown')).findsNothing(); + check(findText(includePlaceholders: false, 'Alice Anderson')).findsOne(); + check(findText(includePlaceholders: false, 'Charlie Carter')).findsNothing(); + check(findText(includePlaceholders: false, 'Bob Brown')).findsNothing(); await tester.enterText(find.byType(TextField), 'son'); await tester.pump(); - check(find.text('Alice Anderson')).findsOne(); - check(find.text('Charlie Carter')).findsNothing(); - check(find.text('Bob Brown')).findsNothing(); + check(findText(includePlaceholders: false, 'Alice Anderson')).findsOne(); + check(findText(includePlaceholders: false, 'Charlie Carter')).findsNothing(); + check(findText(includePlaceholders: false, 'Bob Brown')).findsNothing(); }); testWidgets('shows empty state when no users match', (tester) async { await setupSheet(tester, users: testUsers); await tester.enterText(find.byType(TextField), 'Zebra'); await tester.pump(); - check(find.text('No users found')).findsOne(); - check(find.text('Alice Anderson')).findsNothing(); - check(find.text('Bob Brown')).findsNothing(); - check(find.text('Charlie Carter')).findsNothing(); + check(findText(includePlaceholders: false, 'No users found')).findsOne(); + check(findText(includePlaceholders: false, 'Alice Anderson')).findsNothing(); + check(findText(includePlaceholders: false, 'Bob Brown')).findsNothing(); + check(findText(includePlaceholders: false, 'Charlie Carter')).findsNothing(); }); testWidgets('search text clears when user is selected', (tester) async { @@ -252,7 +258,7 @@ void main() { await tester.tap(findUserTile(eg.selfUser)); await tester.pump(); checkUserSelected(tester, eg.selfUser, true); - check(find.text(eg.selfUser.fullName)).findsExactly(2); + check(findText(includePlaceholders: false, eg.selfUser.fullName)).findsExactly(2); await tester.tap(findUserTile(otherUser)); await tester.pump(); @@ -264,7 +270,7 @@ void main() { final otherUser = eg.user(fullName: 'Other User'); await setupSheet(tester, users: [eg.selfUser, otherUser]); - check(find.text(eg.selfUser.fullName)).findsOne(); + check(findText(includePlaceholders: false, eg.selfUser.fullName)).findsOne(); await tester.tap(findUserTile(otherUser)); await tester.pump(); @@ -285,6 +291,69 @@ void main() { }); }); + group('User status', () { + void checkFindsTileStatusEmoji(WidgetTester tester, User user, Finder emojiFinder) { + final statusEmojiFinder = find.ancestor(of: emojiFinder, + matching: find.byType(UserStatusEmoji)); + final tileStatusEmojiFinder = find.descendant(of: findUserTile(user), + matching: statusEmojiFinder); + check(tester.widget(tileStatusEmojiFinder) + .neverAnimate).isTrue(); + check(tileStatusEmojiFinder).findsOne(); + } + + void checkFindsChipStatusEmoji(WidgetTester tester, User user, Finder emojiFinder) { + final statusEmojiFinder = find.ancestor(of: emojiFinder, + matching: find.byType(UserStatusEmoji)); + final chipStatusEmojiFinder = find.descendant(of: findUserChip(user), + matching: statusEmojiFinder); + check(tester.widget(chipStatusEmojiFinder) + .neverAnimate).isTrue(); + check(chipStatusEmojiFinder).findsOne(); + } + + testWidgets('emoji & text are set -> emoji is displayed, text is not', (tester) async { + final user = eg.user(); + await setupSheet(tester, users: [user]); + await store.changeUserStatus(user.userId, UserStatusChange( + text: OptionSome('Busy'), + emoji: OptionSome(StatusEmoji(emojiName: 'working_on_it', + emojiCode: '1f6e0', reactionType: ReactionType.unicodeEmoji)))); + await tester.pump(); + + checkFindsTileStatusEmoji(tester, user, find.text('\u{1f6e0}')); + check(findUserChip(user)).findsNothing(); + check(find.textContaining('Busy')).findsNothing(); + + await tester.tap(findUserTile(user)); + await tester.pump(); + + checkFindsTileStatusEmoji(tester, user, find.text('\u{1f6e0}')); + check(findUserChip(user)).findsOne(); + checkFindsChipStatusEmoji(tester, user, find.text('\u{1f6e0}')); + check(find.textContaining('Busy')).findsNothing(); + }); + + testWidgets('emoji is not set, text is set -> text is not displayed', (tester) async { + final user = eg.user(); + await setupSheet(tester, users: [user]); + await store.changeUserStatus(user.userId, UserStatusChange( + text: OptionSome('Busy'), emoji: OptionNone())); + await tester.pump(); + + check(findUserTile(user)).findsOne(); + check(findUserChip(user)).findsNothing(); + check(find.textContaining('Busy')).findsNothing(); + + await tester.tap(findUserTile(user)); + await tester.pump(); + + check(findUserTile(user)).findsOne(); + check(findUserChip(user)).findsOne(); + check(find.textContaining('Busy')).findsNothing(); + }); + }); + group('navigation to DM Narrow', () { Future runAndCheck(WidgetTester tester, { required List users, diff --git a/test/widgets/profile_test.dart b/test/widgets/profile_test.dart index 61a85cb63e..0d6fcdfef4 100644 --- a/test/widgets/profile_test.dart +++ b/test/widgets/profile_test.dart @@ -9,6 +9,7 @@ import 'package:url_launcher/url_launcher.dart'; 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/basic.dart'; import 'package:zulip/model/narrow.dart'; import 'package:zulip/model/store.dart'; import 'package:zulip/widgets/button.dart'; @@ -99,6 +100,7 @@ void main() { check(because: 'find user avatar', find.byType(Avatar).evaluate()).length.equals(1); check(because: 'find user name', find.text('test user').evaluate()).isNotEmpty(); + // Tests for user status are in their own test group. check(because: 'find user delivery email', find.text('testuser@example.com').evaluate()).isNotEmpty(); }); @@ -378,6 +380,40 @@ void main() { }); }); + group('user status', () { + testWidgets('non-self profile, status set: status info appears', (tester) async { + await setupPage(tester, users: [eg.otherUser], pageUserId: eg.otherUser.userId); + await store.changeUserStatus(eg.otherUser.userId, UserStatusChange( + text: OptionSome('Busy'), + emoji: OptionSome(StatusEmoji(emojiName: 'working_on_it', + emojiCode: '1f6e0', reactionType: ReactionType.unicodeEmoji)))); + await tester.pump(); + + final statusEmojiFinder = find.ancestor(of: find.text('\u{1f6e0}'), + matching: find.byType(UserStatusEmoji)); + check(statusEmojiFinder).findsOne(); + check(tester.widget(statusEmojiFinder) + .neverAnimate).isFalse(); + check(find.text('Busy')).findsOne(); + }); + + testWidgets('self-profile, status set: status info appears', (tester) async { + await setupPage(tester, users: [eg.selfUser], pageUserId: eg.selfUser.userId); + await store.changeUserStatus(eg.selfUser.userId, UserStatusChange( + text: OptionSome('Busy'), + emoji: OptionSome(StatusEmoji(emojiName: 'working_on_it', + emojiCode: '1f6e0', reactionType: ReactionType.unicodeEmoji)))); + await tester.pump(); + + final statusEmojiFinder = find.ancestor(of: find.text('\u{1f6e0}'), + matching: find.byType(UserStatusEmoji)); + check(statusEmojiFinder).findsOne(); + check(tester.widget(statusEmojiFinder) + .neverAnimate).isFalse(); + check(find.text('Busy')).findsOne(); + }); + }); + group('invisible mode', () { final findRow = find.widgetWithText(ZulipMenuItemButton, 'Invisible mode'); final findToggle = find.descendant(of: findRow, matching: find.byType(Toggle)); diff --git a/test/widgets/recent_dm_conversations_test.dart b/test/widgets/recent_dm_conversations_test.dart index e898218e6e..16c1057c19 100644 --- a/test/widgets/recent_dm_conversations_test.dart +++ b/test/widgets/recent_dm_conversations_test.dart @@ -5,7 +5,9 @@ import 'package:flutter_checks/flutter_checks.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:zulip/api/model/events.dart'; import 'package:zulip/api/model/model.dart'; +import 'package:zulip/basic.dart'; import 'package:zulip/model/narrow.dart'; +import 'package:zulip/model/store.dart'; import 'package:zulip/widgets/content.dart'; import 'package:zulip/widgets/home.dart'; import 'package:zulip/widgets/icons.dart'; @@ -20,10 +22,13 @@ import '../model/binding.dart'; import '../model/test_store.dart'; import '../test_navigation.dart'; import 'content_checks.dart'; +import 'finders.dart'; import 'message_list_checks.dart'; import 'page_checks.dart'; import 'test_app.dart'; +late PerAccountStore store; + Future setupPage(WidgetTester tester, { required List dmMessages, required List users, @@ -36,7 +41,7 @@ Future setupPage(WidgetTester tester, { selfUser ??= eg.selfUser; final selfAccount = eg.account(user: selfUser); await testBinding.globalStore.add(selfAccount, eg.initialSnapshot()); - final store = await testBinding.globalStore.perAccount(selfAccount.id); + store = await testBinding.globalStore.perAccount(selfAccount.id); await store.addUser(selfUser); for (final user in users) { @@ -173,8 +178,9 @@ void main() { // TODO(#232): syntax like `check(find(…), findsOneWidget)` final widget = tester.widget(find.descendant( of: find.byType(RecentDmConversationsItem), - matching: find.text(expectedText), - )); + // The title might contain a WidgetSpan (for status emoji); exclude + // the resulting placeholder character from the text to be matched. + matching: findText(expectedText, includePlaceholders: false))); if (expectedLines != null) { final renderObject = tester.renderObject(find.byWidget(widget)); check(renderObject.size.height).equals( @@ -183,6 +189,16 @@ void main() { } } + void checkFindsStatusEmoji(WidgetTester tester, Finder emojiFinder) { + final statusEmojiFinder = find.ancestor(of: emojiFinder, + matching: find.byType(UserStatusEmoji)); + check(statusEmojiFinder).findsOne(); + check(tester.widget(statusEmojiFinder) + .neverAnimate).isTrue(); + check(find.ancestor(of: statusEmojiFinder, + matching: find.byType(RecentDmConversationsItem))).findsOne(); + } + Future markMessageAsRead(WidgetTester tester, Message message) async { final store = await testBinding.globalStore.perAccount( testBinding.globalStore.accounts.single.id); @@ -229,6 +245,31 @@ void main() { checkTitle(tester, name, 2); }); + group('User status', () { + testWidgets('emoji & text are set -> emoji is displayed, text is not', (tester) async { + final message = eg.dmMessage(from: eg.selfUser, to: []); + await setupPage(tester, dmMessages: [message], users: []); + await store.changeUserStatus(eg.selfUser.userId, UserStatusChange( + text: OptionSome('Busy'), + emoji: OptionSome(StatusEmoji(emojiName: 'working_on_it', + emojiCode: '1f6e0', reactionType: ReactionType.unicodeEmoji)))); + await tester.pump(); + + checkFindsStatusEmoji(tester, find.text('\u{1f6e0}')); + check(find.textContaining('Busy')).findsNothing(); + }); + + testWidgets('emoji is not set, text is set -> text is not displayed', (tester) async { + final message = eg.dmMessage(from: eg.selfUser, to: []); + await setupPage(tester, dmMessages: [message], users: []); + await store.changeUserStatus(eg.selfUser.userId, UserStatusChange( + text: OptionSome('Busy'), emoji: OptionNone())); + await tester.pump(); + + check(find.textContaining('Busy')).findsNothing(); + }); + }); + testWidgets('unread counts', (tester) async { final message = eg.dmMessage(from: eg.selfUser, to: []); await setupPage(tester, users: [], dmMessages: [message]); @@ -289,6 +330,33 @@ void main() { checkTitle(tester, user.fullName, 2); }); + group('User status', () { + testWidgets('emoji & text are set -> emoji is displayed, text is not', (tester) async { + final user = eg.user(); + final message = eg.dmMessage(from: eg.selfUser, to: [user]); + await setupPage(tester, users: [user], dmMessages: [message]); + await store.changeUserStatus(user.userId, UserStatusChange( + text: OptionSome('Busy'), + emoji: OptionSome(StatusEmoji(emojiName: 'working_on_it', + emojiCode: '1f6e0', reactionType: ReactionType.unicodeEmoji)))); + await tester.pump(); + + checkFindsStatusEmoji(tester, find.text('\u{1f6e0}')); + check(find.textContaining('Busy')).findsNothing(); + }); + + testWidgets('emoji is not set, text is set -> text is not displayed', (tester) async { + final user = eg.user(); + final message = eg.dmMessage(from: eg.selfUser, to: [user]); + await setupPage(tester, users: [user], dmMessages: [message]); + await store.changeUserStatus(user.userId, UserStatusChange( + text: OptionSome('Busy'), emoji: OptionNone())); + await tester.pump(); + + check(find.textContaining('Busy')).findsNothing(); + }); + }); + testWidgets('unread counts', (tester) async { final message = eg.dmMessage(from: eg.otherUser, to: [eg.selfUser]); await setupPage(tester, users: [], dmMessages: [message]); @@ -377,6 +445,20 @@ void main() { checkTitle(tester, users.map((u) => u.fullName).join(', '), 2); }); + testWidgets('status emoji & text are set -> none of them is displayed', (tester) async { + final users = usersList(4); + final message = eg.dmMessage(from: eg.selfUser, to: users); + await setupPage(tester, users: users, dmMessages: [message]); + await store.changeUserStatus(users.first.userId, UserStatusChange( + text: OptionSome('Busy'), + emoji: OptionSome(StatusEmoji(emojiName: 'working_on_it', + emojiCode: '1f6e0', reactionType: ReactionType.unicodeEmoji)))); + await tester.pump(); + + check(find.text('\u{1f6e0}')).findsNothing(); + check(find.textContaining('Busy')).findsNothing(); + }); + testWidgets('unread counts', (tester) async { final message = eg.dmMessage(from: eg.thirdUser, to: [eg.selfUser, eg.otherUser]); await setupPage(tester, users: [], dmMessages: [message]);