Files
cls 9dfa06ffee
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled
feat: import Chinese-localized Buzz source snapshot
Signed-off-by: cls_宁波本机 <908705107@qq.com>
2026-08-13 18:34:25 +08:00

2054 lines
62 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:async';
import 'dart:math';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:hooks_riverpod/misc.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:buzz/features/channels/channel.dart';
import 'package:buzz/features/channels/channel_management_provider.dart';
import 'package:buzz/features/channels/channel_sections/channel_sections_provider.dart';
import 'package:buzz/features/channels/channel_sections/channel_sections_storage.dart';
import 'package:buzz/features/channels/channels_page.dart';
import 'package:buzz/features/channels/channels_provider.dart';
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/user_profile.dart';
import 'package:buzz/shared/auth/auth.dart';
import 'package:buzz/shared/community/community_icon_provider.dart';
import 'package:buzz/shared/relay/relay.dart';
import 'package:buzz/shared/theme/theme.dart';
import 'package:buzz/shared/widgets/avatar_image.dart';
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';
void main() {
Widget buildTestable({
required List<Override> overrides,
bool previewDirectory = false,
double keyboardInset = 0,
bool disableAnimations = false,
double bottomPadding = 0,
Map<String, String?> communityIcons = const {},
ValueChanged<String>? onCommunityIconLoad,
TextScaler textScaler = TextScaler.noScaling,
Gradient? topSectionGradient,
ValueChanged<double>? onSettingsTransitionProgress,
ValueListenable<int>? tabReselection,
}) {
return ProviderScope(
overrides: [
// Provide a fake profile and presence so the avatar doesn't hit the network.
profileProvider.overrideWith(() => _FakeProfileNotifier()),
presenceProvider.overrideWith(() => _FakePresenceNotifier()),
communityIconProvider.overrideWith((ref, relayUrl) async {
onCommunityIconLoad?.call(relayUrl);
return communityIcons[relayUrl];
}),
dmDirectoryPreviewEnabledProvider.overrideWith(
(ref) => previewDirectory,
),
...overrides,
],
child: MaterialApp(
theme: AppTheme.light(topSectionGradient: topSectionGradient),
builder: (context, child) => MediaQuery(
data: MediaQuery.of(context).copyWith(
disableAnimations: disableAnimations,
textScaler: textScaler,
padding: EdgeInsets.only(bottom: bottomPadding),
viewInsets: EdgeInsets.only(bottom: keyboardInset),
),
child: child!,
),
home: Stack(
children: [
ChannelsPage(
settingsPageBuilder: _buildSettingsPage,
onSettingsTransitionProgress:
onSettingsTransitionProgress ?? (_) {},
tabReselection: tabReselection,
),
const Positioned.fill(
child: ChannelQuickActionsLauncher(
visible: true,
navigationBarHeight: 60,
navigationBarBottomGap: 12,
navigationBarWidth: 218,
systemBottomInset: 0,
rightInset: 16,
),
),
],
),
),
);
}
final testChannels = [
Channel(
id: '1',
name: 'general',
channelType: 'stream',
visibility: 'open',
description: 'General discussion',
createdBy: 'abc',
createdAt: DateTime(2025),
memberCount: 10,
isMember: true,
),
Channel(
id: '2',
name: 'design-forum',
channelType: 'forum',
visibility: 'open',
description: 'Discuss designs',
createdBy: 'abc',
createdAt: DateTime(2025),
memberCount: 3,
isMember: true,
),
Channel(
id: '3',
name: 'DM',
channelType: 'dm',
visibility: 'open',
description: 'Direct message',
createdBy: 'abc',
createdAt: DateTime(2025),
memberCount: 2,
participants: const ['Test', 'Alice'],
participantPubkeys: const ['aabb', 'alice'],
isMember: true,
),
];
testWidgets('shows grouped channel list when data loads', (tester) async {
await tester.pumpWidget(
buildTestable(
overrides: [
channelsProvider.overrideWith(() => _FakeNotifier(testChannels)),
],
),
);
await tester.pumpAndSettle();
expect(find.text('general'), findsOneWidget);
expect(find.text('design-forum'), findsNothing);
expect(find.text('Alice'), findsOneWidget);
expect(find.text('Channels'), findsOneWidget);
expect(find.text('FORUMS'), findsNothing);
expect(find.text('DMs'), findsOneWidget);
expect(find.text('Community'), findsOneWidget);
expect(find.byTooltip('Create or start conversation'), findsOneWidget);
expect(find.byTooltip('Channels options'), findsOneWidget);
expect(find.byIcon(LucideIcons.ellipsisVertical), findsWidgets);
expect(find.byIcon(LucideIcons.arrowUpDown), findsNothing);
expect(find.byTooltip('DMs options'), findsOneWidget);
await tester.tap(find.byTooltip('Channels options'));
await tester.pumpAndSettle();
expect(find.text('Sort: Recent'), findsOneWidget);
expect(find.text('Sort: AZ'), findsOneWidget);
final popover = find.byKey(const ValueKey('sort-popover-Channels'));
expect(popover, findsOneWidget);
expect(
find.descendant(of: popover, matching: find.byType(PopupMenuDivider)),
findsNothing,
);
final selectedCheck = find.byKey(const ValueKey('sort-selected-check'));
expect(selectedCheck, findsOneWidget);
expect(
tester.getCenter(selectedCheck).dx,
greaterThan(tester.getCenter(find.text('Sort: AZ')).dx),
);
for (final label in ['general', 'Alice']) {
final text = tester.widget<Text>(find.text(label));
expect(text.style?.fontSize, contentListTitleTextStyle.fontSize);
expect(text.style?.height, contentListTitleTextStyle.height);
expect(
text.style?.color,
Theme.of(
tester.element(find.text(label)),
).colorScheme.onSurface.withValues(alpha: 0.8),
);
}
final channelIcon = tester.widget<Icon>(
find.byKey(const ValueKey('channel-icon-1')),
);
expect(
channelIcon.color,
Theme.of(
tester.element(find.byKey(const ValueKey('channel-icon-1'))),
).colorScheme.onSurface.withValues(alpha: 0.8),
);
final sectionTitle = tester.widget<Text>(find.text('Channels'));
expect(sectionTitle.style?.fontSize, contentListTitleTextStyle.fontSize);
expect(sectionTitle.style?.fontWeight, FontWeight.w600);
});
testWidgets('sizes the community header for accessible text', (tester) async {
await tester.pumpWidget(
buildTestable(
textScaler: const TextScaler.linear(2),
overrides: [
channelsProvider.overrideWith(() => _FakeNotifier(testChannels)),
],
),
);
await tester.pumpAndSettle();
final appBar = tester.widget<FrostedAppBar>(
find.byType(FrostedAppBar).last,
);
final titleStyle = appBar.titleStyle!;
expect(titleStyle.fontSize, 22);
expect(
tester
.getSize(
find.descendant(
of: find.byType(FrostedAppBar).last,
matching: find.byType(ClipRect),
),
)
.height,
closeTo(
frostedAppBarHeight(
tester.element(find.byType(FrostedAppBar).last),
titleStyle: titleStyle,
bottomHeight: appBar.bottomHeight,
) -
1,
0.01,
),
);
expect(tester.takeException(), isNull);
});
testWidgets('keeps the last channel above the floating tab bar', (
tester,
) async {
const footerClearance = 102.0;
await tester.pumpWidget(
buildTestable(
bottomPadding: footerClearance,
overrides: [
channelsProvider.overrideWith(() => _FakeNotifier(testChannels)),
],
),
);
await tester.pumpAndSettle();
final padding = tester.widget<SliverPadding>(
find.descendant(
of: find.byType(CustomScrollView),
matching: find.byType(SliverPadding),
),
);
expect((padding.padding as EdgeInsets).bottom, footerClearance);
});
testWidgets('balances an expanded section around its following divider', (
tester,
) async {
await tester.pumpWidget(
buildTestable(
overrides: [
channelsProvider.overrideWith(() => _FakeNotifier(testChannels)),
],
),
);
await tester.pumpAndSettle();
final lastChannel = tester.getRect(find.text('general'));
final divider = tester.getRect(find.byType(Divider).last);
final nextSectionHeader = tester.getRect(find.text('DMs'));
expect(
divider.top - lastChannel.bottom,
closeTo(nextSectionHeader.top - divider.bottom, 0.01),
);
});
testWidgets('keeps the Buzz background fixed behind the channels list', (
tester,
) async {
await tester.pumpWidget(
buildTestable(
topSectionGradient: const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Colors.yellow, Colors.blue],
),
overrides: [
channelsProvider.overrideWith(() => _FakeNotifier(testChannels)),
],
),
);
await tester.pumpAndSettle();
expect(
find.byKey(const ValueKey('frosted-scaffold-pinned-gradient')),
findsOneWidget,
);
expect(find.byType(DecoratedSliver), findsNothing);
final gradientBackground = tester.widget<DecoratedBox>(
find.byKey(const ValueKey('frosted-scaffold-pinned-gradient')),
);
final gradient =
(gradientBackground.decoration as BoxDecoration).gradient
as LinearGradient;
expect(gradient.end, Alignment.bottomCenter);
final appBar = tester.widget<FrostedAppBar>(
find.byType(FrostedAppBar).last,
);
expect(appBar.frosted, isFalse);
expect(appBar.frostedSurfaceOpacity, 0);
expect(appBar.frostedBlurSigma, 0);
expect(appBar.showBottomDivider, isFalse);
expect(appBar.bottomHeight, Grid.xxs);
});
testWidgets('builds Home header frost progressively while scrolling', (
tester,
) async {
tester.view.physicalSize = const Size(320, 160);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await tester.pumpWidget(
buildTestable(
topSectionGradient: const LinearGradient(
colors: [Colors.yellow, Colors.blue],
),
overrides: [
channelsProvider.overrideWith(() => _FakeNotifier(testChannels)),
],
),
);
await tester.pumpAndSettle();
final scrollable = tester.state<ScrollableState>(
find
.descendant(
of: find.byType(CustomScrollView),
matching: find.byType(Scrollable),
)
.first,
);
expect(scrollable.position.maxScrollExtent, greaterThanOrEqualTo(Grid.xxl));
scrollable.position.jumpTo(Grid.xl / 2);
await tester.pump();
var appBar = tester.widget<FrostedAppBar>(find.byType(FrostedAppBar).last);
expect(appBar.frosted, isTrue);
expect(appBar.frostedSurfaceOpacity, 0);
expect(appBar.frostedBlurSigma, closeTo(8.67, 0.001));
scrollable.position.jumpTo(Grid.xxl);
await tester.pump();
appBar = tester.widget<FrostedAppBar>(find.byType(FrostedAppBar).last);
expect(appBar.frostedSurfaceOpacity, 0);
expect(appBar.frostedBlurSigma, 23.12);
});
testWidgets('scrolls Home to the top when its tab is selected again', (
tester,
) async {
tester.view.physicalSize = const Size(320, 160);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
final tabReselection = ValueNotifier(0);
addTearDown(tabReselection.dispose);
await tester.pumpWidget(
buildTestable(
tabReselection: tabReselection,
overrides: [
channelsProvider.overrideWith(() => _FakeNotifier(testChannels)),
],
),
);
await tester.pumpAndSettle();
final scrollable = tester.state<ScrollableState>(
find
.descendant(
of: find.byType(CustomScrollView),
matching: find.byType(Scrollable),
)
.first,
);
scrollable.position.jumpTo(scrollable.position.maxScrollExtent);
tabReselection.value++;
await tester.pump();
await tester.pump(const Duration(milliseconds: 130));
expect(
scrollable.position.pixels,
lessThan(scrollable.position.maxScrollExtent),
);
await tester.pumpAndSettle();
expect(scrollable.position.pixels, scrollable.position.minScrollExtent);
});
testWidgets('truncates long custom section names beside the menu', (
tester,
) async {
tester.view.physicalSize = const Size(320, 800);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
const sectionName = 'A deliberately long custom section name for testing';
await tester.pumpWidget(
buildTestable(
overrides: [
channelsProvider.overrideWith(() => _FakeNotifier(testChannels)),
channelSectionsProvider.overrideWith(
() => _FakeChannelSectionsNotifier(
const ChannelSectionStore(
sections: [
ChannelSection(id: 'section-1', name: sectionName, order: 0),
],
),
),
),
],
),
);
await tester.pumpAndSettle();
final label = tester.widget<Text>(find.text(sectionName));
expect(label.maxLines, 1);
expect(label.overflow, TextOverflow.ellipsis);
expect(tester.takeException(), isNull);
});
testWidgets('section menu matches desktop labels, icons, and inset', (
tester,
) async {
await tester.pumpWidget(
buildTestable(
overrides: [
channelsProvider.overrideWith(() => _FakeNotifier(testChannels)),
channelSectionsProvider.overrideWith(
() => _FakeChannelSectionsNotifier(
const ChannelSectionStore(
sections: [
ChannelSection(id: 'section-1', name: 'Design', order: 0),
],
),
),
),
],
),
);
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('section-menu-section-1')));
await tester.pumpAndSettle();
final popover = find.byKey(const Key('section-popover-section-1'));
expect(popover, findsOneWidget);
for (final label in [
'Rename section',
'Move up',
'Move down',
'Delete section',
]) {
expect(
find.descendant(of: popover, matching: find.text(label)),
findsOne,
);
}
for (final icon in [
LucideIcons.pencil,
LucideIcons.arrowUp,
LucideIcons.arrowDown,
LucideIcons.trash2,
]) {
expect(
find.descendant(of: popover, matching: find.byIcon(icon)),
findsOne,
);
}
final actionMenuItems = tester
.widgetList<PopupMenuItem<String>>(
find.descendant(
of: popover,
matching: find.byWidgetPredicate(
(widget) => widget is PopupMenuItem<String>,
),
),
)
.where(
(item) => const {
'rename',
'move_up',
'move_down',
'delete',
}.contains(item.value),
);
expect(actionMenuItems, hasLength(4));
for (final item in actionMenuItems) {
expect(
item.padding,
const EdgeInsets.fromLTRB(Grid.xs, 0, Grid.twelve, 0),
);
}
final error = Theme.of(tester.element(popover)).colorScheme.error;
final deleteText = tester.widget<Text>(find.text('Delete section'));
final deleteIcon = tester.widget<Icon>(
find.descendant(of: popover, matching: find.byIcon(LucideIcons.trash2)),
);
expect(deleteText.style?.color, error);
expect(deleteIcon.color, error);
});
testWidgets('aligns the top, section, row, and skeleton label columns', (
tester,
) async {
final relaySession = _ReconnectingRelaySession();
await tester.pumpWidget(
buildTestable(
overrides: [
channelsProvider.overrideWith(() => _FakeNotifier(testChannels)),
relaySessionProvider.overrideWith(() => relaySession),
],
),
);
relaySession.connect();
await tester.pumpAndSettle();
final topLabelX = tester.getTopLeft(find.text('Community')).dx;
final sectionLabelX = tester.getTopLeft(find.text('Channels')).dx;
final rowLabelX = tester.getTopLeft(find.text('general')).dx;
// The community title shares the leading row with its avatar. Channel
// labels stay aligned below it.
expect(topLabelX, Grid.twelve + 40 + Grid.xxs);
expect(sectionLabelX, rowLabelX);
relaySession.setReconnecting();
await tester.pump();
await tester.pump(const Duration(seconds: 2));
await tester.pump();
final skeletonSectionLabelX = tester
.getTopLeft(
find.byKey(const Key('channels-skeleton-section-label')).first,
)
.dx;
final skeletonRowLabelX = tester
.getTopLeft(
find.byKey(const Key('channels-skeleton-row-label-0')).first,
)
.dx;
expect(skeletonSectionLabelX, skeletonRowLabelX);
expect(skeletonSectionLabelX, sectionLabelX);
});
testWidgets('matches the community and profile avatar circle sizes', (
tester,
) async {
await tester.pumpWidget(
buildTestable(
overrides: [
channelsProvider.overrideWith(() => _FakeNotifier(testChannels)),
],
),
);
await tester.pumpAndSettle();
final appBar = find.byType(FrostedAppBar).last;
final communityAvatar = find.descendant(
of: appBar,
matching: find.byType(AvatarImage),
);
final profileAvatar = find.descendant(
of: appBar,
matching: find.byType(MaskedAvatarBadge),
);
expect(tester.getSize(communityAvatar), const Size.square(40));
expect(tester.getSize(profileAvatar), const Size.square(40));
});
testWidgets('reveals channel content from same-slot reconnect skeletons', (
tester,
) async {
final relaySession = _ReconnectingRelaySession();
await tester.pumpWidget(
buildTestable(
overrides: [
channelsProvider.overrideWith(() => _FakeNotifier(testChannels)),
relaySessionProvider.overrideWith(() => relaySession),
],
),
);
await tester.pump();
await tester.pump(const Duration(seconds: 2));
await tester.pump();
final skeleton = find.byKey(const Key('channels-connection-skeleton'));
expect(skeleton, findsOneWidget);
expect(
find.descendant(of: skeleton, matching: find.byType(SkeletonBar)),
findsWidgets,
);
expect(
find.descendant(
of: skeleton,
matching: find.byType(CircularProgressIndicator),
),
findsNothing,
);
expect(
tester
.widget<Opacity>(find.byKey(const Key('skeleton-reveal-placeholder')))
.opacity,
1,
);
expect(
tester
.widget<Opacity>(find.byKey(const Key('skeleton-reveal-content')))
.opacity,
0,
);
relaySession.connect();
await tester.pump();
await tester.pump();
expect(
tester.widget<SkeletonReveal>(find.byType(SkeletonReveal)).loading,
isFalse,
);
await tester.pump(const Duration(milliseconds: 200));
expect(
tester
.widget<Opacity>(find.byKey(const Key('skeleton-reveal-placeholder')))
.opacity,
closeTo(0.5, 0.01),
);
expect(
tester
.widget<Opacity>(find.byKey(const Key('skeleton-reveal-content')))
.opacity,
closeTo(0.5, 0.01),
);
await tester.pump(const Duration(milliseconds: 200));
expect(find.text('general'), findsOneWidget);
});
testWidgets('announces neutral loading outside connection transitions', (
tester,
) async {
final relaySession = _ReconnectingRelaySession(
initialStatus: SessionStatus.connected,
);
await tester.pumpWidget(
buildTestable(
overrides: [
channelsProvider.overrideWith(() => _LoadingNotifier()),
relaySessionProvider.overrideWith(() => relaySession),
],
),
);
await tester.pump();
expect(
tester
.widget<Semantics>(
find.byKey(const Key('channels-connection-skeleton')),
)
.properties
.label,
'Loading',
);
});
testWidgets('opens the settings page supplied by the app layer', (
tester,
) async {
await tester.pumpWidget(
buildTestable(
overrides: [
channelsProvider.overrideWith(() => _FakeNotifier(testChannels)),
],
),
);
await tester.pumpAndSettle();
expect(find.byType(Hero), findsNothing);
await tester.tap(find.byType(ProfileAvatar));
await tester.pumpAndSettle();
expect(find.text('Injected settings'), findsOneWidget);
final route = ModalRoute.of(tester.element(find.text('Injected settings')));
expect(route, isNot(isA<MaterialPageRoute<void>>()));
expect(route?.opaque, isFalse);
});
testWidgets('reports Settings progress in both directions', (tester) async {
final progress = <double>[];
await tester.pumpWidget(
buildTestable(
overrides: [
channelsProvider.overrideWith(() => _FakeNotifier(testChannels)),
],
onSettingsTransitionProgress: progress.add,
),
);
await tester.pumpAndSettle();
await tester.tap(find.byType(ProfileAvatar));
await tester.pumpAndSettle();
expect(progress.any((value) => value > 0 && value < 1), isTrue);
expect(progress.last, 1);
final reverseStart = progress.length;
Navigator.of(tester.element(find.text('Injected settings'))).pop();
await tester.pumpAndSettle();
expect(
progress.skip(reverseStart).any((value) => value > 0 && value < 1),
isTrue,
);
expect(progress.last, 0);
});
testWidgets('paints Settings content with its surface from the first frame', (
tester,
) async {
await tester.pumpWidget(
buildTestable(
overrides: [
channelsProvider.overrideWith(() => _FakeNotifier(testChannels)),
],
),
);
await tester.pumpAndSettle();
await tester.tap(find.byType(ProfileAvatar));
await tester.pump();
final transition = find.byKey(
const ValueKey('settings-transition-opacity'),
skipOffstage: false,
);
expect(transition, findsOneWidget);
expect(
find.descendant(
of: transition,
matching: find.byKey(
const ValueKey('settings-transition-layer'),
skipOffstage: false,
),
),
findsOneWidget,
);
expect(tester.widget<FadeTransition>(transition).opacity.value, 0.8);
await tester.pump(const Duration(milliseconds: 95));
expect(
tester.widget<FadeTransition>(transition).opacity.value,
inExclusiveRange(0.8, 1),
);
await tester.pumpAndSettle();
expect(tester.widget<FadeTransition>(transition).opacity.value, 1);
Navigator.of(tester.element(find.text('Injected settings'))).pop();
await tester.pump();
await tester.pump(const Duration(milliseconds: 95));
expect(
tester.widget<FadeTransition>(transition).opacity.value,
inExclusiveRange(0, 1),
reason: 'The complete Settings layer still fades out on exit.',
);
});
testWidgets('gives feedback for the profile and community controls', (
tester,
) async {
final hapticCalls = <MethodCall>[];
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(SystemChannels.platform, (call) async {
if (call.method == 'HapticFeedback.vibrate') hapticCalls.add(call);
return null;
});
addTearDown(
() => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(SystemChannels.platform, null),
);
await tester.pumpWidget(
buildTestable(
overrides: [
channelsProvider.overrideWith(() => _FakeNotifier(testChannels)),
],
),
);
await tester.pumpAndSettle();
await tester.tap(find.byType(ProfileAvatar));
await tester.pumpAndSettle();
expect(hapticCalls.single.arguments, 'HapticFeedbackType.lightImpact');
Navigator.of(tester.element(find.text('Injected settings'))).pop();
await tester.pumpAndSettle();
final communityAvatar = find.descendant(
of: find.byType(FrostedAppBar).last,
matching: find.byType(AvatarImage),
);
await tester.tap(communityAvatar);
await tester.pump();
expect(hapticCalls.last.arguments, 'HapticFeedbackType.selectionClick');
});
testWidgets('community switcher separates selection from edit removal', (
tester,
) async {
tester.view.physicalSize = const Size(390, 844);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final communities = [
Community(
id: 'alpha',
name: 'Alpha',
relayUrl: 'wss://alpha.example.com',
addedAt: DateTime(2025),
),
Community(
id: 'bravo',
name: 'Bravo',
relayUrl: 'wss://bravo.example.com',
addedAt: DateTime(2025),
),
];
final communityNotifier = _FakeCommunityListNotifier(communities);
await tester.pumpWidget(
buildTestable(
communityIcons: const {
'wss://alpha.example.com':
'data:image/png;base64,'
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
},
overrides: [
channelsProvider.overrideWith(() => _FakeNotifier(testChannels)),
communityListProvider.overrideWith(() => communityNotifier),
activeCommunityProvider.overrideWith(
(ref) async => communities.first,
),
],
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Alpha'));
await tester.pumpAndSettle();
expect(find.text('Switch Community'), findsOneWidget);
final options = find.byKey(const Key('community-switcher-options'));
expect(options, findsOneWidget);
final editButton = find.byKey(const Key('community-switcher-edit'));
expect(
tester.getRect(options).top - tester.getRect(editButton).bottom,
closeTo(8, 0.01),
);
expect(tester.getSize(editButton).height, 32);
expect(find.text('alpha.example.com'), findsOneWidget);
expect(find.text('bravo.example.com'), findsOneWidget);
expect(find.text('Rename'), findsNothing);
expect(
find.descendant(
of: options,
matching: find.byIcon(LucideIcons.ellipsisVertical),
),
findsNothing,
);
expect(find.text('Edit'), findsOneWidget);
expect(find.byIcon(LucideIcons.trash2), findsNothing);
expect(
tester.getSize(find.byKey(const Key('community-switcher-avatar-alpha'))),
const Size.square(36),
);
final alphaAvatar = tester.widget<AvatarImage>(
find.descendant(
of: find.byKey(const Key('community-switcher-avatar-alpha')),
matching: find.byType(AvatarImage),
),
);
expect(alphaAvatar.imageUrl, startsWith('data:image/png;base64,'));
final activeSelection = find.byKey(
const Key('community-switcher-selection-alpha'),
);
final inactiveSelection = find.byKey(
const Key('community-switcher-selection-bravo'),
);
expect(tester.getSize(activeSelection), const Size.square(40));
expect(
tester.getSize(find.byKey(const Key('community-switcher-circle-alpha'))),
const Size.square(24),
);
expect(
find.descendant(
of: activeSelection,
matching: find.byIcon(LucideIcons.check),
),
findsOneWidget,
);
expect(
find.descendant(
of: inactiveSelection,
matching: find.byIcon(LucideIcons.check),
),
findsNothing,
);
expect(
find.descendant(of: options, matching: find.byType(Divider)),
findsNWidgets(2),
);
await tester.tap(find.text('Edit'));
await tester.pump();
final activeAction = find.byKey(
const Key('community-switcher-action-alpha'),
);
final actionSwitcher = tester.widget<AnimatedSwitcher>(
find.descendant(
of: activeAction,
matching: find.byType(AnimatedSwitcher),
),
);
expect(actionSwitcher.duration, const Duration(milliseconds: 250));
expect(activeSelection, findsOneWidget);
expect(
find.byKey(const Key('community-switcher-remove-alpha')),
findsOneWidget,
);
await tester.pump(const Duration(milliseconds: 125));
final transitioningOpacities = tester
.widgetList<Opacity>(
find.descendant(of: activeAction, matching: find.byType(Opacity)),
)
.map((opacity) => opacity.opacity);
expect(
transitioningOpacities.any((opacity) => opacity > 0 && opacity < 1),
isTrue,
);
expect(
find.descendant(of: activeAction, matching: find.byType(ImageFiltered)),
findsWidgets,
);
await tester.pumpAndSettle();
expect(find.text('Done'), findsOneWidget);
expect(find.byIcon(LucideIcons.trash2), findsNWidgets(2));
expect(activeSelection, findsNothing);
expect(inactiveSelection, findsNothing);
await tester.tap(find.byTooltip('Remove Bravo'));
await tester.pumpAndSettle();
expect(find.text('Remove community?'), findsOneWidget);
expect(
find.text(
'Are you sure you want to remove “Bravo”? '
'You can pair with it again later.',
),
findsOneWidget,
);
await tester.tap(find.widgetWithText(FilledButton, 'Remove'));
await tester.pumpAndSettle();
expect(communityNotifier.removedIds, ['bravo']);
expect(find.text('Switch Community'), findsOneWidget);
expect(find.text('Bravo'), findsNothing);
expect(find.text('Done'), findsOneWidget);
await tester.tap(find.text('Done'));
await tester.pumpAndSettle();
expect(find.text('Edit'), findsOneWidget);
expect(
find.byKey(const Key('community-switcher-selection-alpha')),
findsOneWidget,
);
});
testWidgets('opening the community switcher refreshes visible icons', (
tester,
) async {
final community = Community(
id: 'alpha',
name: 'Alpha',
relayUrl: 'wss://alpha.example.com',
addedAt: DateTime(2025),
);
var iconLoads = 0;
await tester.pumpWidget(
buildTestable(
onCommunityIconLoad: (_) => iconLoads++,
overrides: [
channelsProvider.overrideWith(() => _FakeNotifier(testChannels)),
communityListProvider.overrideWith(
() => _FakeCommunityListNotifier([community]),
),
activeCommunityProvider.overrideWith((ref) async => community),
],
),
);
await tester.pumpAndSettle();
final initialIconLoads = iconLoads;
await tester.tap(find.text('Alpha'));
await tester.pumpAndSettle();
expect(iconLoads, greaterThan(initialIconLoads));
});
testWidgets('community switcher header grows with accessible text', (
tester,
) async {
final community = Community(
id: 'alpha',
name: 'Alpha',
relayUrl: 'wss://alpha.example.com',
addedAt: DateTime(2025),
);
await tester.pumpWidget(
buildTestable(
textScaler: TextScaler.linear(2),
overrides: [
channelsProvider.overrideWith(() => _FakeNotifier(testChannels)),
communityListProvider.overrideWith(
() => _FakeCommunityListNotifier([community]),
),
activeCommunityProvider.overrideWith((ref) async => community),
],
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Alpha'));
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
expect(
tester.getSize(find.byKey(const Key('community-switcher-title'))).height,
greaterThan(32),
);
});
testWidgets('community switcher disables icon motion when requested', (
tester,
) async {
final communities = [
Community(
id: 'alpha',
name: 'Alpha',
relayUrl: 'wss://alpha.example.com',
addedAt: DateTime(2025),
),
];
await tester.pumpWidget(
buildTestable(
disableAnimations: true,
overrides: [
channelsProvider.overrideWith(() => _FakeNotifier(testChannels)),
communityListProvider.overrideWith(
() => _FakeCommunityListNotifier(communities),
),
activeCommunityProvider.overrideWith(
(ref) async => communities.first,
),
],
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Alpha'));
await tester.pumpAndSettle();
await tester.tap(find.text('Edit'));
await tester.pump();
final actionSwitcher = tester.widget<AnimatedSwitcher>(
find.descendant(
of: find.byKey(const Key('community-switcher-action-alpha')),
matching: find.byType(AnimatedSwitcher),
),
);
expect(actionSwitcher.duration, Duration.zero);
expect(
find.byKey(const Key('community-switcher-selection-alpha')),
findsNothing,
);
expect(
find.byKey(const Key('community-switcher-remove-alpha')),
findsOneWidget,
);
});
testWidgets('quick actions slide behind navigation when leaving home', (
tester,
) async {
Widget buildLauncher({required bool visible}) {
return ProviderScope(
overrides: [profileProvider.overrideWith(() => _FakeProfileNotifier())],
child: MaterialApp(
theme: AppTheme.light(),
home: Stack(
children: [
Positioned.fill(
child: ChannelQuickActionsLauncher(
visible: visible,
navigationBarHeight: 60,
navigationBarBottomGap: 12,
navigationBarWidth: 218,
systemBottomInset: 0,
rightInset: 16,
),
),
],
),
),
);
}
await tester.pumpWidget(buildLauncher(visible: true));
await tester.pumpAndSettle();
Transform motionTransform() => tester.widget<Transform>(
find.byKey(const Key('channel-quick-actions-transform')),
);
expect(motionTransform().transform.getTranslation().x, 0);
await tester.pumpWidget(buildLauncher(visible: false));
await tester.pump();
await tester.pump(const Duration(milliseconds: 110));
final midpoint = motionTransform().transform.getTranslation().x;
expect(midpoint, lessThan(0));
expect(midpoint, greaterThan(-279));
await tester.pumpAndSettle();
expect(motionTransform().transform.getTranslation().x, closeTo(-279, 0.01));
final hiddenOpacity = tester.widget<Opacity>(
find.byKey(const Key('channel-quick-actions-opacity')),
);
expect(hiddenOpacity.opacity, 0);
final hiddenPointerGate = tester.widget<IgnorePointer>(
find
.ancestor(
of: find.byKey(const Key('channel-quick-actions-transform')),
matching: find.byType(IgnorePointer),
)
.first,
);
expect(hiddenPointerGate.ignoring, isTrue);
});
testWidgets('quick actions spring into spaced muted cards', (tester) async {
await tester.pumpWidget(
buildTestable(
overrides: [
channelsProvider.overrideWith(() => _FakeNotifier(testChannels)),
],
),
);
await tester.pumpAndSettle();
final surface = find.byKey(const Key('quick-actions-surface'));
expect(tester.getSize(surface), const Size.square(56));
await tester.tap(find.byTooltip('Create or start conversation'));
await tester.pump();
var largestHeight = tester.getSize(surface).height;
for (var frame = 0; frame < 20; frame++) {
await tester.pump(const Duration(milliseconds: 16));
largestHeight = max(largestHeight, tester.getSize(surface).height);
}
await tester.pumpAndSettle();
expect(largestHeight, greaterThan(160));
expect(tester.getSize(surface).height, closeTo(160, 0.01));
final screenWidth = MediaQuery.sizeOf(tester.element(surface)).width;
final surfaceRect = tester.getRect(surface);
expect(surfaceRect.left, closeTo(20, 0.01));
expect(surfaceRect.right, closeTo(screenWidth - 20, 0.01));
final menuRect = tester.getRect(
find.byKey(const Key('quick-actions-menu')),
);
final createCard = find.byKey(
const Key('quick-action-create-channel-card'),
);
final dmCard = find.byKey(const Key('quick-action-new-dm-card'));
final createRect = tester.getRect(createCard);
final dmRect = tester.getRect(dmCard);
expect(createRect.left - menuRect.left, closeTo(8, 0.01));
expect(menuRect.right - createRect.right, closeTo(8, 0.01));
expect(dmRect.left - menuRect.left, closeTo(8, 0.01));
expect(menuRect.right - dmRect.right, closeTo(8, 0.01));
expect(dmRect.top - createRect.bottom, closeTo(8, 0.01));
expect(dmRect.width, createRect.width);
expect(dmRect.width, closeTo(menuRect.width - 16, 0.01));
final cardScheme = Theme.of(tester.element(createCard)).colorScheme;
final expectedCardColor = Color.alphaBlend(
cardScheme.onPrimary.withValues(alpha: 0.1),
cardScheme.primary,
);
final createMaterial = tester.widget<Material>(
find.descendant(of: createCard, matching: find.byType(Material)).first,
);
final dmMaterial = tester.widget<Material>(
find.descendant(of: dmCard, matching: find.byType(Material)).first,
);
expect(createMaterial.color, expectedCardColor);
expect(dmMaterial.color, expectedCardColor);
expect(
(createMaterial.borderRadius as BorderRadius).topLeft.x,
closeTo(12, 0.01),
);
expect(
(dmMaterial.borderRadius as BorderRadius).topLeft.x,
closeTo(12, 0.01),
);
expect(find.text('Start a new stream channel'), findsNothing);
expect(
tester.widget<Text>(find.text('Create channel')).style?.fontSize,
16,
);
expect(
tester.widget<Text>(find.text('New direct message')).style?.fontSize,
16,
);
expect(find.text('Message one or more people'), findsNothing);
});
testWidgets('create channel sheet lists type and visibility radio options', (
tester,
) async {
await tester.pumpWidget(
buildTestable(
overrides: [
channelsProvider.overrideWith(() => _FakeNotifier(testChannels)),
],
),
);
await tester.pumpAndSettle();
await tester.tap(find.byTooltip('Create or start conversation'));
await tester.pumpAndSettle();
await tester.tap(find.text('Create channel'));
await tester.pumpAndSettle();
final createSheetRect = tester.getRect(find.byType(BottomSheet).last);
expect(createSheetRect.top, greaterThanOrEqualTo(24));
tester.testTextInput.hide();
await tester.pumpAndSettle();
expect(find.text('Create a new channel'), findsOneWidget);
expect(find.text('Name'), findsOneWidget);
expect(find.text('Description Optional'), findsOneWidget);
expect(find.text('Channel type'), findsOneWidget);
expect(find.text('Ongoing'), findsOneWidget);
expect(find.text('Temporary'), findsOneWidget);
expect(find.text('Visibility'), findsOneWidget);
expect(find.text('Public'), findsOneWidget);
expect(find.text('Private'), findsOneWidget);
expect(find.byKey(const Key('create-channel-submit')), findsNothing);
final nameField = tester.widget<TextField>(
find.byKey(const Key('create-channel-name')),
);
final descriptionField = tester.widget<TextField>(
find.byKey(const Key('create-channel-description')),
);
expect(nameField.textInputAction, TextInputAction.done);
expect(nameField.onSubmitted, isNotNull);
expect(descriptionField.textInputAction, TextInputAction.done);
expect(descriptionField.onSubmitted, isNotNull);
expect(
tester.getSize(find.byKey(const Key('create-channel-name'))).height,
greaterThanOrEqualTo(52),
);
expect(
tester
.getSize(find.byKey(const Key('create-channel-type-ongoing')))
.height,
greaterThanOrEqualTo(56),
);
await tester.tap(find.byKey(const Key('create-channel-type-temporary')));
await tester.pumpAndSettle();
expect(find.text('Expires after'), findsOneWidget);
expect(find.text('7 days'), findsOneWidget);
expect(
tester.getSize(find.byKey(const Key('create-channel-ttl'))).height,
greaterThanOrEqualTo(51),
);
final privateVisibility = find.byKey(
const Key('create-channel-visibility-private'),
);
await tester.ensureVisible(privateVisibility);
await tester.pumpAndSettle();
await tester.tap(privateVisibility);
await tester.pumpAndSettle();
expect(
tester
.widget<RadioGroup<String>>(find.byType(RadioGroup<String>))
.groupValue,
'private',
);
});
testWidgets('new message sheet lists and selects relay members', (
tester,
) async {
tester.view.physicalSize = const Size(390, 800);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
const directoryUsers = [
DirectoryUser(
pubkey: 'alice',
displayName: 'Alice',
nip05Handle: 'alice@example.com',
),
DirectoryUser(pubkey: 'bob', displayName: 'Bob'),
DirectoryUser(pubkey: 'charlie', displayName: 'Charlie'),
DirectoryUser(pubkey: 'danielle', displayName: 'Danielle'),
];
await tester.pumpWidget(
buildTestable(
overrides: [
channelsProvider.overrideWith(() => _FakeNotifier(testChannels)),
relayDirectoryUsersProvider.overrideWith(
(ref) async => directoryUsers,
),
relayDirectorySearchProvider.overrideWith((ref, query) async {
return directoryUsers
.where(
(user) =>
user.label.toLowerCase().contains(query.toLowerCase()),
)
.toList();
}),
],
),
);
await tester.pumpAndSettle();
await tester.tap(find.byTooltip('Create or start conversation'));
await tester.pumpAndSettle();
await tester.tap(find.text('New direct message'));
await tester.pumpAndSettle();
final dmSheetRect = tester.getRect(find.byType(BottomSheet).last);
expect(dmSheetRect.top, greaterThanOrEqualTo(24));
expect(find.text('New message'), findsOneWidget);
expect(find.text('To:'), findsOneWidget);
expect(find.text('Search for a person'), findsOneWidget);
final recipientField = find.byKey(const Key('new-dm-recipient-field'));
final initialRecipientWidth = tester.getSize(recipientField).width;
expect(tester.getSize(recipientField).height, greaterThanOrEqualTo(48));
expect(find.byKey(const Key('new-dm-person-alice')), findsOneWidget);
expect(find.byKey(const Key('new-dm-person-bob')), findsOneWidget);
expect(
find.descendant(
of: find.byKey(const Key('new-dm-person-alice')),
matching: find.byType(AvatarImage),
),
findsOneWidget,
);
await tester.enterText(find.byKey(const Key('new-dm-search')), 'bob');
await tester.pump(const Duration(milliseconds: 300));
await tester.pumpAndSettle();
expect(find.byKey(const Key('new-dm-person-alice')), findsNothing);
expect(find.byKey(const Key('new-dm-person-bob')), findsOneWidget);
await tester.enterText(find.byKey(const Key('new-dm-search')), '');
await tester.pump(const Duration(milliseconds: 300));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('new-dm-person-alice')));
await tester.pump();
final aliceChip = find.byKey(const Key('new-dm-selected-alice'));
final inlineSearch = find.byKey(const Key('new-dm-search'));
final aliceRect = tester.getRect(aliceChip);
final inlineSearchRect = tester.getRect(inlineSearch);
expect(inlineSearchRect.left, greaterThan(aliceRect.right));
expect(
(inlineSearchRect.center.dy - aliceRect.center.dy).abs(),
lessThan(1),
);
expect(find.text('Search for a person'), findsNothing);
expect(tester.widget<TextField>(inlineSearch).focusNode?.hasFocus, isTrue);
expect(tester.getSize(recipientField).width, initialRecipientWidth);
expect(tester.getSize(aliceChip).height, 40);
expect(tester.getSize(aliceChip).width, lessThanOrEqualTo(224));
final aliceChipMaterial = tester.widget<Material>(
find.descendant(of: aliceChip, matching: find.byType(Material)).first,
);
expect(aliceChipMaterial.shape, isA<StadiumBorder>());
expect(
aliceChipMaterial.color,
Theme.of(tester.element(aliceChip)).colorScheme.surfaceContainerHighest,
);
expect(
tester
.widget<AvatarImage>(
find.descendant(of: aliceChip, matching: find.byType(AvatarImage)),
)
.radius,
16,
);
expect(
tester
.widget<Text>(
find.descendant(of: aliceChip, matching: find.text('Alice')),
)
.style
?.fontSize,
16,
);
expect(
find.descendant(of: aliceChip, matching: find.byIcon(LucideIcons.x)),
findsNothing,
);
expect(find.bySemanticsLabel('Remove Alice'), findsOneWidget);
await tester.tap(find.byKey(const Key('new-dm-person-bob')));
await tester.pump();
await tester.tap(find.byKey(const Key('new-dm-person-charlie')));
await tester.pump();
await tester.tap(find.byKey(const Key('new-dm-person-danielle')));
await tester.pump();
expect(aliceChip, findsOneWidget);
expect(find.byKey(const Key('new-dm-selected-bob')), findsOneWidget);
expect(find.byKey(const Key('new-dm-selected-charlie')), findsOneWidget);
final danielleChip = find.byKey(const Key('new-dm-selected-danielle'));
expect(danielleChip, findsOneWidget);
expect(tester.getSize(recipientField).width, initialRecipientWidth);
expect(
tester.getRect(danielleChip).top,
greaterThan(tester.getRect(aliceChip).top),
);
final recipientScrollViews = tester.widgetList<SingleChildScrollView>(
find.ancestor(
of: aliceChip,
matching: find.byType(SingleChildScrollView),
),
);
expect(
recipientScrollViews.every(
(scrollView) => scrollView.scrollDirection == Axis.vertical,
),
isTrue,
);
expect(
find.ancestor(
of: aliceChip,
matching: find.byKey(const Key('new-dm-recipient-wrap')),
),
findsOneWidget,
);
await tester.tap(find.byKey(const Key('new-dm-selected-bob')));
await tester.pump();
expect(find.byKey(const Key('new-dm-selected-bob')), findsNothing);
expect(find.byKey(const Key('new-dm-person-bob')), findsOneWidget);
expect(tester.widget<TextField>(inlineSearch).focusNode?.hasFocus, isTrue);
expect(find.text('Cancel'), findsNothing);
expect(find.text('Open DM'), findsNothing);
final searchField = tester.widget<TextField>(
find.byKey(const Key('new-dm-search')),
);
expect(searchField.textInputAction, TextInputAction.done);
expect(searchField.onSubmitted, isNotNull);
});
testWidgets('wraps many recipients without overflowing above the keyboard', (
tester,
) async {
tester.view.physicalSize = const Size(390, 800);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final directoryUsers = [
for (var index = 0; index < 8; index++)
DirectoryUser(
pubkey: 'person-$index',
displayName: 'Long recipient name number $index',
),
];
await tester.pumpWidget(
buildTestable(
keyboardInset: 300,
overrides: [
channelsProvider.overrideWith(() => _FakeNotifier(testChannels)),
relayDirectoryUsersProvider.overrideWith(
(ref) async => directoryUsers,
),
],
),
);
await tester.pumpAndSettle();
await tester.tap(find.byTooltip('Create or start conversation'));
await tester.pumpAndSettle();
await tester.tap(find.text('New direct message'));
await tester.pumpAndSettle();
for (final user in directoryUsers) {
final result = find.byKey(Key('new-dm-person-${user.pubkey}'));
await tester.ensureVisible(result);
await tester.pumpAndSettle();
await tester.tap(result);
await tester.pumpAndSettle();
}
expect(find.byKey(const Key('new-dm-recipient-wrap')), findsOneWidget);
expect(
find.bySemanticsLabel(RegExp(r'^Remove Long recipient')),
findsNWidgets(8),
);
expect(
find.text('DMs support up to nine people, including you.'),
findsOneWidget,
);
expect(tester.takeException(), isNull);
});
testWidgets('shows local preview people when the relay is offline', (
tester,
) async {
tester.view.physicalSize = const Size(390, 800);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(
buildTestable(
previewDirectory: true,
overrides: [
channelsProvider.overrideWith(() => _FakeNotifier(testChannels)),
],
),
);
await tester.pumpAndSettle();
await tester.tap(find.byTooltip('Create or start conversation'));
await tester.pumpAndSettle();
await tester.tap(find.text('New direct message'));
await tester.pumpAndSettle();
expect(find.text('Could not load people from this relay.'), findsNothing);
expect(find.text('Maya Chen'), findsOneWidget);
expect(find.text('Jordan Brooks'), findsOneWidget);
expect(find.text('Priya Shah'), findsOneWidget);
await tester.tap(
find.byKey(
const Key(
'new-dm-person-'
'1111111111111111111111111111111111111111111111111111111111111111',
),
),
);
await tester.pump();
expect(find.text('Maya Chen'), findsWidgets);
await tester.testTextInput.receiveAction(TextInputAction.done);
await tester.pump();
expect(
find.text('Preview only — mock people cannot be messaged.'),
findsOneWidget,
);
});
testWidgets('hides unjoined and archived channels from the main list', (
tester,
) async {
final channels = [
...testChannels,
Channel(
id: '4',
name: 'open-stream',
channelType: 'stream',
visibility: 'open',
description: 'Available to join',
createdBy: 'abc',
createdAt: DateTime(2025),
memberCount: 8,
isMember: false,
),
Channel(
id: '5',
name: 'archived-stream',
channelType: 'stream',
visibility: 'open',
description: 'Archived channel',
createdBy: 'abc',
createdAt: DateTime(2025),
memberCount: 4,
isMember: true,
archivedAt: DateTime(2025, 1, 2),
),
];
await tester.pumpWidget(
buildTestable(
overrides: [
channelsProvider.overrideWith(() => _FakeNotifier(channels)),
],
),
);
await tester.pumpAndSettle();
// Unjoined and archived channels should not appear in the main list.
expect(find.text('general'), findsOneWidget);
expect(find.text('open-stream'), findsNothing);
expect(find.text('archived-stream'), findsNothing);
});
testWidgets('shows empty state when no channels', (tester) async {
await tester.pumpWidget(
buildTestable(
overrides: [channelsProvider.overrideWith(() => _FakeNotifier([]))],
),
);
await tester.pumpAndSettle();
expect(find.text('No conversations yet'), findsOneWidget);
});
testWidgets('shows error view with retry button', (tester) async {
await tester.pumpWidget(
buildTestable(
overrides: [channelsProvider.overrideWith(() => _ErrorNotifier())],
),
);
// The error view is gated on a grace timer in ChannelsPage to absorb
// transient AsyncError frames during relay reconnect. Pump once to mount
// and schedule the timer, advance the fake clock past the grace window,
// then pump again to flush the setState the timer triggered.
await tester.pump();
await tester.pump(const Duration(seconds: 3));
await tester.pump();
expect(find.text('Could not load channels'), findsOneWidget);
expect(find.text('Retry'), findsOneWidget);
});
testWidgets('bolds and clears unread channel labels', (tester) async {
final channels = [
Channel(
id: '1',
name: 'general',
channelType: 'stream',
visibility: 'open',
description: 'General discussion',
createdBy: 'abc',
createdAt: DateTime(2025),
memberCount: 10,
lastMessageAt: DateTime.fromMillisecondsSinceEpoch(
20 * 1000,
isUtc: true,
),
isMember: true,
),
];
final readState = _FakeReadStateNotifier(
const ReadStateState(
isReady: true,
pubkey: 'pk',
contexts: {'1': 10},
version: 0,
),
);
await tester.pumpWidget(
buildTestable(
overrides: [
channelsProvider.overrideWith(
() => _FakeNotifier(
channels,
observedEventsByChannel: {
'1': [_observed(id: 'msg-1', createdAt: 20)],
},
),
),
readStateProvider.overrideWith(() => readState),
],
),
);
await tester.pumpAndSettle();
expect(
tester.widget<Text>(find.text('general')).style?.fontWeight,
FontWeight.w700,
);
expect(
tester.widget<Text>(find.text('general')).style?.color,
Theme.of(tester.element(find.text('general'))).colorScheme.onSurface,
);
readState.markContextRead('1', 20);
await tester.pump();
expect(
tester.widget<Text>(find.text('general')).style?.fontWeight,
FontWeight.w400,
);
});
testWidgets('bolds channels with unread thread activity without a badge', (
tester,
) async {
final channels = [
Channel(
id: '1',
name: 'general',
channelType: 'stream',
visibility: 'open',
description: 'General discussion',
createdBy: 'abc',
createdAt: DateTime(2025),
memberCount: 10,
lastMessageAt: DateTime.fromMillisecondsSinceEpoch(
30 * 1000,
isUtc: true,
),
isMember: true,
),
];
final readState = _FakeReadStateNotifier(
const ReadStateState(
isReady: true,
pubkey: 'pk',
contexts: {'1': 10},
version: 0,
),
);
await tester.pumpWidget(
buildTestable(
overrides: [
channelsProvider.overrideWith(
() => _FakeNotifier(
channels,
observedEventsByChannel: {
'1': [
_observed(
id: 'reply-1',
createdAt: 20,
rootId: 'root',
isThreadedReply: true,
),
_observed(
id: 'reply-2',
createdAt: 30,
rootId: 'root',
isThreadedReply: true,
),
],
},
),
),
readStateProvider.overrideWith(() => readState),
],
),
);
await tester.pumpAndSettle();
expect(
tester.widget<Text>(find.text('general')).style?.fontWeight,
FontWeight.w700,
);
});
testWidgets('seeds first loaded channels as read', (tester) async {
final channels = [
Channel(
id: '1',
name: 'general',
channelType: 'stream',
visibility: 'open',
description: 'General discussion',
createdBy: 'abc',
createdAt: DateTime(2025),
memberCount: 10,
lastMessageAt: DateTime.fromMillisecondsSinceEpoch(
20 * 1000,
isUtc: true,
),
isMember: true,
),
];
final readState = _FakeReadStateNotifier(
const ReadStateState(
isReady: true,
pubkey: 'pk',
contexts: {},
version: 0,
),
);
await tester.pumpWidget(
buildTestable(
overrides: [
channelsProvider.overrideWith(() => _FakeNotifier(channels)),
readStateProvider.overrideWith(() => readState),
],
),
);
await tester.pumpAndSettle();
expect(readState.seededContexts, {'1': 20});
expect(readState.markedContexts, isEmpty);
expect(
tester.widget<Text>(find.text('general')).style?.fontWeight,
FontWeight.w400,
);
});
testWidgets('waits for read-state readiness before initial seeding', (
tester,
) async {
final channels = [
Channel(
id: '1',
name: 'general',
channelType: 'stream',
visibility: 'open',
description: 'General discussion',
createdBy: 'abc',
createdAt: DateTime(2025),
memberCount: 10,
lastMessageAt: DateTime.fromMillisecondsSinceEpoch(
20 * 1000,
isUtc: true,
),
isMember: true,
),
];
final readState = _FakeReadStateNotifier(
const ReadStateState(
isReady: false,
pubkey: 'pk',
contexts: {},
version: 0,
),
);
await tester.pumpWidget(
buildTestable(
overrides: [
channelsProvider.overrideWith(() => _FakeNotifier(channels)),
readStateProvider.overrideWith(() => readState),
],
),
);
await tester.pumpAndSettle();
expect(readState.seededContexts, isEmpty);
expect(readState.markedContexts, isEmpty);
readState.setReady();
await tester.pumpAndSettle();
expect(readState.seededContexts, {'1': 20});
expect(readState.markedContexts, isEmpty);
});
}
Widget _buildSettingsPage(BuildContext context) =>
const Scaffold(body: Text('Injected settings'));
class _FakeNotifier extends ChannelsNotifier {
final List<Channel> _channels;
final Map<String, Map<String, ObservedUnreadEvent>> _observedEventsByChannel;
_FakeNotifier(
this._channels, {
Map<String, List<ObservedUnreadEvent>> observedEventsByChannel = const {},
}) : _observedEventsByChannel = {
for (final entry in observedEventsByChannel.entries)
entry.key: {for (final event in entry.value) event.id: event},
};
@override
Future<List<Channel>> build() async => _channels;
@override
Map<String, int> get latestObservedByChannel => {
for (final entry in _observedEventsByChannel.entries)
if (entry.value.isNotEmpty)
entry.key: entry.value.values
.map((event) => event.createdAt)
.reduce((left, right) => left > right ? left : right),
};
@override
Map<String, Map<String, ObservedUnreadEvent>>
get observedUnreadEventsByChannel => _observedEventsByChannel;
}
class _FakeChannelSectionsNotifier extends ChannelSectionsNotifier {
_FakeChannelSectionsNotifier(this._store);
final ChannelSectionStore _store;
@override
ChannelSectionsState build() =>
ChannelSectionsState(isReady: true, store: _store, version: 1);
}
class _FakeCommunityListNotifier extends CommunityListNotifier {
_FakeCommunityListNotifier(this._communities);
List<Community> _communities;
final List<String> removedIds = [];
@override
Future<List<Community>> build() async => _communities;
@override
Future<void> removeCommunity(String id) async {
removedIds.add(id);
_communities = _communities
.where((community) => community.id != id)
.toList();
state = AsyncData(_communities);
}
}
class _ErrorNotifier extends ChannelsNotifier {
@override
Future<List<Channel>> build() => Future.error('Connection refused');
}
class _LoadingNotifier extends ChannelsNotifier {
@override
Future<List<Channel>> build() => Completer<List<Channel>>().future;
}
class _ReconnectingRelaySession extends RelaySessionNotifier {
final SessionStatus initialStatus;
_ReconnectingRelaySession({this.initialStatus = SessionStatus.reconnecting});
@override
SessionState build() => SessionState(status: initialStatus);
@override
Future<List<NostrEvent>> fetchHistory(
NostrFilter filter, {
Duration timeout = const Duration(seconds: 8),
}) async => [];
@override
Future<void Function()> subscribe(
NostrFilter filter,
void Function(NostrEvent) onEvent, {
void Function(String message)? onClosed,
}) async => () {};
void connect() {
state = const SessionState(status: SessionStatus.connected);
}
void setReconnecting() {
state = const SessionState(status: SessionStatus.reconnecting);
}
}
class _FakeProfileNotifier extends ProfileNotifier {
@override
Future<UserProfile?> build() async =>
const UserProfile(pubkey: 'aabb', displayName: 'Test');
}
class _FakePresenceNotifier extends PresenceNotifier {
@override
Future<String> build() async => 'online';
}
class _FakeReadStateNotifier extends ReadStateNotifier {
final ReadStateState _initialState;
final Map<String, int> seededContexts = {};
final Map<String, int> markedContexts = {};
_FakeReadStateNotifier(this._initialState);
@override
ReadStateState build() => _initialState;
void setReady() {
state = ReadStateState(
isReady: true,
pubkey: state.pubkey,
contexts: state.contexts,
version: state.version + 1,
forcedUnreadContexts: state.forcedUnreadContexts,
);
}
@override
void seedContextRead(String contextId, int unixTimestamp) {
seededContexts[contextId] = unixTimestamp;
state = state.copyWithContext(contextId, unixTimestamp);
}
@override
void markContextRead(
String contextId,
int unixTimestamp, {
bool clearForcedMessages = false,
}) {
markedContexts[contextId] = unixTimestamp;
state = state.copyWithContext(contextId, unixTimestamp);
}
}
ObservedUnreadEvent _observed({
required String id,
required int createdAt,
String? rootId,
bool highPriority = false,
bool isThreadedReply = false,
}) => makeObservedUnreadEvent(
id: id,
createdAt: createdAt,
rootId: rootId,
highPriority: highPriority,
channelType: 'stream',
isThreadedReply: isThreadedReply,
);