diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index 32e287e2a4e..29874f390c2 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -4,11 +4,17 @@ import '../profile/presence_snapshot_test.dart' show PresenceTestRelay, presenceEvent; import 'dart:collection'; import 'dart:convert'; +import 'dart:io'; +import 'dart:ui' as ui; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart' - show RenderParagraph, ScrollDirection, SemanticsAction; + show + RenderParagraph, + RenderRepaintBoundary, + ScrollDirection, + SemanticsAction; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -46,6 +52,7 @@ import 'package:buzz/shared/read_state/read_state_provider.dart'; import 'package:buzz/features/channels/unread_badge/observed_unread_event.dart'; import 'package:buzz/features/channels/small_avatar.dart'; import 'package:buzz/features/profile/profile_provider.dart'; +import 'package:buzz/features/profile/presence_cache_provider.dart'; import 'package:buzz/shared/profile/user_cache_provider.dart'; import 'package:buzz/shared/profile/user_profile.dart'; import 'package:buzz/features/profile/user_profile_sheet.dart'; @@ -67,6 +74,8 @@ import 'package:buzz/shared/widgets/masked_avatar_badge.dart'; import 'package:buzz/shared/widgets/skeleton.dart'; import 'package:shared_preferences/shared_preferences.dart'; +part 'channel_detail_page_test/presence_tests.dart'; + const _channelId = '11111111-2222-4333-8444-555555555555'; const _huddleChannelId = '8d764100-fd8f-44cf-9c98-6d8fbd739b8c'; const _otherChannelId = '22222222-3333-4444-8555-666666666666'; @@ -202,6 +211,7 @@ NostrEvent _edit({ Widget _buildTestable({ required List messages, + PresenceCacheNotifier? presenceCache, List typing = const [], Map users = const {}, Set? knownAgentPubkeys, @@ -272,6 +282,8 @@ Widget _buildTestable({ () => userCacheNotifier ?? _FakeUserCacheNotifier(users), ), profileProvider.overrideWith(() => _FakeProfileNotifier()), + if (presenceCache != null) + presenceCacheProvider.overrideWith(() => presenceCache), channelsProvider.overrideWith(() => fakeChannelsNotifier), channelStarsProvider.overrideWith(_FakeChannelStarsNotifier.new), channelMutesProvider.overrideWith(_FakeChannelMutesNotifier.new), @@ -474,6 +486,7 @@ double? effectiveFontSizeForText( } void main() { + presenceTests(); setUp(() async { SharedPreferences.setMockInitialValues({}); _testPrefs = await SharedPreferences.getInstance(); diff --git a/mobile/test/features/channels/channel_detail_page_test/presence_tests.dart b/mobile/test/features/channels/channel_detail_page_test/presence_tests.dart new file mode 100644 index 00000000000..ddead61a911 --- /dev/null +++ b/mobile/test/features/channels/channel_detail_page_test/presence_tests.dart @@ -0,0 +1,137 @@ +part of '../channel_detail_page_test.dart'; + +void presenceTests() { + testWidgets('DM header never substitutes offline for unknown presence', ( + tester, + ) async { + await _loadPresenceFonts(tester); + final cache = _PresenceFixture(); + final channel = Channel( + id: _channelId, + name: 'DM', + channelType: 'dm', + description: 'Direct message', + visibility: 'private', + createdBy: 'self', + createdAt: DateTime(2025), + memberCount: 2, + participants: const ['Self', 'Alice'], + participantPubkeys: const ['self', 'alice'], + isMember: true, + ); + await tester.pumpWidget( + _buildTestable( + messages: const [], + channel: channel, + presenceCache: cache, + users: const { + 'alice': UserProfile( + pubkey: 'alice', + displayName: 'Alice', + ownerPubkey: 'self', + ), + }, + ), + ); + await tester.pumpAndSettle(); + final avatar = find.byKey(const ValueKey('dm-header-avatar')); + final bounds = tester.getRect(avatar); + for (final entry in { + null: 'Unknown', + 'online': 'Online', + 'away': 'Away', + 'offline': 'Offline', + }.entries) { + cache.setPresence(entry.key); + await tester.pumpAndSettle(); + expect( + tester + .widget(find.byKey(const ValueKey('dm-header-presence'))) + .data, + entry.value, + ); + expect( + tester.widget(avatar).badge, + entry.key == null ? isNull : isNotNull, + ); + expect(tester.getRect(avatar), bounds); + await _capturePresence(tester, 'dm-${entry.value}'); + } + cache.setPresence(null); + await tester.pumpAndSettle(); + expect(find.text('Offline'), findsNothing); + expect(tester.takeException(), isNull); + }); + + testWidgets('profile exposes one truthful presence label for every state', ( + tester, + ) async { + await _loadPresenceFonts(tester); + final cache = _PresenceFixture(); + final semantics = tester.ensureSemantics(); + + await tester.pumpWidget( + _buildTestable( + messages: const [], + presenceCache: cache, + users: const { + 'alice': UserProfile( + pubkey: 'alice', + displayName: 'Alice', + ownerPubkey: 'self', + ), + }, + home: const Scaffold(body: UserProfileSheet(pubkey: 'alice')), + ), + ); + await tester.pumpAndSettle(); + for (final entry in { + null: 'Unknown', + 'online': 'Online', + 'away': 'Away', + 'offline': 'Offline', + }.entries) { + cache.setPresence(entry.key); + await tester.pumpAndSettle(); + expect(find.text(entry.value), findsOneWidget); + expect(find.bySemanticsLabel('Presence: ${entry.value}'), findsOneWidget); + if (entry.key != 'offline') expect(find.text('Offline'), findsNothing); + await _capturePresence(tester, 'profile-${entry.value}'); + } + expect(tester.takeException(), isNull); + semantics.dispose(); + }); +} + +class _PresenceFixture extends PresenceCacheNotifier { + @override + Map build() => {}; + @override + void track(List pubkeys) {} + void setPresence(String? status) => state = {'alice': ?status}; +} + +// Opt-in rendered evidence; ordinary test runs perform no filesystem writes. +Future _loadPresenceFonts(WidgetTester tester) async { + if (Platform.environment['PRESENCE_SCREENSHOTS'] == null) return; + await tester.binding.setSurfaceSize(const Size(390, 844)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + await (FontLoader( + 'Inter', + )..addFont(rootBundle.load('assets/fonts/InterVariable.ttf'))).load(); +} + +Future _capturePresence(WidgetTester tester, String name) async { + final directory = Platform.environment['PRESENCE_SCREENSHOTS']; + if (directory == null) return; + final boundary = tester.renderObject( + find.byType(RepaintBoundary).first, + ); + await tester.runAsync(() async { + final image = await boundary.toImage(); + final data = await image.toByteData(format: ui.ImageByteFormat.png); + await Directory(directory).create(recursive: true); + await File('$directory/$name.png').writeAsBytes(data!.buffer.asUint8List()); + image.dispose(); + }); +} diff --git a/mobile/test/features/channels/channels_page_test.dart b/mobile/test/features/channels/channels_page_test.dart index dcf8dbc8631..8e2b22bc759 100644 --- a/mobile/test/features/channels/channels_page_test.dart +++ b/mobile/test/features/channels/channels_page_test.dart @@ -21,6 +21,7 @@ import 'package:buzz/shared/read_state/read_state_provider.dart'; import 'package:buzz/features/channels/unread_badge/observed_unread_event.dart'; import 'package:buzz/features/profile/profile_avatar.dart'; import 'package:buzz/features/profile/profile_provider.dart'; +import 'package:buzz/features/profile/presence_cache_provider.dart'; import 'package:buzz/shared/profile/user_profile.dart'; import 'package:buzz/shared/utils/string_utils.dart'; import 'package:buzz/shared/auth/auth.dart'; @@ -33,6 +34,8 @@ import 'package:buzz/shared/widgets/frosted_app_bar.dart'; import 'package:buzz/shared/widgets/masked_avatar_badge.dart'; import 'package:buzz/shared/widgets/skeleton.dart'; +part 'channels_page_test/presence_tests.dart'; + void main() { Widget buildTestable({ required List overrides, @@ -137,6 +140,11 @@ void main() { ), ]; + presenceListTests( + (overrides) => buildTestable(overrides: overrides), + testChannels, + ); + testWidgets('DM tile presence observation failure hides the dot', ( tester, ) async { diff --git a/mobile/test/features/channels/channels_page_test/presence_tests.dart b/mobile/test/features/channels/channels_page_test/presence_tests.dart new file mode 100644 index 00000000000..147ef270f93 --- /dev/null +++ b/mobile/test/features/channels/channels_page_test/presence_tests.dart @@ -0,0 +1,53 @@ +part of '../channels_page_test.dart'; + +void presenceListTests( + Widget Function(List) harness, + List channels, +) { + testWidgets('DM list hides unconfirmed dots and paints confirmed presence', ( + tester, + ) async { + final cache = _ListPresenceFixture(); + await tester.pumpWidget( + harness([ + channelsProvider.overrideWith(() => _FakeNotifier(channels)), + presenceCacheProvider.overrideWith(() => cache), + ]), + ); + await tester.pumpAndSettle(); + final avatar = find.byWidgetPredicate( + (widget) => widget is AvatarImage && widget.radius == 9, + ); + final dots = find.byWidgetPredicate( + (widget) => + widget is Positioned && widget.bottom == -1 && widget.right == -1, + ); + expect(dots, findsNothing); + final bounds = tester.getRect(avatar); + for (final status in ['online', 'away', 'offline']) { + cache.state = {'alice': status}; + await tester.pumpAndSettle(); + expect(dots, findsOneWidget); + final dot = tester.widget( + find.descendant(of: dots, matching: find.byType(Container)), + ); + final theme = AppTheme.light(); + expect((dot.decoration as BoxDecoration).color, switch (status) { + 'online' => theme.extension()!.success, + 'away' => theme.extension()!.warning, + _ => theme.colorScheme.outline, + }); + expect(tester.getRect(avatar), bounds); + } + cache.state = {}; + await tester.pumpAndSettle(); + expect(dots, findsNothing); + }); +} + +class _ListPresenceFixture extends PresenceCacheNotifier { + @override + Map build() => {}; + @override + void track(List pubkeys) {} +}