feat: import Chinese-localized Buzz source snapshot
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
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
Signed-off-by: cls_宁波本机 <908705107@qq.com>
This commit is contained in:
@@ -0,0 +1,915 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:buzz/features/activity/activity_page.dart';
|
||||
import 'package:buzz/features/activity/activity_provider.dart';
|
||||
import 'package:buzz/features/activity/feed_item.dart';
|
||||
import 'package:buzz/features/activity/inbox_item.dart';
|
||||
import 'package:buzz/features/activity/reminders_provider.dart';
|
||||
import 'package:buzz/features/channels/channel.dart';
|
||||
import 'package:buzz/features/channels/channel_detail_page.dart';
|
||||
import 'package:buzz/features/channels/message_content.dart';
|
||||
import 'package:buzz/features/channels/channels_provider.dart';
|
||||
import 'package:buzz/shared/read_state/read_state_provider.dart';
|
||||
import 'package:buzz/shared/l10n/l10n.dart';
|
||||
import 'package:buzz/features/profile/user_cache_provider.dart';
|
||||
import 'package:buzz/features/profile/user_profile.dart';
|
||||
import 'package:buzz/shared/theme/theme.dart';
|
||||
import 'package:buzz/shared/widgets/anchored_popover_menu.dart';
|
||||
import 'package:buzz/shared/widgets/frosted_app_bar.dart';
|
||||
import 'package:buzz/shared/widgets/avatar_image.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:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
void main() {
|
||||
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
||||
|
||||
final testMention = FeedItem(
|
||||
id: 'm1',
|
||||
kind: 9,
|
||||
pubkey: 'alice_pk',
|
||||
content: 'Hey check this out',
|
||||
createdAt: now - 120,
|
||||
channelId: 'ch1',
|
||||
channelName: 'general',
|
||||
tags: const [],
|
||||
category: 'mention',
|
||||
);
|
||||
|
||||
final testThreadReply = FeedItem(
|
||||
id: 'a1',
|
||||
kind: 9,
|
||||
pubkey: 'bob_pk',
|
||||
content: 'Deployed the fix',
|
||||
createdAt: now - 3600,
|
||||
channelId: 'ch2',
|
||||
channelName: 'engineering',
|
||||
tags: const [
|
||||
['e', 'root1', '', 'root'],
|
||||
['e', 'root1', '', 'reply'],
|
||||
],
|
||||
category: 'activity',
|
||||
);
|
||||
|
||||
final testAgent = FeedItem(
|
||||
id: 'ag1',
|
||||
kind: 43004,
|
||||
pubkey: 'agent_pk',
|
||||
content: 'Job completed successfully',
|
||||
createdAt: now - 60,
|
||||
channelId: 'ch1',
|
||||
channelName: 'general',
|
||||
tags: const [],
|
||||
category: 'agent_activity',
|
||||
);
|
||||
|
||||
final testFeed = HomeFeedResponse(
|
||||
mentions: [testMention],
|
||||
needsAction: const [],
|
||||
activity: [testThreadReply],
|
||||
agentActivity: [testAgent],
|
||||
);
|
||||
|
||||
final testChannels = [
|
||||
Channel(
|
||||
id: 'ch1',
|
||||
name: 'general',
|
||||
channelType: 'stream',
|
||||
visibility: 'open',
|
||||
description: '',
|
||||
createdBy: 'x',
|
||||
createdAt: DateTime(2025),
|
||||
memberCount: 5,
|
||||
isMember: true,
|
||||
),
|
||||
Channel(
|
||||
id: 'ch2',
|
||||
name: 'engineering',
|
||||
channelType: 'stream',
|
||||
visibility: 'open',
|
||||
description: '',
|
||||
createdBy: 'x',
|
||||
createdAt: DateTime(2025),
|
||||
memberCount: 3,
|
||||
isMember: true,
|
||||
),
|
||||
];
|
||||
|
||||
final testUsers = <String, UserProfile>{
|
||||
'alice_pk': const UserProfile(
|
||||
pubkey: 'alice_pk',
|
||||
displayName: 'Alice',
|
||||
nip05Handle: 'alice@example.com',
|
||||
),
|
||||
'bob_pk': const UserProfile(pubkey: 'bob_pk', displayName: 'Bob'),
|
||||
'agent_pk': const UserProfile(pubkey: 'agent_pk', displayName: 'Scout'),
|
||||
};
|
||||
|
||||
Future<Widget> buildTestable({
|
||||
HomeFeedResponse? feed,
|
||||
ActivityNotifier Function()? activityNotifier,
|
||||
Map<String, UserProfile>? users,
|
||||
Map<String, int> readContexts = const {},
|
||||
List<Channel>? channels,
|
||||
TextScaler? textScaler,
|
||||
EdgeInsets mediaPadding = EdgeInsets.zero,
|
||||
ValueListenable<int>? tabReselection,
|
||||
}) async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return ProviderScope(
|
||||
overrides: [
|
||||
savedPrefsProvider.overrideWithValue(prefs),
|
||||
activityProvider.overrideWith(
|
||||
activityNotifier ?? () => _FakeActivityNotifier(feed ?? testFeed),
|
||||
),
|
||||
channelsProvider.overrideWith(
|
||||
() => _FakeChannelsNotifier(channels ?? testChannels),
|
||||
),
|
||||
userCacheProvider.overrideWith(
|
||||
() => _FakeUserCacheNotifier(users ?? testUsers),
|
||||
),
|
||||
readStateProvider.overrideWith(
|
||||
() => _FakeReadStateNotifier(readContexts),
|
||||
),
|
||||
remindersProvider.overrideWith(() => _FakeRemindersNotifier(const [])),
|
||||
],
|
||||
child: MaterialApp(
|
||||
locale: const Locale('en'),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
theme: AppTheme.light(),
|
||||
builder: (context, child) => MediaQuery(
|
||||
data: MediaQuery.of(
|
||||
context,
|
||||
).copyWith(textScaler: textScaler, padding: mediaPadding),
|
||||
child: child!,
|
||||
),
|
||||
home: ActivityPage(tabReselection: tabReselection),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
testWidgets('shows loading skeleton while feed loads', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
await buildTestable(activityNotifier: _PendingActivityNotifier.new),
|
||||
);
|
||||
// Single pump - don't settle, the future never completes.
|
||||
await tester.pump();
|
||||
|
||||
expect(find.byType(Container), findsWidgets);
|
||||
expect(find.text('Hey check this out'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('shows empty state when feed is empty', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
await buildTestable(
|
||||
feed: HomeFeedResponse(
|
||||
mentions: const [],
|
||||
needsAction: const [],
|
||||
activity: const [],
|
||||
agentActivity: const [],
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('No activity yet'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('does not imply a back button for the top-level Activity tab', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(await buildTestable());
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final appBar = tester.widget<FrostedAppBar>(
|
||||
find.byType(FrostedAppBar).last,
|
||||
);
|
||||
expect(appBar.automaticallyImplyLeading, isFalse);
|
||||
expect(appBar.gradient, isNull);
|
||||
expect(appBar.frosted, isTrue);
|
||||
expect(appBar.showBottomDivider, isTrue);
|
||||
expect(appBar.bottomHeight, Grid.xxs);
|
||||
expect(find.byTooltip('Back'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('sizes the Activity app bar for its custom title style', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
await buildTestable(textScaler: const TextScaler.linear(2)),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final appBar = tester.widget<FrostedAppBar>(
|
||||
find.byType(FrostedAppBar).last,
|
||||
);
|
||||
final titleStyle = appBar.titleStyle!;
|
||||
expect(titleStyle.fontSize, 22);
|
||||
expect(
|
||||
tester.getSize(find.byType(ClipRect).last).height,
|
||||
closeTo(
|
||||
frostedAppBarHeight(
|
||||
tester.element(find.byType(FrostedAppBar).last),
|
||||
titleStyle: titleStyle,
|
||||
bottomHeight: Grid.xxs,
|
||||
),
|
||||
0.01,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('keeps footer clearance inside the scrollable content', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
await buildTestable(mediaPadding: const EdgeInsets.only(bottom: 88)),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final safeArea = tester.widget<SafeArea>(
|
||||
find.byKey(const ValueKey('activity-content-safe-area')),
|
||||
);
|
||||
expect(safeArea.top, isFalse);
|
||||
expect(safeArea.bottom, isFalse);
|
||||
|
||||
final padding = tester.widget<SliverPadding>(
|
||||
find.descendant(
|
||||
of: find.byType(CustomScrollView),
|
||||
matching: find.byType(SliverPadding),
|
||||
),
|
||||
);
|
||||
expect(padding.padding, const EdgeInsets.fromLTRB(0, Grid.xxs, 0, 96));
|
||||
});
|
||||
|
||||
testWidgets('scrolls Activity to the top when its tab is selected again', (
|
||||
tester,
|
||||
) async {
|
||||
tester.view.physicalSize = const Size(320, 180);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
final tabReselection = ValueNotifier(0);
|
||||
addTearDown(tabReselection.dispose);
|
||||
await tester.pumpWidget(
|
||||
await buildTestable(tabReselection: tabReselection),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final scrollable = tester.state<ScrollableState>(
|
||||
find
|
||||
.descendant(
|
||||
of: find.byType(CustomScrollView),
|
||||
matching: find.byType(Scrollable),
|
||||
)
|
||||
.first,
|
||||
);
|
||||
expect(scrollable.position.maxScrollExtent, greaterThan(0));
|
||||
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('shows error view with retry button', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
await buildTestable(activityNotifier: _ErrorActivityNotifier.new),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Failed to load activity'), findsOneWidget);
|
||||
expect(find.text('Retry'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('activity popovers use fixed-layout scale and opacity motion', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(await buildTestable());
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final filterTrigger = find.byKey(const ValueKey('activity-filter-menu'));
|
||||
expect(
|
||||
tester.getSize(filterTrigger).height,
|
||||
greaterThanOrEqualTo(Grid.xl),
|
||||
reason: 'The Activity filter trigger must keep a 48dp touch target.',
|
||||
);
|
||||
|
||||
await tester.tap(filterTrigger);
|
||||
await tester.pump();
|
||||
|
||||
final surface = find.byKey(const ValueKey('activity-filter-popover'));
|
||||
final fade = find.byKey(const ValueKey('activity-popover-fade'));
|
||||
final scale = find.byKey(const ValueKey('activity-popover-scale'));
|
||||
expect(surface, findsOneWidget);
|
||||
expect(fade, findsOneWidget);
|
||||
expect(scale, findsOneWidget);
|
||||
|
||||
final initialSize = tester.getSize(surface);
|
||||
final initialFade = tester.widget<FadeTransition>(fade);
|
||||
final initialScale = tester.widget<ScaleTransition>(scale);
|
||||
expect(initialSize.width, 240);
|
||||
expect(initialFade.opacity.value, lessThan(1));
|
||||
expect(initialScale.scale.value, greaterThanOrEqualTo(0.96));
|
||||
expect(initialScale.scale.value, lessThan(1));
|
||||
expect(initialScale.alignment, Alignment.topLeft);
|
||||
|
||||
await tester.pump(const Duration(milliseconds: 75));
|
||||
|
||||
final movingFade = tester.widget<FadeTransition>(fade);
|
||||
final movingScale = tester.widget<ScaleTransition>(scale);
|
||||
expect(movingFade.opacity.value, greaterThan(0));
|
||||
expect(movingFade.opacity.value, lessThan(1));
|
||||
expect(movingScale.scale.value, greaterThan(0.96));
|
||||
expect(movingScale.scale.value, lessThan(1));
|
||||
expect(tester.getSize(surface), initialSize);
|
||||
|
||||
await tester.pump(const Duration(milliseconds: 75));
|
||||
expect(tester.widget<FadeTransition>(fade).opacity.value, 1);
|
||||
expect(tester.widget<ScaleTransition>(scale).scale.value, 1);
|
||||
expect(tester.getSize(surface), initialSize);
|
||||
|
||||
final material = tester.widget<Material>(surface);
|
||||
final shape = material.shape! as RoundedRectangleBorder;
|
||||
expect(shape.borderRadius, BorderRadius.circular(Radii.popover));
|
||||
expect(shape.side.color, Colors.black.withValues(alpha: 0.04));
|
||||
expect(material.elevation, appPopoverElevation);
|
||||
expect(
|
||||
material.shadowColor,
|
||||
appPopoverShadowColor(tester.element(surface)),
|
||||
);
|
||||
expect(material.surfaceTintColor, Colors.transparent);
|
||||
expect(material.clipBehavior, Clip.antiAlias);
|
||||
|
||||
final items = tester.widgetList<PopupMenuItem<InboxFilter>>(
|
||||
find.byType(PopupMenuItem<InboxFilter>),
|
||||
);
|
||||
expect(items, hasLength(InboxFilter.values.length));
|
||||
expect(
|
||||
items.every((item) => item.height >= Grid.xl),
|
||||
isTrue,
|
||||
reason: 'Activity filter choices must keep 48dp touch targets.',
|
||||
);
|
||||
|
||||
await tester.tap(find.descendant(of: surface, matching: find.text('All')));
|
||||
await tester.pumpAndSettle();
|
||||
final optionsTrigger = find.byKey(const ValueKey('activity-options-menu'));
|
||||
expect(
|
||||
tester.getSize(optionsTrigger),
|
||||
const Size(Grid.xl, Grid.xl),
|
||||
reason: 'Activity options must retain a 48dp touch target.',
|
||||
);
|
||||
await tester.tap(optionsTrigger);
|
||||
await tester.pump();
|
||||
|
||||
final optionsSurface = find.byKey(
|
||||
const ValueKey('activity-options-popover'),
|
||||
);
|
||||
final optionsMaterial = tester.widget<Material>(optionsSurface);
|
||||
final optionsShape = optionsMaterial.shape! as RoundedRectangleBorder;
|
||||
expect(tester.getSize(optionsSurface).width, 216);
|
||||
expect(optionsShape.borderRadius, BorderRadius.circular(Radii.popover));
|
||||
expect(optionsMaterial.elevation, appPopoverElevation);
|
||||
expect(
|
||||
tester
|
||||
.widget<ScaleTransition>(
|
||||
find.byKey(const ValueKey('activity-popover-scale')),
|
||||
)
|
||||
.alignment,
|
||||
Alignment.topRight,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('rows lead with sender, contextual label, and preview', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(await buildTestable());
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Sender names resolved from the user cache.
|
||||
expect(find.text('Alice'), findsOneWidget);
|
||||
expect(find.text('alice@example.com'), findsOneWidget);
|
||||
expect(find.text('Bob'), findsOneWidget);
|
||||
expect(find.text('Scout'), findsOneWidget);
|
||||
|
||||
// Contextual labels + channel chips.
|
||||
expect(find.text('Mentioned in'), findsOneWidget);
|
||||
expect(find.text('Thread in'), findsOneWidget);
|
||||
expect(find.text('#general'), findsNWidgets(2)); // mention + agent
|
||||
expect(find.text('#engineering'), findsOneWidget);
|
||||
|
||||
// Context labels and channel pills share the compact Activity style.
|
||||
final contextLabel = tester.widget<Text>(find.text('Mentioned in'));
|
||||
final channelLabel = tester.widgetList<Text>(find.text('#general')).first;
|
||||
expect(contextLabel.style?.fontSize, activityContextTextStyle.fontSize);
|
||||
expect(contextLabel.style?.fontWeight, activityContextTextStyle.fontWeight);
|
||||
expect(contextLabel.style?.height, activityContextTextStyle.height);
|
||||
expect(channelLabel.style?.fontSize, activityContextTextStyle.fontSize);
|
||||
expect(channelLabel.style?.fontWeight, activityContextTextStyle.fontWeight);
|
||||
expect(channelLabel.style?.height, activityContextTextStyle.height);
|
||||
|
||||
// Message previews.
|
||||
expect(find.textContaining('Hey check this out'), findsOneWidget);
|
||||
expect(find.textContaining('Deployed the fix'), findsOneWidget);
|
||||
|
||||
// Activity rows use their conversation-oriented scale.
|
||||
final senderText = tester.widget<Text>(find.text('Alice'));
|
||||
final theme = Theme.of(tester.element(find.text('Alice')));
|
||||
expect(senderText.style?.fontSize, activityUsernameTextStyle.fontSize);
|
||||
expect(senderText.style?.fontWeight, activityUsernameTextStyle.fontWeight);
|
||||
expect(senderText.style?.height, activityUsernameTextStyle.height);
|
||||
expect(senderText.style?.color, theme.colorScheme.onSurface);
|
||||
final usernameText = tester.widget<Text>(
|
||||
find.byKey(const ValueKey('activity-username-m1')),
|
||||
);
|
||||
final timestampText = tester.widget<Text>(
|
||||
find.byKey(const ValueKey('activity-timestamp-m1')),
|
||||
);
|
||||
expect(usernameText.style?.fontSize, messageMetadataTextStyle.fontSize);
|
||||
expect(usernameText.style?.fontWeight, FontWeight.w400);
|
||||
expect(usernameText.style?.height, messageMetadataTextStyle.height);
|
||||
expect(timestampText.style?.fontSize, messageMetadataTextStyle.fontSize);
|
||||
expect(timestampText.style?.fontWeight, FontWeight.w400);
|
||||
|
||||
final avatars = tester.widgetList<AvatarImage>(find.byType(AvatarImage));
|
||||
expect(avatars, isNotEmpty);
|
||||
expect(
|
||||
avatars.every((avatar) => avatar.radius == activityAvatarSize / 2),
|
||||
isTrue,
|
||||
);
|
||||
|
||||
final previews = tester.widgetList<MessageContent>(
|
||||
find.byType(MessageContent),
|
||||
);
|
||||
expect(previews, isNotEmpty);
|
||||
expect(
|
||||
previews.every(
|
||||
(preview) =>
|
||||
preview.baseStyle?.fontSize == activityPreviewTextStyle.fontSize &&
|
||||
preview.baseStyle?.fontWeight ==
|
||||
activityPreviewTextStyle.fontWeight &&
|
||||
preview.baseStyle?.height == activityPreviewTextStyle.height &&
|
||||
preview.baseStyle?.letterSpacing ==
|
||||
activityPreviewTextStyle.letterSpacing &&
|
||||
preview.baseStyle?.color == theme.colorScheme.onSurface,
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('multiple top-level messages in one DM render one row', (
|
||||
tester,
|
||||
) async {
|
||||
final dmChannel = Channel(
|
||||
id: 'dm1',
|
||||
name: 'dm',
|
||||
channelType: 'dm',
|
||||
visibility: 'private',
|
||||
description: '',
|
||||
createdBy: 'x',
|
||||
createdAt: DateTime(2025),
|
||||
memberCount: 2,
|
||||
isMember: true,
|
||||
participants: const ['Alice'],
|
||||
);
|
||||
FeedItem dmMessage(String id, int age) => FeedItem(
|
||||
id: id,
|
||||
kind: 9,
|
||||
pubkey: 'alice_pk',
|
||||
content: 'dm body $id',
|
||||
createdAt: now - age,
|
||||
channelId: 'dm1',
|
||||
channelName: '',
|
||||
tags: const [],
|
||||
category: 'activity',
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
await buildTestable(
|
||||
feed: HomeFeedResponse(
|
||||
mentions: const [],
|
||||
needsAction: const [],
|
||||
activity: [dmMessage('dm-a', 300), dmMessage('dm-b', 200)],
|
||||
agentActivity: const [],
|
||||
),
|
||||
channels: [...testChannels, dmChannel],
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// One conversation row for the DM, represented by the latest message.
|
||||
expect(find.byKey(const ValueKey('inbox-row-dm-b')), findsOneWidget);
|
||||
expect(find.byKey(const ValueKey('inbox-row-dm-a')), findsNothing);
|
||||
expect(find.textContaining('dm body dm-b'), findsOneWidget);
|
||||
expect(find.textContaining('dm body dm-a'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('unread rows show a dot; read rows do not', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
await buildTestable(
|
||||
// ch1 fully read; ch2 (thread) unread.
|
||||
readContexts: {'ch1': now},
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byKey(const ValueKey('inbox-unread-dot-m1')), findsNothing);
|
||||
expect(find.byKey(const ValueKey('inbox-unread-dot-ag1')), findsNothing);
|
||||
expect(find.byKey(const ValueKey('inbox-unread-dot-a1')), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('New boundary separates unread rows from read rows', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
await buildTestable(
|
||||
// Mention (2m ago) and agent (1m ago) unread; thread (1h ago) read.
|
||||
readContexts: {'thread:root1': now, 'ch2': now},
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('New'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('filter menu switches sources and shows empty states', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(await buildTestable());
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byKey(const ValueKey('activity-filter-menu')));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Mentions'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.textContaining('Hey check this out'), findsOneWidget);
|
||||
expect(find.textContaining('Deployed the fix'), findsNothing);
|
||||
expect(find.textContaining('Job completed successfully'), findsNothing);
|
||||
|
||||
await tester.tap(find.byKey(const ValueKey('activity-filter-menu')));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Needs Action'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Nothing needs your action'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('filter menu supports accessibility text scaling', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
await buildTestable(textScaler: const TextScaler.linear(3)),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byKey(const ValueKey('activity-filter-menu')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
find.byKey(const ValueKey('activity-filter-popover')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('opens a thread mention at the referenced message', (
|
||||
tester,
|
||||
) async {
|
||||
final threadMention = FeedItem(
|
||||
id: 'reply-event',
|
||||
kind: 9,
|
||||
pubkey: 'alice_pk',
|
||||
content: 'Reply in a thread',
|
||||
createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||
channelId: 'ch1',
|
||||
channelName: 'general',
|
||||
tags: const [
|
||||
['e', 'thread-root', '', 'root'],
|
||||
['e', 'parent-reply', '', 'reply'],
|
||||
],
|
||||
category: 'mention',
|
||||
);
|
||||
final feed = HomeFeedResponse(
|
||||
mentions: [threadMention],
|
||||
needsAction: const [],
|
||||
activity: const [],
|
||||
agentActivity: const [],
|
||||
);
|
||||
|
||||
await tester.pumpWidget(await buildTestable(feed: feed));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byKey(const ValueKey('inbox-row-reply-event')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final page = tester.widget<ChannelDetailPage>(
|
||||
find.byType(ChannelDetailPage),
|
||||
);
|
||||
expect(page.channel.id, 'ch1');
|
||||
expect(page.initialThreadRootId, 'parent-reply');
|
||||
expect(page.initialMessageId, 'reply-event');
|
||||
});
|
||||
|
||||
testWidgets('thread filter matches grouped thread replies', (tester) async {
|
||||
await tester.pumpWidget(await buildTestable());
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byKey(const ValueKey('activity-filter-menu')));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Threads'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.textContaining('Deployed the fix'), findsOneWidget);
|
||||
expect(find.textContaining('Hey check this out'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('reminders filter shows the reminders empty surface', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(await buildTestable());
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byKey(const ValueKey('activity-filter-menu')));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Reminders'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('No reminders'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('drafts filter shows the drafts empty surface', (tester) async {
|
||||
await tester.pumpWidget(await buildTestable());
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byKey(const ValueKey('activity-filter-menu')));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Drafts'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('No drafts'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('unread-only toggle hides read rows', (tester) async {
|
||||
await tester.pumpWidget(await buildTestable(readContexts: {'ch1': now}));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byKey(const ValueKey('activity-options-menu')));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Show unread'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Only the unread thread row remains.
|
||||
expect(find.textContaining('Deployed the fix'), findsOneWidget);
|
||||
expect(find.textContaining('Hey check this out'), findsNothing);
|
||||
expect(find.textContaining('Job completed successfully'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('long-press mark unread reopens a read row', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
await buildTestable(
|
||||
readContexts: {'ch1': now, 'ch2': now, 'thread:root1': now},
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byKey(const ValueKey('inbox-unread-dot-m1')), findsNothing);
|
||||
|
||||
await tester.longPress(find.byKey(const ValueKey('inbox-row-m1')));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(
|
||||
find.descendant(
|
||||
of: find.byType(BottomSheet),
|
||||
matching: find.text('Mark unread'),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byKey(const ValueKey('inbox-unread-dot-m1')), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('swiping an inbox row reveals and runs its row actions', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
await buildTestable(
|
||||
readContexts: {'ch1': now, 'ch2': now, 'thread:root1': now},
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final row = find.byKey(const ValueKey('inbox-row-m1'));
|
||||
final originalLeft = tester.getTopLeft(row).dx;
|
||||
await tester.drag(row, const Offset(-200, 0));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(tester.getTopLeft(row).dx, lessThan(originalLeft));
|
||||
expect(find.byKey(const ValueKey('inbox-swipe-read-m1')), findsOneWidget);
|
||||
expect(find.byKey(const ValueKey('inbox-swipe-open-m1')), findsNothing);
|
||||
expect(
|
||||
find.byKey(const ValueKey('inbox-swipe-background-m1')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
tester
|
||||
.getSize(find.byKey(const ValueKey('inbox-swipe-background-m1')))
|
||||
.height,
|
||||
tester.getSize(row).height,
|
||||
);
|
||||
final markUnreadAction = find.byKey(const ValueKey('inbox-swipe-read-m1'));
|
||||
final markUnreadMaterial = tester.widget<Material>(
|
||||
find.descendant(of: markUnreadAction, matching: find.byType(Material)),
|
||||
);
|
||||
expect(markUnreadMaterial.color, tester.element(row).colors.primary);
|
||||
expect(markUnreadMaterial.borderRadius, BorderRadius.circular(Radii.full));
|
||||
|
||||
await tester.tap(find.byKey(const ValueKey('inbox-swipe-read-m1')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byKey(const ValueKey('inbox-unread-dot-m1')), findsOneWidget);
|
||||
|
||||
await tester.drag(row, const Offset(-200, 0));
|
||||
await tester.pumpAndSettle();
|
||||
final markReadAction = find.byKey(const ValueKey('inbox-swipe-read-m1'));
|
||||
final markReadMaterial = tester.widget<Material>(
|
||||
find.descendant(of: markReadAction, matching: find.byType(Material)),
|
||||
);
|
||||
expect(markReadMaterial.color, tester.element(row).appColors.success);
|
||||
expect(markReadMaterial.color, isNot(markUnreadMaterial.color));
|
||||
});
|
||||
|
||||
testWidgets('swipe action reveals its label with one threshold haptic', (
|
||||
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(
|
||||
await buildTestable(
|
||||
readContexts: {'ch1': now, 'ch2': now, 'thread:root1': now},
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final row = find.byKey(const ValueKey('inbox-row-m1'));
|
||||
final gesture = await tester.startGesture(tester.getCenter(row));
|
||||
for (var step = 1; step <= 7; step++) {
|
||||
await gesture.moveBy(
|
||||
const Offset(-10, 0),
|
||||
timeStamp: Duration(milliseconds: step * 50),
|
||||
);
|
||||
await tester.pump();
|
||||
}
|
||||
|
||||
final action = find.byKey(const ValueKey('inbox-swipe-read-m1'));
|
||||
expect(action, findsOneWidget);
|
||||
expect(
|
||||
find.descendant(of: action, matching: find.byIcon(LucideIcons.mail)),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.text('Mark unread'), findsNothing);
|
||||
expect(hapticCalls, isEmpty);
|
||||
|
||||
for (var step = 8; step <= 10; step++) {
|
||||
await gesture.moveBy(
|
||||
const Offset(-10, 0),
|
||||
timeStamp: Duration(milliseconds: step * 50),
|
||||
);
|
||||
await tester.pump();
|
||||
}
|
||||
expect(find.text('Mark unread'), findsOneWidget);
|
||||
expect(hapticCalls, hasLength(1));
|
||||
expect(hapticCalls.single.arguments, 'HapticFeedbackType.selectionClick');
|
||||
|
||||
await gesture.moveBy(
|
||||
const Offset(20, 0),
|
||||
timeStamp: const Duration(milliseconds: 550),
|
||||
);
|
||||
await tester.pump();
|
||||
await gesture.moveBy(
|
||||
const Offset(-20, 0),
|
||||
timeStamp: const Duration(milliseconds: 600),
|
||||
);
|
||||
await tester.pump();
|
||||
expect(hapticCalls, hasLength(1));
|
||||
|
||||
await gesture.up();
|
||||
await tester.pumpAndSettle();
|
||||
});
|
||||
|
||||
testWidgets('commits the inbox read-state action past the swipe threshold', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
await buildTestable(
|
||||
readContexts: {'ch1': now, 'ch2': now, 'thread:root1': now},
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final row = find.byKey(const ValueKey('inbox-row-m1'));
|
||||
await tester.drag(row, const Offset(-500, 0));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byKey(const ValueKey('inbox-unread-dot-m1')), findsOneWidget);
|
||||
expect(
|
||||
find.byKey(const ValueKey('inbox-swipe-background-m1')),
|
||||
findsNothing,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('falls back to short pubkey when user not cached', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(await buildTestable(users: const {}));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Sender label falls back to the (short) pubkey.
|
||||
expect(find.text('alice_pk'), findsOneWidget);
|
||||
expect(find.text('Alice'), findsNothing);
|
||||
});
|
||||
}
|
||||
|
||||
class _FakeActivityNotifier extends ActivityNotifier {
|
||||
final HomeFeedResponse _feed;
|
||||
_FakeActivityNotifier(this._feed);
|
||||
|
||||
@override
|
||||
Future<HomeFeedResponse> build() async => _feed;
|
||||
}
|
||||
|
||||
class _PendingActivityNotifier extends ActivityNotifier {
|
||||
@override
|
||||
Future<HomeFeedResponse> build() => Completer<HomeFeedResponse>().future;
|
||||
}
|
||||
|
||||
class _ErrorActivityNotifier extends ActivityNotifier {
|
||||
@override
|
||||
Future<HomeFeedResponse> build() => Future.error('Connection refused');
|
||||
}
|
||||
|
||||
class _FakeChannelsNotifier extends ChannelsNotifier {
|
||||
final List<Channel> _channels;
|
||||
_FakeChannelsNotifier(this._channels);
|
||||
|
||||
@override
|
||||
Future<List<Channel>> build() async => _channels;
|
||||
}
|
||||
|
||||
class _FakeUserCacheNotifier extends UserCacheNotifier {
|
||||
final Map<String, UserProfile> _users;
|
||||
_FakeUserCacheNotifier(this._users);
|
||||
|
||||
@override
|
||||
Map<String, UserProfile> build() => _users;
|
||||
}
|
||||
|
||||
class _FakeReadStateNotifier extends ReadStateNotifier {
|
||||
final Map<String, int> _contexts;
|
||||
_FakeReadStateNotifier(this._contexts);
|
||||
|
||||
@override
|
||||
ReadStateState build() => ReadStateState(
|
||||
isReady: true,
|
||||
pubkey: 'me_pk',
|
||||
contexts: Map.unmodifiable(_contexts),
|
||||
version: 1,
|
||||
);
|
||||
|
||||
@override
|
||||
void markContextRead(
|
||||
String contextId,
|
||||
int unixTimestamp, {
|
||||
bool clearForcedMessages = false,
|
||||
}) {
|
||||
state = state.copyWithContext(contextId, unixTimestamp);
|
||||
}
|
||||
}
|
||||
|
||||
class _FakeRemindersNotifier extends RemindersNotifier {
|
||||
final List<Reminder> _reminders;
|
||||
_FakeRemindersNotifier(this._reminders);
|
||||
|
||||
@override
|
||||
Future<List<Reminder>> build() async => _reminders;
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:buzz/features/activity/activity_provider.dart';
|
||||
import 'package:buzz/features/channels/channel.dart';
|
||||
import 'package:buzz/features/channels/channels_provider.dart';
|
||||
import 'package:buzz/shared/relay/relay.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
/// Records subscriptions and DM history queries for Activity projection tests.
|
||||
class _RecordingSessionNotifier extends RelaySessionNotifier {
|
||||
final List<List<String>> dmQueries = [];
|
||||
final List<NostrEvent> _history = [];
|
||||
final List<({NostrFilter filter, void Function(NostrEvent) onEvent})>
|
||||
_subscriptions = [];
|
||||
Completer<void>? mentionFetchGate;
|
||||
bool failNextMentionFetch = false;
|
||||
int mentionFetchCount = 0;
|
||||
int activeMentionFetches = 0;
|
||||
int maxActiveMentionFetches = 0;
|
||||
|
||||
@override
|
||||
SessionState build() => const SessionState(status: SessionStatus.connected);
|
||||
|
||||
@override
|
||||
Future<List<NostrEvent>> fetchHistory(
|
||||
NostrFilter filter, {
|
||||
Duration timeout = const Duration(seconds: 8),
|
||||
}) async {
|
||||
final h = filter.tags['#h'];
|
||||
if (h != null) dmQueries.add(h);
|
||||
final isMentionFetch =
|
||||
filter.tags.containsKey('#p') && filter.kinds.contains(40002);
|
||||
if (isMentionFetch) {
|
||||
mentionFetchCount += 1;
|
||||
activeMentionFetches += 1;
|
||||
if (activeMentionFetches > maxActiveMentionFetches) {
|
||||
maxActiveMentionFetches = activeMentionFetches;
|
||||
}
|
||||
try {
|
||||
final gate = mentionFetchGate;
|
||||
if (gate != null) await gate.future;
|
||||
if (failNextMentionFetch) {
|
||||
failNextMentionFetch = false;
|
||||
throw StateError('transient mention history failure');
|
||||
}
|
||||
} finally {
|
||||
activeMentionFetches -= 1;
|
||||
}
|
||||
}
|
||||
return _history.where((event) => _matches(filter, event)).toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void Function()> subscribe(
|
||||
NostrFilter filter,
|
||||
void Function(NostrEvent) onEvent, {
|
||||
void Function(String message)? onClosed,
|
||||
}) async {
|
||||
final subscription = (filter: filter, onEvent: onEvent);
|
||||
_subscriptions.add(subscription);
|
||||
return () => _subscriptions.remove(subscription);
|
||||
}
|
||||
|
||||
void emit(NostrEvent event) {
|
||||
_history.add(event);
|
||||
for (final subscription in List.of(_subscriptions)) {
|
||||
if (_matches(subscription.filter, event)) {
|
||||
subscription.onEvent(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void seed(NostrEvent event) => _history.add(event);
|
||||
|
||||
bool _matches(NostrFilter filter, NostrEvent event) {
|
||||
if (!filter.kinds.contains(event.kind)) return false;
|
||||
for (final entry in filter.tags.entries) {
|
||||
final tagName = entry.key.startsWith('#')
|
||||
? entry.key.substring(1)
|
||||
: entry.key;
|
||||
final matchesTag = event.tags.any(
|
||||
(tag) =>
|
||||
tag.length > 1 && tag[0] == tagName && entry.value.contains(tag[1]),
|
||||
);
|
||||
if (!matchesTag) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// Channels provider that starts loading and resolves on demand, modelling a
|
||||
/// cold start where the channel list arrives after Activity's first fetch.
|
||||
class _LateChannelsNotifier extends ChannelsNotifier {
|
||||
final Completer<List<Channel>> _completer = Completer<List<Channel>>();
|
||||
|
||||
@override
|
||||
Future<List<Channel>> build() => _completer.future;
|
||||
|
||||
void resolve(List<Channel> channels) => _completer.complete(channels);
|
||||
}
|
||||
|
||||
class _FixedRelayConfigNotifier extends RelayConfigNotifier {
|
||||
@override
|
||||
RelayConfig build() =>
|
||||
const RelayConfig(baseUrl: 'https://relay.example', nsec: null);
|
||||
}
|
||||
|
||||
Channel _dmChannel(String id) => Channel(
|
||||
id: id,
|
||||
name: 'dm',
|
||||
channelType: 'dm',
|
||||
visibility: 'private',
|
||||
description: '',
|
||||
createdBy: 'x',
|
||||
createdAt: DateTime(2025),
|
||||
memberCount: 2,
|
||||
isMember: true,
|
||||
);
|
||||
|
||||
NostrEvent _mentionEvent(String id, int createdAt) => NostrEvent(
|
||||
id: id,
|
||||
pubkey: 'other_pk',
|
||||
createdAt: createdAt,
|
||||
kind: 40002,
|
||||
tags: const [
|
||||
['p', 'me_pk'],
|
||||
['h', 'channel-1'],
|
||||
],
|
||||
content: 'Hello from the live relay',
|
||||
sig: '',
|
||||
);
|
||||
|
||||
Future<void> _waitFor(bool Function() predicate) async {
|
||||
for (var attempt = 0; attempt < 100; attempt++) {
|
||||
if (predicate()) return;
|
||||
await Future<void>.delayed(const Duration(milliseconds: 10));
|
||||
}
|
||||
fail('Condition was not reached before timeout');
|
||||
}
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
test('refetches and includes DMs when channels resolve after first '
|
||||
'fetch (cold start)', () async {
|
||||
final session = _RecordingSessionNotifier();
|
||||
final channels = _LateChannelsNotifier();
|
||||
final container = ProviderContainer(
|
||||
overrides: [
|
||||
relayConfigProvider.overrideWith(_FixedRelayConfigNotifier.new),
|
||||
myPubkeyProvider.overrideWithValue('me_pk'),
|
||||
relaySessionProvider.overrideWith(() => session),
|
||||
channelsProvider.overrideWith(() => channels),
|
||||
],
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
|
||||
// Cold start: channels still loading, so the first fetch has no DM ids.
|
||||
await container.read(activityProvider.future);
|
||||
expect(session.dmQueries, isEmpty);
|
||||
|
||||
// Channel list resolves with a DM → Activity must rebuild and query it.
|
||||
channels.resolve([_dmChannel('dm1')]);
|
||||
await container.read(channelsProvider.future);
|
||||
await container.read(activityProvider.future);
|
||||
|
||||
expect(session.dmQueries, hasLength(1));
|
||||
expect(session.dmQueries.single, ['dm1']);
|
||||
});
|
||||
|
||||
test('does not query DMs when the resolved channel list has none', () async {
|
||||
final session = _RecordingSessionNotifier();
|
||||
final channels = _LateChannelsNotifier();
|
||||
final container = ProviderContainer(
|
||||
overrides: [
|
||||
relayConfigProvider.overrideWith(_FixedRelayConfigNotifier.new),
|
||||
myPubkeyProvider.overrideWithValue('me_pk'),
|
||||
relaySessionProvider.overrideWith(() => session),
|
||||
channelsProvider.overrideWith(() => channels),
|
||||
],
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
|
||||
await container.read(activityProvider.future);
|
||||
channels.resolve(const []);
|
||||
await container.read(channelsProvider.future);
|
||||
await container.read(activityProvider.future);
|
||||
|
||||
expect(session.dmQueries, isEmpty);
|
||||
});
|
||||
|
||||
test(
|
||||
'refreshes the inbox projection when addressed activity arrives',
|
||||
() async {
|
||||
final session = _RecordingSessionNotifier();
|
||||
final container = ProviderContainer(
|
||||
overrides: [
|
||||
relayConfigProvider.overrideWith(_FixedRelayConfigNotifier.new),
|
||||
myPubkeyProvider.overrideWithValue('me_pk'),
|
||||
relaySessionProvider.overrideWith(() => session),
|
||||
channelsProvider.overrideWith(
|
||||
() => _FixedChannelsNotifier(const <Channel>[]),
|
||||
),
|
||||
],
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
|
||||
await container.read(channelsProvider.future);
|
||||
await container.read(activityProvider.future);
|
||||
await Future<void>.delayed(const Duration(milliseconds: 10));
|
||||
expect(container.read(inboxItemsProvider), isEmpty);
|
||||
|
||||
session.emit(
|
||||
const NostrEvent(
|
||||
id: 'live-mention',
|
||||
pubkey: 'other_pk',
|
||||
createdAt: 1_700_000_000,
|
||||
kind: 40002,
|
||||
tags: [
|
||||
['p', 'me_pk'],
|
||||
['h', 'channel-1'],
|
||||
],
|
||||
content: 'Hello from the live relay',
|
||||
sig: '',
|
||||
),
|
||||
);
|
||||
await Future<void>.delayed(const Duration(milliseconds: 100));
|
||||
|
||||
expect(container.read(inboxItemsProvider).single.id, 'live-mention');
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'serializes live refreshes and catches up events queued mid-fetch',
|
||||
() async {
|
||||
final session = _RecordingSessionNotifier();
|
||||
final container = ProviderContainer(
|
||||
overrides: [
|
||||
relayConfigProvider.overrideWith(_FixedRelayConfigNotifier.new),
|
||||
myPubkeyProvider.overrideWithValue('me_pk'),
|
||||
relaySessionProvider.overrideWith(() => session),
|
||||
channelsProvider.overrideWith(
|
||||
() => _FixedChannelsNotifier(const <Channel>[]),
|
||||
),
|
||||
],
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
|
||||
await container.read(channelsProvider.future);
|
||||
await container.read(activityProvider.future);
|
||||
await Future<void>.delayed(const Duration(milliseconds: 10));
|
||||
|
||||
session.mentionFetchGate = Completer<void>();
|
||||
session.emit(_mentionEvent('live-one', 1_700_000_001));
|
||||
await _waitFor(() => session.activeMentionFetches == 1);
|
||||
|
||||
session.emit(_mentionEvent('live-two', 1_700_000_002));
|
||||
await Future<void>.delayed(const Duration(milliseconds: 100));
|
||||
expect(session.maxActiveMentionFetches, 1);
|
||||
|
||||
session.mentionFetchGate!.complete();
|
||||
await _waitFor(() => session.mentionFetchCount >= 3);
|
||||
await _waitFor(() => container.read(inboxItemsProvider).length == 2);
|
||||
|
||||
expect(session.maxActiveMentionFetches, 1);
|
||||
expect(
|
||||
container.read(inboxItemsProvider).map((item) => item.id),
|
||||
containsAll(['live-one', 'live-two']),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test('serializes manual and live inbox refreshes', () async {
|
||||
final session = _RecordingSessionNotifier();
|
||||
final container = ProviderContainer(
|
||||
overrides: [
|
||||
relayConfigProvider.overrideWith(_FixedRelayConfigNotifier.new),
|
||||
myPubkeyProvider.overrideWithValue('me_pk'),
|
||||
relaySessionProvider.overrideWith(() => session),
|
||||
channelsProvider.overrideWith(
|
||||
() => _FixedChannelsNotifier(const <Channel>[]),
|
||||
),
|
||||
],
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
|
||||
await container.read(channelsProvider.future);
|
||||
await container.read(activityProvider.future);
|
||||
await Future<void>.delayed(const Duration(milliseconds: 10));
|
||||
|
||||
session.mentionFetchGate = Completer<void>();
|
||||
final manualRefresh = container.read(activityProvider.notifier).refresh();
|
||||
await _waitFor(() => session.activeMentionFetches == 1);
|
||||
|
||||
session.emit(_mentionEvent('live-during-manual', 1_700_000_003));
|
||||
await Future<void>.delayed(const Duration(milliseconds: 100));
|
||||
expect(session.maxActiveMentionFetches, 1);
|
||||
|
||||
session.mentionFetchGate!.complete();
|
||||
await manualRefresh;
|
||||
await _waitFor(() => session.mentionFetchCount >= 3);
|
||||
await _waitFor(
|
||||
() =>
|
||||
container.read(inboxItemsProvider).single.id == 'live-during-manual',
|
||||
);
|
||||
|
||||
expect(session.maxActiveMentionFetches, 1);
|
||||
});
|
||||
|
||||
test('retains the loaded inbox when a live refresh fails', () async {
|
||||
final session = _RecordingSessionNotifier()
|
||||
..seed(_mentionEvent('existing', 1_700_000_001));
|
||||
final container = ProviderContainer(
|
||||
overrides: [
|
||||
relayConfigProvider.overrideWith(_FixedRelayConfigNotifier.new),
|
||||
myPubkeyProvider.overrideWithValue('me_pk'),
|
||||
relaySessionProvider.overrideWith(() => session),
|
||||
channelsProvider.overrideWith(
|
||||
() => _FixedChannelsNotifier(const <Channel>[]),
|
||||
),
|
||||
],
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
|
||||
await container.read(channelsProvider.future);
|
||||
await container.read(activityProvider.future);
|
||||
await Future<void>.delayed(const Duration(milliseconds: 10));
|
||||
expect(container.read(inboxItemsProvider).single.id, 'existing');
|
||||
|
||||
session.failNextMentionFetch = true;
|
||||
session.emit(_mentionEvent('newer', 1_700_000_002));
|
||||
await _waitFor(() => session.mentionFetchCount >= 2);
|
||||
await Future<void>.delayed(const Duration(milliseconds: 10));
|
||||
|
||||
expect(container.read(inboxItemsProvider).single.id, 'existing');
|
||||
});
|
||||
}
|
||||
|
||||
class _FixedChannelsNotifier extends ChannelsNotifier {
|
||||
final List<Channel> channels;
|
||||
|
||||
_FixedChannelsNotifier(this.channels);
|
||||
|
||||
@override
|
||||
Future<List<Channel>> build() async => channels;
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
import 'package:buzz/features/activity/compose_drafts_provider.dart';
|
||||
import 'package:buzz/shared/relay/relay.dart';
|
||||
import 'package:buzz/shared/theme/theme_provider.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
class _FixedRelayConfigNotifier extends RelayConfigNotifier {
|
||||
final RelayConfig _config;
|
||||
_FixedRelayConfigNotifier(this._config);
|
||||
|
||||
@override
|
||||
RelayConfig build() => _config;
|
||||
}
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
Future<ProviderContainer> containerWithPrefs({
|
||||
String relayUrl = 'https://relay-a.example',
|
||||
String? pubkey = 'pk_a',
|
||||
}) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final container = ProviderContainer(
|
||||
overrides: [
|
||||
savedPrefsProvider.overrideWithValue(prefs),
|
||||
relayConfigProvider.overrideWith(
|
||||
() => _FixedRelayConfigNotifier(RelayConfig(baseUrl: relayUrl)),
|
||||
),
|
||||
myPubkeyProvider.overrideWithValue(pubkey),
|
||||
],
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
return container;
|
||||
}
|
||||
|
||||
test(
|
||||
'an invite-joined community keeps its drafts after origin canonicalization',
|
||||
() async {
|
||||
// Written by a build that stored the invite link's wss:// origin
|
||||
// verbatim; RelayConfig now canonicalizes that to https://, so the key
|
||||
// the app computes no longer matches the key on disk.
|
||||
const legacyKey = 'compose_drafts_v1:wss://relay-a.example:pk_a';
|
||||
const canonicalKey = 'compose_drafts_v1:https://relay-a.example:pk_a';
|
||||
SharedPreferences.setMockInitialValues({
|
||||
legacyKey:
|
||||
'[{"key":"ch1","channel_id":"ch1","text":"unsent work",'
|
||||
'"updated_at":1700000000}]',
|
||||
});
|
||||
|
||||
final container = await containerWithPrefs(
|
||||
relayUrl: 'wss://relay-a.example',
|
||||
);
|
||||
|
||||
final drafts = container.read(composeDraftsProvider);
|
||||
expect(drafts, hasLength(1), reason: 'draft survives the upgrade');
|
||||
expect(drafts.single.text, 'unsent work');
|
||||
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
expect(prefs.getString(canonicalKey), isNotNull);
|
||||
expect(prefs.getString(legacyKey), isNull);
|
||||
},
|
||||
);
|
||||
|
||||
test('composeDraftKey separates channel and thread composers', () {
|
||||
expect(composeDraftKey('ch1'), 'ch1');
|
||||
expect(composeDraftKey('ch1', threadHeadId: 't1'), 'ch1:t1');
|
||||
});
|
||||
|
||||
test('save adds a draft and textFor returns it', () async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
final container = await containerWithPrefs();
|
||||
final notifier = container.read(composeDraftsProvider.notifier);
|
||||
|
||||
notifier.save(key: 'ch1', channelId: 'ch1', text: 'hello there');
|
||||
|
||||
final drafts = container.read(composeDraftsProvider);
|
||||
expect(drafts, hasLength(1));
|
||||
expect(drafts.single.text, 'hello there');
|
||||
expect(notifier.textFor('ch1'), 'hello there');
|
||||
});
|
||||
|
||||
test('empty text removes the draft', () async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
final container = await containerWithPrefs();
|
||||
final notifier = container.read(composeDraftsProvider.notifier);
|
||||
|
||||
notifier.save(key: 'ch1', channelId: 'ch1', text: 'hello');
|
||||
notifier.save(key: 'ch1', channelId: 'ch1', text: ' ');
|
||||
|
||||
expect(container.read(composeDraftsProvider), isEmpty);
|
||||
});
|
||||
|
||||
test('drafts persist across container restarts', () async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
final first = await containerWithPrefs();
|
||||
first
|
||||
.read(composeDraftsProvider.notifier)
|
||||
.save(key: 'ch1:t1', channelId: 'ch1', threadHeadId: 't1', text: 'wip');
|
||||
|
||||
final second = await containerWithPrefs();
|
||||
final restored = second.read(composeDraftsProvider);
|
||||
expect(restored, hasLength(1));
|
||||
expect(restored.single.channelId, 'ch1');
|
||||
expect(restored.single.threadHeadId, 't1');
|
||||
expect(restored.single.text, 'wip');
|
||||
});
|
||||
|
||||
test('remove deletes by key', () async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
final container = await containerWithPrefs();
|
||||
final notifier = container.read(composeDraftsProvider.notifier);
|
||||
|
||||
notifier.save(key: 'ch1', channelId: 'ch1', text: 'hello');
|
||||
notifier.remove('ch1');
|
||||
|
||||
expect(container.read(composeDraftsProvider), isEmpty);
|
||||
});
|
||||
|
||||
test('malformed persisted json is ignored', () async {
|
||||
SharedPreferences.setMockInitialValues({
|
||||
'compose_drafts_v1:https://relay-a.example:pk_a': '{not valid',
|
||||
});
|
||||
final container = await containerWithPrefs();
|
||||
expect(container.read(composeDraftsProvider), isEmpty);
|
||||
});
|
||||
|
||||
test('drafts are isolated per community', () async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
final communityA = await containerWithPrefs(
|
||||
relayUrl: 'https://relay-a.example',
|
||||
);
|
||||
communityA
|
||||
.read(composeDraftsProvider.notifier)
|
||||
.save(key: 'ch1', channelId: 'ch1', text: 'secret from A');
|
||||
|
||||
// Same channel id in another community must not see A's draft.
|
||||
final communityB = await containerWithPrefs(
|
||||
relayUrl: 'https://relay-b.example',
|
||||
);
|
||||
expect(communityB.read(composeDraftsProvider), isEmpty);
|
||||
expect(
|
||||
communityB.read(composeDraftsProvider.notifier).textFor('ch1'),
|
||||
isNull,
|
||||
);
|
||||
|
||||
// A's own store is untouched.
|
||||
final communityAAgain = await containerWithPrefs(
|
||||
relayUrl: 'https://relay-a.example',
|
||||
);
|
||||
expect(
|
||||
communityAAgain.read(composeDraftsProvider.notifier).textFor('ch1'),
|
||||
'secret from A',
|
||||
);
|
||||
});
|
||||
|
||||
test('drafts are isolated per account pubkey', () async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
final accountA = await containerWithPrefs(pubkey: 'pk_a');
|
||||
accountA
|
||||
.read(composeDraftsProvider.notifier)
|
||||
.save(key: 'ch1', channelId: 'ch1', text: 'account A draft');
|
||||
|
||||
// Same relay, different account: a colliding channel id must not
|
||||
// restore the other account's draft.
|
||||
final accountB = await containerWithPrefs(pubkey: 'pk_b');
|
||||
expect(accountB.read(composeDraftsProvider), isEmpty);
|
||||
expect(
|
||||
accountB.read(composeDraftsProvider.notifier).textFor('ch1'),
|
||||
isNull,
|
||||
);
|
||||
});
|
||||
|
||||
test('in-place identity change rebuilds onto the new store', () async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final container = ProviderContainer(
|
||||
overrides: [
|
||||
savedPrefsProvider.overrideWithValue(prefs),
|
||||
// Real derivation path: myPubkeyProvider derives from the config's
|
||||
// nsec, so an in-place config change simulates an account/community
|
||||
// switch without restarting the container.
|
||||
relayConfigProvider.overrideWith(
|
||||
() => _FixedRelayConfigNotifier(
|
||||
const RelayConfig(baseUrl: 'https://relay-a.example'),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
|
||||
// Seed a draft for the initial identity (nsec-less config → 'anon').
|
||||
container
|
||||
.read(composeDraftsProvider.notifier)
|
||||
.save(key: 'ch1', channelId: 'ch1', text: 'first identity draft');
|
||||
expect(container.read(composeDraftsProvider), hasLength(1));
|
||||
|
||||
// Switch community in place: the drafts provider must rebuild against
|
||||
// the new identity's (empty) store, not keep the old state.
|
||||
container
|
||||
.read(relayConfigProvider.notifier)
|
||||
.update(baseUrl: 'https://relay-b.example');
|
||||
expect(container.read(composeDraftsProvider), isEmpty);
|
||||
expect(
|
||||
container.read(composeDraftsProvider.notifier).textFor('ch1'),
|
||||
isNull,
|
||||
);
|
||||
|
||||
// Switching back restores the original identity's draft.
|
||||
container
|
||||
.read(relayConfigProvider.notifier)
|
||||
.update(baseUrl: 'https://relay-a.example');
|
||||
expect(
|
||||
container.read(composeDraftsProvider.notifier).textFor('ch1'),
|
||||
'first identity draft',
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:buzz/features/activity/feed_item.dart';
|
||||
|
||||
void main() {
|
||||
group('FeedItem.fromJson', () {
|
||||
test('parses all fields', () {
|
||||
final item = FeedItem.fromJson({
|
||||
'id': 'evt1',
|
||||
'kind': 9,
|
||||
'pubkey': 'abc123',
|
||||
'content': 'Hello world',
|
||||
'created_at': 1700000000,
|
||||
'channel_id': 'ch1',
|
||||
'channel_name': 'general',
|
||||
'tags': [
|
||||
['p', 'abc123'],
|
||||
],
|
||||
'category': 'mention',
|
||||
});
|
||||
|
||||
expect(item.id, 'evt1');
|
||||
expect(item.kind, 9);
|
||||
expect(item.pubkey, 'abc123');
|
||||
expect(item.content, 'Hello world');
|
||||
expect(item.createdAt, 1700000000);
|
||||
expect(item.channelId, 'ch1');
|
||||
expect(item.channelName, 'general');
|
||||
expect(item.tags, [
|
||||
['p', 'abc123'],
|
||||
]);
|
||||
expect(item.category, 'mention');
|
||||
});
|
||||
|
||||
test('handles null channel_id', () {
|
||||
final item = FeedItem.fromJson({
|
||||
'id': 'evt2',
|
||||
'kind': 9,
|
||||
'pubkey': 'abc',
|
||||
'content': '',
|
||||
'created_at': 0,
|
||||
'channel_id': null,
|
||||
'channel_name': null,
|
||||
'tags': null,
|
||||
'category': 'activity',
|
||||
});
|
||||
|
||||
expect(item.channelId, isNull);
|
||||
expect(item.channelName, '');
|
||||
expect(item.tags, isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
group('FeedItem.threadHeadId', () {
|
||||
FeedItem makeItem(List<List<String>> tags) => FeedItem(
|
||||
id: 'reply',
|
||||
kind: 9,
|
||||
pubkey: 'pk',
|
||||
content: '',
|
||||
createdAt: 0,
|
||||
channelId: 'channel',
|
||||
channelName: '',
|
||||
tags: tags,
|
||||
category: 'mention',
|
||||
);
|
||||
|
||||
test('uses the reply marker for a direct thread reply', () {
|
||||
expect(
|
||||
makeItem(const [
|
||||
['e', 'root', '', 'reply'],
|
||||
]).threadHeadId,
|
||||
'root',
|
||||
);
|
||||
});
|
||||
|
||||
test('uses the direct parent marker for a nested thread reply', () {
|
||||
expect(
|
||||
makeItem(const [
|
||||
['e', 'root', '', 'root'],
|
||||
['e', 'parent', '', 'reply'],
|
||||
]).threadHeadId,
|
||||
'parent',
|
||||
);
|
||||
});
|
||||
|
||||
test('does not treat unrelated event references as threads', () {
|
||||
expect(
|
||||
makeItem(const [
|
||||
['e', 'linked-event'],
|
||||
]).threadHeadId,
|
||||
isNull,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('FeedItem.headline', () {
|
||||
FeedItem makeItem({required int kind, String category = 'activity'}) =>
|
||||
FeedItem(
|
||||
id: 'x',
|
||||
kind: kind,
|
||||
pubkey: 'pk',
|
||||
content: '',
|
||||
createdAt: 0,
|
||||
channelId: null,
|
||||
channelName: '',
|
||||
tags: const [],
|
||||
category: category,
|
||||
);
|
||||
|
||||
test('returns known kind labels', () {
|
||||
expect(makeItem(kind: 45001).headline, 'Forum post');
|
||||
expect(makeItem(kind: 45003).headline, 'Forum reply');
|
||||
expect(makeItem(kind: 46010).headline, 'Approval requested');
|
||||
expect(makeItem(kind: 43001).headline, 'Job requested');
|
||||
expect(makeItem(kind: 43002).headline, 'Job accepted');
|
||||
expect(makeItem(kind: 43003).headline, 'Progress update');
|
||||
expect(makeItem(kind: 43004).headline, 'Job result');
|
||||
expect(makeItem(kind: 43005).headline, 'Job cancelled');
|
||||
expect(makeItem(kind: 43006).headline, 'Job failed');
|
||||
});
|
||||
|
||||
test('falls back to category for unknown kinds', () {
|
||||
expect(makeItem(kind: 9, category: 'mention').headline, 'Mention');
|
||||
expect(
|
||||
makeItem(kind: 9, category: 'agent_activity').headline,
|
||||
'Agent update',
|
||||
);
|
||||
expect(
|
||||
makeItem(kind: 9, category: 'activity').headline,
|
||||
'Channel update',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('FeedItem.displayContent', () {
|
||||
FeedItem makeItem({required String content, int kind = 9}) => FeedItem(
|
||||
id: 'x',
|
||||
kind: kind,
|
||||
pubkey: 'pk',
|
||||
content: content,
|
||||
createdAt: 0,
|
||||
channelId: null,
|
||||
channelName: '',
|
||||
tags: const [],
|
||||
category: 'activity',
|
||||
);
|
||||
|
||||
test('returns trimmed content when non-empty', () {
|
||||
expect(makeItem(content: ' Hello ').displayContent, 'Hello');
|
||||
});
|
||||
|
||||
test('returns approval fallback for empty approval events', () {
|
||||
expect(
|
||||
makeItem(content: '', kind: 46010).displayContent,
|
||||
'A workflow is waiting for approval.',
|
||||
);
|
||||
});
|
||||
|
||||
test('returns generic fallback for other empty events', () {
|
||||
expect(makeItem(content: '').displayContent, 'No additional details.');
|
||||
expect(makeItem(content: ' ').displayContent, 'No additional details.');
|
||||
});
|
||||
});
|
||||
|
||||
group('HomeFeedResponse', () {
|
||||
test('parses from JSON with all four categories', () {
|
||||
final response = HomeFeedResponse.fromJson({
|
||||
'feed': {
|
||||
'mentions': [
|
||||
{
|
||||
'id': 'm1',
|
||||
'kind': 9,
|
||||
'pubkey': 'pk',
|
||||
'content': 'hi',
|
||||
'created_at': 100,
|
||||
'channel_id': 'c1',
|
||||
'channel_name': 'general',
|
||||
'tags': [],
|
||||
'category': 'mention',
|
||||
},
|
||||
],
|
||||
'needs_action': [],
|
||||
'activity': [
|
||||
{
|
||||
'id': 'a1',
|
||||
'kind': 9,
|
||||
'pubkey': 'pk',
|
||||
'content': 'update',
|
||||
'created_at': 200,
|
||||
'channel_id': 'c2',
|
||||
'channel_name': 'dev',
|
||||
'tags': [],
|
||||
'category': 'activity',
|
||||
},
|
||||
],
|
||||
'agent_activity': [],
|
||||
},
|
||||
'meta': {'since': 0, 'total': 2, 'generated_at': 300},
|
||||
});
|
||||
|
||||
expect(response.mentions.length, 1);
|
||||
expect(response.needsAction, isEmpty);
|
||||
expect(response.activity.length, 1);
|
||||
expect(response.agentActivity, isEmpty);
|
||||
});
|
||||
|
||||
test('handles null category lists gracefully', () {
|
||||
final response = HomeFeedResponse.fromJson({
|
||||
'feed': {
|
||||
'mentions': null,
|
||||
'needs_action': null,
|
||||
'activity': null,
|
||||
'agent_activity': null,
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.mentions, isEmpty);
|
||||
expect(response.isEmpty, isTrue);
|
||||
});
|
||||
|
||||
test('all merges and sorts newest-first', () {
|
||||
final response = HomeFeedResponse(
|
||||
mentions: [
|
||||
FeedItem(
|
||||
id: 'old',
|
||||
kind: 9,
|
||||
pubkey: 'pk',
|
||||
content: '',
|
||||
createdAt: 100,
|
||||
channelId: null,
|
||||
channelName: '',
|
||||
tags: const [],
|
||||
category: 'mention',
|
||||
),
|
||||
],
|
||||
needsAction: const [],
|
||||
activity: [
|
||||
FeedItem(
|
||||
id: 'new',
|
||||
kind: 9,
|
||||
pubkey: 'pk',
|
||||
content: '',
|
||||
createdAt: 300,
|
||||
channelId: null,
|
||||
channelName: '',
|
||||
tags: const [],
|
||||
category: 'activity',
|
||||
),
|
||||
],
|
||||
agentActivity: [
|
||||
FeedItem(
|
||||
id: 'mid',
|
||||
kind: 9,
|
||||
pubkey: 'pk',
|
||||
content: '',
|
||||
createdAt: 200,
|
||||
channelId: null,
|
||||
channelName: '',
|
||||
tags: const [],
|
||||
category: 'agent_activity',
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
final all = response.all;
|
||||
expect(all.length, 3);
|
||||
expect(all[0].id, 'new');
|
||||
expect(all[1].id, 'mid');
|
||||
expect(all[2].id, 'old');
|
||||
});
|
||||
|
||||
test('isEmpty returns true when all lists empty', () {
|
||||
final response = HomeFeedResponse(
|
||||
mentions: [],
|
||||
needsAction: [],
|
||||
activity: [],
|
||||
agentActivity: [],
|
||||
);
|
||||
expect(response.isEmpty, isTrue);
|
||||
});
|
||||
|
||||
test('isEmpty returns false when any list has items', () {
|
||||
final response = HomeFeedResponse(
|
||||
mentions: [
|
||||
FeedItem(
|
||||
id: 'x',
|
||||
kind: 9,
|
||||
pubkey: 'pk',
|
||||
content: '',
|
||||
createdAt: 0,
|
||||
channelId: null,
|
||||
channelName: '',
|
||||
tags: const [],
|
||||
category: 'mention',
|
||||
),
|
||||
],
|
||||
needsAction: const [],
|
||||
activity: const [],
|
||||
agentActivity: const [],
|
||||
);
|
||||
expect(response.isEmpty, isFalse);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
import 'package:buzz/features/activity/feed_item.dart';
|
||||
import 'package:buzz/features/activity/inbox_item.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
FeedItem item({
|
||||
required String id,
|
||||
int createdAt = 100,
|
||||
String category = 'mention',
|
||||
String pubkey = 'pk1',
|
||||
String? channelId = 'ch1',
|
||||
List<List<String>> tags = const [],
|
||||
int kind = 9,
|
||||
String content = 'hello',
|
||||
}) => FeedItem(
|
||||
id: id,
|
||||
kind: kind,
|
||||
pubkey: pubkey,
|
||||
content: content,
|
||||
createdAt: createdAt,
|
||||
channelId: channelId,
|
||||
channelName: '',
|
||||
tags: tags,
|
||||
category: category,
|
||||
);
|
||||
|
||||
List<List<String>> replyTags(String rootId, String parentId) => [
|
||||
['e', rootId, '', 'root'],
|
||||
['e', parentId, '', 'reply'],
|
||||
];
|
||||
|
||||
void main() {
|
||||
group('inboxConversationId', () {
|
||||
test('uses root id when present', () {
|
||||
expect(
|
||||
inboxConversationId(replyTags('root1', 'parent1'), 'ev1'),
|
||||
'root1',
|
||||
);
|
||||
});
|
||||
|
||||
test('falls back to parent id without a root tag', () {
|
||||
expect(
|
||||
inboxConversationId([
|
||||
['e', 'parent1', '', 'reply'],
|
||||
], 'ev1'),
|
||||
'parent1',
|
||||
);
|
||||
});
|
||||
|
||||
test('falls back to the event id for top-level messages', () {
|
||||
expect(inboxConversationId(const [], 'ev1'), 'ev1');
|
||||
});
|
||||
});
|
||||
|
||||
group('isThreadReply', () {
|
||||
test('true for reply-tagged events', () {
|
||||
expect(isThreadReply(replyTags('r', 'p')), isTrue);
|
||||
});
|
||||
|
||||
test('false for broadcast replies', () {
|
||||
expect(
|
||||
isThreadReply([
|
||||
...replyTags('r', 'p'),
|
||||
['broadcast', '1'],
|
||||
]),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
test('false for top-level messages', () {
|
||||
expect(isThreadReply(const []), isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('buildInboxItems', () {
|
||||
test('groups thread events into one conversation row', () {
|
||||
final rows = buildInboxItems([
|
||||
item(id: 'a', createdAt: 10, tags: replyTags('root1', 'root1')),
|
||||
item(id: 'b', createdAt: 20, tags: replyTags('root1', 'a')),
|
||||
item(id: 'c', createdAt: 15),
|
||||
]);
|
||||
|
||||
expect(rows, hasLength(2));
|
||||
final threadRow = rows.firstWhere((r) => r.conversationId == 'root1');
|
||||
expect(threadRow.id, 'b'); // latest event represents the row
|
||||
expect(threadRow.groupItems, hasLength(2));
|
||||
expect(threadRow.latestActivityAt, 20);
|
||||
expect(threadRow.threadRootId, 'root1');
|
||||
});
|
||||
|
||||
test('sorts rows by latest activity, newest first', () {
|
||||
final rows = buildInboxItems([
|
||||
item(id: 'old', createdAt: 10),
|
||||
item(id: 'new', createdAt: 99),
|
||||
]);
|
||||
expect(rows.map((r) => r.id).toList(), ['new', 'old']);
|
||||
});
|
||||
|
||||
test('groups ordinary top-level DM messages into one row per DM', () {
|
||||
bool isDm(String channelId) => channelId == 'dm1';
|
||||
final rows = buildInboxItems([
|
||||
// Three top-level messages (no thread tags) in the same DM.
|
||||
item(id: 'd1', createdAt: 10, channelId: 'dm1', category: 'activity'),
|
||||
item(id: 'd2', createdAt: 20, channelId: 'dm1', category: 'activity'),
|
||||
item(id: 'd3', createdAt: 30, channelId: 'dm1', category: 'activity'),
|
||||
// Ordinary top-level channel posts still group per event.
|
||||
item(id: 'c1', createdAt: 40, channelId: 'ch1'),
|
||||
item(id: 'c2', createdAt: 50, channelId: 'ch1'),
|
||||
], isDmChannel: isDm);
|
||||
|
||||
expect(rows, hasLength(3));
|
||||
final dmRow = rows.singleWhere((r) => r.conversationId == 'dm:dm1');
|
||||
expect(dmRow.groupItems, hasLength(3));
|
||||
expect(dmRow.id, 'd3'); // latest DM message represents the row
|
||||
expect(dmRow.latestActivityAt, 30);
|
||||
});
|
||||
|
||||
test('thread replies inside a DM still group by thread root', () {
|
||||
bool isDm(String channelId) => channelId == 'dm1';
|
||||
final rows = buildInboxItems([
|
||||
item(id: 'top', createdAt: 10, channelId: 'dm1'),
|
||||
item(
|
||||
id: 'reply',
|
||||
createdAt: 20,
|
||||
channelId: 'dm1',
|
||||
tags: replyTags('top', 'top'),
|
||||
),
|
||||
], isDmChannel: isDm);
|
||||
|
||||
expect(rows, hasLength(2));
|
||||
expect(rows.map((r) => r.conversationId).toSet(), {'dm:dm1', 'top'});
|
||||
});
|
||||
|
||||
test('categories are priority sorted and set isActionRequired', () {
|
||||
final rows = buildInboxItems([
|
||||
item(
|
||||
id: 'a',
|
||||
createdAt: 10,
|
||||
category: 'activity',
|
||||
tags: replyTags('root1', 'root1'),
|
||||
),
|
||||
item(
|
||||
id: 'b',
|
||||
createdAt: 20,
|
||||
category: 'needs_action',
|
||||
tags: replyTags('root1', 'a'),
|
||||
),
|
||||
]);
|
||||
expect(rows.single.categories.first, 'needs_action');
|
||||
expect(rows.single.isActionRequired, isTrue);
|
||||
});
|
||||
});
|
||||
|
||||
group('InboxItem.deepLinkTarget', () {
|
||||
final rows = buildInboxItems([
|
||||
item(id: 'a', createdAt: 10, tags: replyTags('root1', 'root1')),
|
||||
item(id: 'b', createdAt: 20, tags: replyTags('root1', 'a')),
|
||||
item(id: 'c', createdAt: 30, tags: replyTags('root1', 'b')),
|
||||
]);
|
||||
|
||||
test('targets the oldest unread event', () {
|
||||
expect(rows.single.deepLinkTarget(15).id, 'b');
|
||||
});
|
||||
|
||||
test('targets the oldest event when nothing is read', () {
|
||||
expect(rows.single.deepLinkTarget(5).id, 'a');
|
||||
});
|
||||
|
||||
test('falls back to the latest event when all are read', () {
|
||||
expect(rows.single.deepLinkTarget(99).id, 'c');
|
||||
});
|
||||
|
||||
test('falls back to the latest event without a read marker', () {
|
||||
expect(rows.single.deepLinkTarget(null).id, 'c');
|
||||
});
|
||||
});
|
||||
|
||||
group('matchesInboxFilter', () {
|
||||
InboxItem rowOf(FeedItem feedItem) => buildInboxItems([feedItem]).single;
|
||||
|
||||
test('all matches everything', () {
|
||||
expect(matchesInboxFilter(rowOf(item(id: 'a')), InboxFilter.all), isTrue);
|
||||
});
|
||||
|
||||
test('thread filter needs a thread reply in the group', () {
|
||||
expect(
|
||||
matchesInboxFilter(rowOf(item(id: 'a')), InboxFilter.thread),
|
||||
isFalse,
|
||||
);
|
||||
expect(
|
||||
matchesInboxFilter(
|
||||
rowOf(item(id: 'a', tags: replyTags('r', 'p'))),
|
||||
InboxFilter.thread,
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('category filters match their category', () {
|
||||
final mention = rowOf(item(id: 'a', category: 'mention'));
|
||||
expect(matchesInboxFilter(mention, InboxFilter.mention), isTrue);
|
||||
expect(matchesInboxFilter(mention, InboxFilter.needsAction), isFalse);
|
||||
expect(matchesInboxFilter(mention, InboxFilter.activity), isFalse);
|
||||
expect(
|
||||
matchesInboxFilter(
|
||||
rowOf(item(id: 'b', category: 'agent_activity')),
|
||||
InboxFilter.agentActivity,
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('reminders and drafts are separate surfaces', () {
|
||||
final row = rowOf(item(id: 'a'));
|
||||
expect(matchesInboxFilter(row, InboxFilter.reminders), isFalse);
|
||||
expect(matchesInboxFilter(row, InboxFilter.drafts), isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('inboxTypeLabel', () {
|
||||
InboxItem rowOf(FeedItem feedItem) => buildInboxItems([feedItem]).single;
|
||||
|
||||
test('DM rows lead with the sender', () {
|
||||
final label = inboxTypeLabel(
|
||||
rowOf(item(id: 'a', category: 'activity')),
|
||||
channelName: null,
|
||||
isDm: true,
|
||||
senderLabel: 'Alice',
|
||||
);
|
||||
expect(label.text, 'DM from Alice');
|
||||
expect(label.channelLabel, isNull);
|
||||
});
|
||||
|
||||
test('mention rows say Mentioned in #channel', () {
|
||||
final label = inboxTypeLabel(
|
||||
rowOf(item(id: 'a', category: 'mention')),
|
||||
channelName: 'general',
|
||||
isDm: false,
|
||||
senderLabel: 'Alice',
|
||||
);
|
||||
expect(label.text, 'Mentioned in');
|
||||
expect(label.channelLabel, 'general');
|
||||
});
|
||||
|
||||
test('needs action wins over thread context', () {
|
||||
final label = inboxTypeLabel(
|
||||
rowOf(
|
||||
item(id: 'a', category: 'needs_action', tags: replyTags('r', 'p')),
|
||||
),
|
||||
channelName: 'general',
|
||||
isDm: false,
|
||||
senderLabel: 'Alice',
|
||||
);
|
||||
expect(label.text, 'Needs action in');
|
||||
});
|
||||
|
||||
test('plain thread replies say Thread in', () {
|
||||
final label = inboxTypeLabel(
|
||||
rowOf(item(id: 'a', category: 'activity', tags: replyTags('r', 'p'))),
|
||||
channelName: 'general',
|
||||
isDm: false,
|
||||
senderLabel: 'Alice',
|
||||
);
|
||||
expect(label.text, 'Thread in');
|
||||
});
|
||||
});
|
||||
|
||||
group('inboxDayLabel', () {
|
||||
final now = DateTime(2026, 7, 25, 12);
|
||||
int at(DateTime d) => d.millisecondsSinceEpoch ~/ 1000;
|
||||
|
||||
test('today / yesterday / weekday / date buckets', () {
|
||||
expect(inboxDayLabel(at(DateTime(2026, 7, 25, 9)), now: now), 'Today');
|
||||
expect(
|
||||
inboxDayLabel(at(DateTime(2026, 7, 24, 9)), now: now),
|
||||
'Yesterday',
|
||||
);
|
||||
expect(inboxDayLabel(at(DateTime(2026, 7, 21, 9)), now: now), 'Tuesday');
|
||||
expect(inboxDayLabel(at(DateTime(2026, 1, 2)), now: now), 'Jan 2');
|
||||
expect(
|
||||
inboxDayLabel(at(DateTime(2025, 12, 31)), now: now),
|
||||
'Dec 31, 2025',
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import 'package:buzz/features/activity/feed_item.dart';
|
||||
import 'package:buzz/features/activity/inbox_item.dart';
|
||||
import 'package:buzz/features/activity/inbox_read_state.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
FeedItem item({
|
||||
required String id,
|
||||
int createdAt = 100,
|
||||
String category = 'mention',
|
||||
String? channelId = 'ch1',
|
||||
List<List<String>> tags = const [],
|
||||
}) => FeedItem(
|
||||
id: id,
|
||||
kind: 9,
|
||||
pubkey: 'pk1',
|
||||
content: 'hello',
|
||||
createdAt: createdAt,
|
||||
channelId: channelId,
|
||||
channelName: '',
|
||||
tags: tags,
|
||||
category: category,
|
||||
);
|
||||
|
||||
List<List<String>> replyTags(String rootId, String parentId) => [
|
||||
['e', rootId, '', 'root'],
|
||||
['e', parentId, '', 'reply'],
|
||||
];
|
||||
|
||||
int? Function(String) markers(Map<String, int> map) =>
|
||||
(contextId) => map[contextId];
|
||||
|
||||
void main() {
|
||||
group('resolveInboxItemReadAt', () {
|
||||
test('channel rows use the channel marker', () {
|
||||
final row = buildInboxItems([item(id: 'a')]).single;
|
||||
expect(resolveInboxItemReadAt(row, markerOf: markers({'ch1': 42})), 42);
|
||||
});
|
||||
|
||||
test('thread rows use max of thread and msg markers', () {
|
||||
final row = buildInboxItems([
|
||||
item(id: 'a', tags: replyTags('root1', 'root1')),
|
||||
]).single;
|
||||
expect(
|
||||
resolveInboxItemReadAt(
|
||||
row,
|
||||
markerOf: markers({'thread:root1': 10, 'msg:a': 25}),
|
||||
),
|
||||
25,
|
||||
);
|
||||
});
|
||||
|
||||
test('channel-less rows have no marker', () {
|
||||
final row = buildInboxItems([item(id: 'a', channelId: null)]).single;
|
||||
expect(resolveInboxItemReadAt(row, markerOf: markers({})), isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('isInboxItemDone', () {
|
||||
test('done when no grouped activity is newer than the marker', () {
|
||||
final row = buildInboxItems([item(id: 'a', createdAt: 50)]).single;
|
||||
expect(
|
||||
isInboxItemDone(
|
||||
row,
|
||||
markerOf: markers({'ch1': 50}),
|
||||
localUnreadOverrides: const {},
|
||||
localDoneSet: const {},
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
isInboxItemDone(
|
||||
row,
|
||||
markerOf: markers({'ch1': 49}),
|
||||
localUnreadOverrides: const {},
|
||||
localDoneSet: const {},
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
test('a local unread override always wins', () {
|
||||
final row = buildInboxItems([item(id: 'a', createdAt: 50)]).single;
|
||||
expect(
|
||||
isInboxItemDone(
|
||||
row,
|
||||
markerOf: markers({'ch1': 99}),
|
||||
localUnreadOverrides: const {'a'},
|
||||
localDoneSet: const {},
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
test('channel-less rows fall back to the local done set', () {
|
||||
final row = buildInboxItems([item(id: 'a', channelId: null)]).single;
|
||||
expect(
|
||||
isInboxItemDone(
|
||||
row,
|
||||
markerOf: markers({}),
|
||||
localUnreadOverrides: const {},
|
||||
localDoneSet: const {},
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
expect(
|
||||
isInboxItemDone(
|
||||
row,
|
||||
markerOf: markers({}),
|
||||
localUnreadOverrides: const {},
|
||||
localDoneSet: const {'a'},
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('groupedChannelReadTimestamp', () {
|
||||
test('only counts top-level events in the channel', () {
|
||||
final row = buildInboxItems([
|
||||
item(id: 'a', createdAt: 10, tags: replyTags('root1', 'root1')),
|
||||
item(id: 'b', createdAt: 30, tags: replyTags('root1', 'a')),
|
||||
]).single;
|
||||
// Every grouped event is a thread reply, so marking the thread read
|
||||
// must not advance the channel marker.
|
||||
expect(groupedChannelReadTimestamp(row), isNull);
|
||||
});
|
||||
|
||||
test('returns the newest top-level timestamp', () {
|
||||
final row = buildInboxItems([
|
||||
item(id: 'root1', createdAt: 10),
|
||||
item(id: 'b', createdAt: 30, tags: replyTags('root1', 'root1')),
|
||||
]).single;
|
||||
final result = groupedChannelReadTimestamp(row);
|
||||
expect(result?.channelId, 'ch1');
|
||||
expect(result?.timestamp, 10);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:buzz/features/activity/reminders_provider.dart';
|
||||
import 'package:buzz/shared/relay/relay.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
NostrEvent reminderEvent({
|
||||
List<List<String>> tags = const [],
|
||||
String content = 'ciphertext',
|
||||
int createdAt = 100,
|
||||
}) => NostrEvent(
|
||||
id: 'ev1',
|
||||
pubkey: 'pk1',
|
||||
createdAt: createdAt,
|
||||
kind: kindEventReminder,
|
||||
tags: tags,
|
||||
content: content,
|
||||
sig: '',
|
||||
);
|
||||
|
||||
void main() {
|
||||
group('parseNotBefore', () {
|
||||
test('accepts plain unix seconds', () {
|
||||
expect(parseNotBefore('1753400000'), 1753400000);
|
||||
expect(parseNotBefore('0'), 0);
|
||||
});
|
||||
|
||||
test('rejects malformed values', () {
|
||||
expect(parseNotBefore('01'), isNull);
|
||||
expect(parseNotBefore('-5'), isNull);
|
||||
expect(parseNotBefore('12.5'), isNull);
|
||||
expect(parseNotBefore('abc'), isNull);
|
||||
expect(parseNotBefore(''), isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('parseReminderContent', () {
|
||||
test('parses a well-formed reminder with target', () {
|
||||
final parsed = parseReminderContent(
|
||||
jsonEncode({
|
||||
'status': 'pending',
|
||||
'note': 'follow up',
|
||||
'target': {
|
||||
'eventId': 'ev1',
|
||||
'channelId': 'ch1',
|
||||
'preview': 'hello',
|
||||
'authorPubkey': 'pk1',
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(parsed, isNotNull);
|
||||
expect(parsed!.status, 'pending');
|
||||
expect(parsed.note, 'follow up');
|
||||
expect(parsed.target?.eventId, 'ev1');
|
||||
expect(parsed.target?.channelId, 'ch1');
|
||||
});
|
||||
|
||||
test('accepts a note-only reminder', () {
|
||||
final parsed = parseReminderContent(
|
||||
jsonEncode({'status': 'pending', 'note': 'water plants'}),
|
||||
);
|
||||
expect(parsed?.note, 'water plants');
|
||||
expect(parsed?.target, isNull);
|
||||
});
|
||||
|
||||
test('fails closed on off-shape content', () {
|
||||
expect(parseReminderContent('not json'), isNull);
|
||||
expect(parseReminderContent('[]'), isNull);
|
||||
expect(parseReminderContent(jsonEncode({'status': 'snoozed'})), isNull);
|
||||
// No target and no note is meaningless.
|
||||
expect(parseReminderContent(jsonEncode({'status': 'pending'})), isNull);
|
||||
// Target missing required fields.
|
||||
expect(
|
||||
parseReminderContent(
|
||||
jsonEncode({
|
||||
'status': 'pending',
|
||||
'target': {'eventId': 'ev1'},
|
||||
}),
|
||||
),
|
||||
isNull,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('decodeReminderEvent', () {
|
||||
final plaintext = jsonEncode({'status': 'pending', 'note': 'hi'});
|
||||
|
||||
test('decodes d-tag, not_before, and decrypted content', () {
|
||||
final reminder = decodeReminderEvent(
|
||||
reminderEvent(
|
||||
tags: [
|
||||
['d', 'rem1'],
|
||||
['not_before', '1753400000'],
|
||||
],
|
||||
),
|
||||
decrypt: (_) => plaintext,
|
||||
);
|
||||
expect(reminder, isNotNull);
|
||||
expect(reminder!.id, 'rem1');
|
||||
expect(reminder.notBefore, 1753400000);
|
||||
expect(reminder.status, 'pending');
|
||||
expect(reminder.isDue(1753400001), isTrue);
|
||||
expect(reminder.isDue(1753399999), isFalse);
|
||||
});
|
||||
|
||||
test('requires a d tag', () {
|
||||
expect(
|
||||
decodeReminderEvent(reminderEvent(), decrypt: (_) => plaintext),
|
||||
isNull,
|
||||
);
|
||||
});
|
||||
|
||||
test('fails closed when decryption throws', () {
|
||||
expect(
|
||||
decodeReminderEvent(
|
||||
reminderEvent(
|
||||
tags: [
|
||||
['d', 'rem1'],
|
||||
],
|
||||
),
|
||||
decrypt: (_) => throw StateError('bad ciphertext'),
|
||||
),
|
||||
isNull,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user