Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion mobile/test/features/channels/channel_detail_page_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -202,6 +211,7 @@ NostrEvent _edit({

Widget _buildTestable({
required List<NostrEvent> messages,
PresenceCacheNotifier? presenceCache,
List<TypingEntry> typing = const [],
Map<String, UserProfile> users = const {},
Set<String>? knownAgentPubkeys,
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -474,6 +486,7 @@ double? effectiveFontSizeForText(
}

void main() {
presenceTests();
setUp(() async {
SharedPreferences.setMockInitialValues({});
_testPrefs = await SharedPreferences.getInstance();
Expand Down
Original file line number Diff line number Diff line change
@@ -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 <String?, String>{
null: 'Unknown',
'online': 'Online',
'away': 'Away',
'offline': 'Offline',
}.entries) {
cache.setPresence(entry.key);
await tester.pumpAndSettle();
expect(
tester
.widget<Text>(find.byKey(const ValueKey('dm-header-presence')))
.data,
entry.value,
);
expect(
tester.widget<MaskedAvatarBadge>(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 <String?, String>{
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<String, String> build() => {};
@override
void track(List<String> pubkeys) {}
void setPresence(String? status) => state = {'alice': ?status};
}

// Opt-in rendered evidence; ordinary test runs perform no filesystem writes.
Future<void> _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<void> _capturePresence(WidgetTester tester, String name) async {
final directory = Platform.environment['PRESENCE_SCREENSHOTS'];
if (directory == null) return;
final boundary = tester.renderObject<RenderRepaintBoundary>(
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();
});
}
8 changes: 8 additions & 0 deletions mobile/test/features/channels/channels_page_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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<Override> overrides,
Expand Down Expand Up @@ -137,6 +140,11 @@ void main() {
),
];

presenceListTests(
(overrides) => buildTestable(overrides: overrides),
testChannels,
);

testWidgets('DM tile presence observation failure hides the dot', (
tester,
) async {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
part of '../channels_page_test.dart';

void presenceListTests(
Widget Function(List<Override>) harness,
List<Channel> 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<Container>(
find.descendant(of: dots, matching: find.byType(Container)),
);
final theme = AppTheme.light();
expect((dot.decoration as BoxDecoration).color, switch (status) {
'online' => theme.extension<AppColors>()!.success,
'away' => theme.extension<AppColors>()!.warning,
_ => theme.colorScheme.outline,
});
expect(tester.getRect(avatar), bounds);
}
cache.state = {};
await tester.pumpAndSettle();
expect(dots, findsNothing);
});
}

class _ListPresenceFixture extends PresenceCacheNotifier {
@override
Map<String, String> build() => {};
@override
void track(List<String> pubkeys) {}
}