Bring synced Solo chats and projects to mobile (#8337)

## Summary
Bring desktop Solo chats and project organization to mobile, with synced
collapsible sections, shared unread highlights and tab badges, agent
selection, shared search, native iOS creation sheets, and inline typing
bubbles that transition into replies.

Resolve Solo placement for every chat entry route, retain encrypted
organization snapshots for offline reading, and let users resume or
discard unfinished chat and project setup without deleting existing
messages.

### Related issue
Related: #6565; expandable agent transcripts remain separate work.

### Testing
- Fresh Solo read state seeds each active chat from its captured initial
last-message timestamp before exposing row/section/tab unread state.
Baselines survive intervening channel updates and reset across
identity/community boundaries while surviving read-state readiness
changes. Regressions cover multiple chats, events before read-state
readiness and during/after seeding, and identity changes; removing
either the seed or the display gate fails them.
- Chat completion reconciles a retained Solo-save journal even when
placement is already confirmed. False/throwing cleanup regressions
ensure the outer intent remains until reconciliation and only one
placement is published. Typing terminal-close loops remain bounded; the
retry budget resets after an uninterrupted eight-second health interval
or transport reconnect.
- Typing retry budgets reset after sustained recovery; invalid Solo
updates retain cached rows with a retry warning that survives failed
refreshes. Project names use the editors’ 120-grapheme limit, including
emoji and combining characters. Regression coverage exercises repeated
recovery, invalid live/refresh heads, and character-limit boundaries.
- Rebased onto current main, preserving the newer Search controls,
Huddle timeline filtering, and message-media layout.
- Failed project journal writes reload storage before claiming recovery
and reopen the entered name for retry. Typing subscription rejections
are caught; terminal closures clear stale indicators and retry with a
bounded backoff, canceled on disposal. Regression tests cover both
false/throwing storage writes and initial/late subscription rejection.
- Explicit ordinary destinations skip optional Solo lookup waits.
Generic destinations perform one bounded lookup in the destination
(search no longer duplicates it); an unavailable classification falls
back to ordinary sending per the product owner's explicit choice.
Positively identified Solo destinations use Solo providers.
- Terminal Solo subscription closure preserves cached rows and exposes
live-update Retry. Page coverage verifies warning visibility, usable
rows during recovery, and a subsequent terminal close. Removing the
warning or the immediate ordinary-route guard fails its regression.
- Generic channel entries share an ordinary default when Solo
classification is unavailable, per the requested product behavior. Known
Solo placement takes precedence; callers can explicitly require
unresolved placement to remain blocked. Bounded initial lookup preserves
known Solo deep-link routing.
- Dirty newest snapshots and offline-save warnings survive transport
reconnects; Retry persists that same event even if the relay returns an
older head. Community changes replace the service, and agent-directory
reconnect refresh remains intact.
- Pending-save discard reloads failed removals. Repeated reconnect
testing confirms typing subscriptions are released on provider rebuild
and disposal; removing the existing cleanup fails the regression.
- Ordinary Home/create routes explicitly preserve private-channel
sending during Solo failures; identified Solo sends use the Solo sender
and explicit unresolved routes remain fail-closed. Solo unread replies
beyond the initial 200-event page are loaded through the oldest-unread
boundary.
- Chat/project/agent cleanup reloads failed removals, retains
same-process recovery, and protects replaced records. False/throw
regressions verify retries do not duplicate creation or membership.
Route, unread, and cleanup guard-removal mutations each fail their
regression.
- Ordinary channels, DMs, and forums remain sendable when optional Solo
sync fails; explicit unresolved placement stays blocked. Controlled
cache races retain the newest verified snapshot, keep usable chats
visible, and retry offline persistence without stale writes. Both
protection-removal regressions fail as expected.
- Confirmed journal cleanup checks false/throwing removal and
exact-record identity. Interrupted agent choices are visible
immediately, identify the saved agent, and cover Resume/Later/Discard
with accessible name/key-fallback controls.
- Notification deep links preserve the contiguous history cursor;
same-second messages infer the newest agent consistently. Failed
offline-cache writes surface Retry, with false/throwing storage failures
and offline restart covered.
- Composite history cursors cover 350 same-second events plus older
history; removing the ID cursor makes the regression fail. Agent changes
now journal before granting access, with restart/failure and
Resume/Later/Discard widget coverage. Signed malformed profiles remain
isolated.
- Native iOS avatar tests passed on the simulator: visible-row loading,
three-request concurrency, streamed byte limits, response validation,
downsampling/pixel limits, cancellation, and visible avatar updates.
- Solo and Home share observed-event/read-marker logic. Widget coverage
verifies unread rows, collapsed-section indicators, the tab badge before
first Solo visit, message/thread reads, forced unread, and filtering
archived/nonmember/unplaced chats.
- Chat intent is saved before ownership and organization preflight;
tests cover interrupted ownership lookup and a disconnect while the
native creation form is open.
- Mobile analysis, formatting, file-size checks, and full mobile tests
passed at `1d4c41354`.
- Project creation persists its encrypted name/UUID before connectivity
checks or relay reads. Restart tests cover Resume/Later/Discard, scope
isolation, stale-intent rejection, and lost acknowledgements. Removing
the pre-network save makes all four production-flow regressions fail.
- Regression coverage includes notification/Activity routing, inline
replies, unknown-placement send protection, restart recovery/discard,
and encrypted offline cache isolation/refresh.
- Earlier feature build exercised on a physical iPhone with existing
account/community data; review follow-ups have not been reinstalled on
the device. UIKit agent-row accessibility checks passed on an iOS
simulator.
- Earlier cross-platform `just ci` stopped on the existing `buzz-acp`
test `signals_cancel_startup_turn_and_pending_stdin_without_hanging`
(process exit-code assertion); no Rust production changes are included
in this PR.

### Remaining review limitation
Agent discovery is bounded to 10 pages / 1,000 profiles with automatic
retries disabled. Owner-scoped discovery remains open because the
current relay ignores `#auth` filters; supporting that query requires a
relay change.

### Snapshots
Sample data, shared Flutter/Android surface. Native iOS uses platform
sheets and controls.

| Solo: projects and chats | New chat | Choose an agent |
| --- | --- | --- |
| <img
src="https://raw.githubusercontent.com/block/buzz/d9b29aa079a3b9a88e8ed89b70df8d6af06fa41b/pr-8337--01-solo.png"
width="240" alt="Solo with collapsible projects and chats" /> | <img
src="https://raw.githubusercontent.com/block/buzz/d9b29aa079a3b9a88e8ed89b70df8d6af06fa41b/pr-8337--02-new-chat.png"
width="240" alt="New chat form" /> | <img
src="https://raw.githubusercontent.com/block/buzz/d9b29aa079a3b9a88e8ed89b70df8d6af06fa41b/pr-8337--03-agent-picker.png"
width="240" alt="Agent selection list" /> |

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Signed-off-by: Logan Johnson <loganj@squareup.com>
Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
Co-authored-by: Logan Johnson <loganj@squareup.com>
Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
This commit is contained in:
klopez4212
2026-10-15 17:07:23 -04:00
committed by GitHub
parent cdfc38ecbd
commit b1d60f98e2
88 changed files with 9248 additions and 1396 deletions

View File

@@ -0,0 +1,56 @@
import 'package:buzz/features/channels/agent_reply_handoff.dart';
import 'package:buzz/features/channels/agent_typing_bubble.dart';
import 'package:buzz/shared/theme/theme.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
for (final reduced in [false, true]) {
testWidgets('reply grows from bubble with reduced motion $reduced', (
tester,
) async {
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light(),
home: MediaQuery(
data: MediaQueryData(disableAnimations: reduced),
child: const Scaffold(
body: Align(
alignment: Alignment.topLeft,
child: AgentReplyHandoff(
animate: true,
child: SizedBox(
key: ValueKey('reply'),
width: 220,
height: 120,
child: Text('Agent reply'),
),
),
),
),
),
),
);
expect(
find.byType(AgentTypingBubble),
reduced ? findsNothing : findsOneWidget,
);
if (!reduced) {
final bubble = tester.getSize(find.byType(AnimatedSize));
expect(bubble.width, lessThan(220));
await tester.pump();
await tester.pump(const Duration(milliseconds: 70));
final during = tester.getSize(find.byType(AnimatedSize));
expect(during.width, greaterThan(bubble.width));
expect(during.width, lessThan(220));
}
await tester.pumpAndSettle();
expect(find.byType(AgentTypingBubble), findsNothing);
expect(find.text('Agent reply'), findsOneWidget);
expect(
tester.getSize(find.byKey(const ValueKey('reply'))),
const Size(220, 120),
);
});
}
}

View File

@@ -1,3 +1,10 @@
import 'package:buzz/features/channels/compose_bar.dart';
import 'package:buzz/features/channels/send_message_provider.dart';
import 'package:buzz/features/channels/agent_reply_handoff.dart';
import 'package:buzz/features/channels/me/me_messages_provider.dart';
import 'package:buzz/shared/me/me_assistants.dart';
import 'package:buzz/shared/me/me_sync.dart';
import 'package:buzz/shared/me/me_groups.dart';
import 'package:flutter/semantics.dart';
import 'package:buzz/features/channels/message_media_avatar.dart';
import 'dart:async';
@@ -229,6 +236,7 @@ Widget _buildTestable({
ProfileNotifier? profileNotifier,
String? signingPubkey,
bool darkTheme = false,
bool personalChat = false,
required List<NostrEvent> messages,
PresenceCacheNotifier? presenceCache,
List<TypingEntry> typing = const [],
@@ -278,6 +286,10 @@ Widget _buildTestable({
http.Client? mediaClient,
VideoPreviewFrameLoader? videoPreviewLoader,
Widget? home,
Stream<MeGroups>? organization,
SendMessage? sender,
SendMessage? soloSender,
ChannelRoutePlacement placement = ChannelRoutePlacement.ordinary,
}) {
final resolvedChannel = channel ?? _testChannel;
final navigatorKey = GlobalKey<NavigatorState>();
@@ -288,11 +300,42 @@ Widget _buildTestable({
return ProviderScope(
retry: providerRetry ?? (disableRetries ? (_, _) => null : null),
overrides: [
if (sender != null) sendMessageProvider.overrideWithValue(sender),
if (soloSender != null) ...[
meSendMessageProvider.overrideWithValue(soloSender),
meAssistantOwnershipProvider('agent').overrideWith((ref) async => true),
],
meGroupsProvider.overrideWith(
(ref) =>
organization ??
Stream.value(
MeGroups(channels: personalChat ? [resolvedChannel.id] : []),
),
),
if (personalChat) ...[
meSyncProvider.overrideWithValue(
MeSync(
RelaySessionNotifier(),
RelayConfig(
baseUrl: 'https://relay.test',
nsec: nostr.Nip19.encode(
prefix: nostr.Nip19Prefix.nsec,
data:
'0000000000000000000000000000000000000000000000000000000000000001',
),
),
),
),
meMessagesProvider(_channelId).overrideWith(() => fakeMessagesNotifier),
meAssistantsProvider.overrideWith((ref) async => []),
],
if (videoPreviewLoader != null)
videoPreviewFrameLoaderProvider.overrideWithValue(videoPreviewLoader),
channelMessagesProvider(
_channelId,
).overrideWith(() => fakeMessagesNotifier),
channelMessagesProvider(_channelId).overrideWith(
() => personalChat
? _FakeMessagesNotifier(messages)
: fakeMessagesNotifier,
),
channelTypingProvider(
_channelId,
).overrideWith(() => typingNotifier ?? _FakeTypingNotifier(typing)),
@@ -431,6 +474,8 @@ Widget _buildTestable({
home ??
ChannelDetailPage(
channel: resolvedChannel,
personalChat: personalChat,
placement: placement,
initialMessageId: initialMessageId,
initialThreadRootId: initialThreadRootId,
initialThreadRouteBehavior: initialThreadRouteBehavior,
@@ -4502,78 +4547,165 @@ void main() {
expect(find.byTooltip('Jump to latest message'), findsOneWidget);
});
testWidgets('loads history through the oldest unread boundary', (
tester,
) async {
final newestPage = [
for (var i = 50; i < 100; i++)
_textMsg(
id: 'msg$i',
pubkey: 'alice',
content: 'Message $i',
createdAt: 1000 + i,
),
];
final olderPage = [
for (var i = 0; i < 50; i++)
_textMsg(
id: 'msg$i',
pubkey: 'alice',
content: 'Message $i',
createdAt: 1000 + i,
),
];
final messagesNotifier = _FakeMessagesNotifier(
newestPage,
olderPages: [olderPage],
);
final channelsNotifier = _FakeChannelsNotifier(
[_testChannel],
observedUnread: {
_channelId: [
makeObservedUnreadEvent(
id: 'msg21',
createdAt: 1021,
rootId: null,
highPriority: false,
channelType: 'stream',
isThreadedReply: false,
for (final gesture in ['status bar', 'scroll']) {
testWidgets(
'Me history uses selected provider for $gesture',
(tester) async {
final notifier = _FakeMessagesNotifier(
[
for (var i = 50; i < 250; i++)
_textMsg(
id: 'msg$i',
pubkey: 'alice',
content: 'Message $i',
createdAt: 1000 + i,
),
],
olderPages: [
[
for (var i = 0; i < 50; i++)
_textMsg(
id: 'msg$i',
pubkey: 'alice',
content: 'Message $i',
createdAt: 1000 + i,
),
],
],
);
await tester.pumpWidget(
_buildTestable(
messages: const [],
messagesNotifier: notifier,
personalChat: true,
disableAnimations: true,
),
],
);
await tester.pumpAndSettle();
if (gesture == 'status bar') {
await tester.binding.defaultBinaryMessenger.handlePlatformMessage(
'flutter/status_bar',
const JSONMethodCodec().encodeMethodCall(
const MethodCall('handleScrollToTop'),
),
(_) {},
);
await tester.pumpAndSettle();
} else {
final list = tester.widget<ScrollablePositionedList>(
find.byType(ScrollablePositionedList),
);
list.itemScrollController!.jumpTo(index: 195);
await tester.pumpAndSettle();
await tester.drag(
find.byType(ScrollablePositionedList),
const Offset(0, 350),
);
await tester.pumpAndSettle();
}
expect(notifier.fetchOlderCalls, greaterThan(0));
if (gesture == 'status bar') {
expect(findRichText('Message 0'), findsOneWidget);
} else {
// Scroll pagination extends the visible provider, even if the user
// has not scrolled through every newly loaded row yet.
expect(
ProviderScope.containerOf(
tester.element(find.byType(ChannelDetailPage)),
).read(meMessagesProvider(_channelId)).requireValue.first.id,
'msg0',
);
}
},
variant: TargetPlatformVariant.only(TargetPlatform.iOS),
);
}
for (final personal in [false, true]) {
testWidgets(
'loads history through the oldest unread boundary personal=$personal',
(tester) async {
final newestPage = [
for (var i = 50; i < 250; i++)
_textMsg(
id: 'msg$i',
pubkey: 'alice',
content: 'Message $i',
createdAt: 1000 + i,
extraTags: personal
? [
['e', threadIdThreadDashRoot, '', 'reply'],
]
: const [],
),
];
final olderPage = [
for (var i = 0; i < 50; i++)
_textMsg(
id: 'msg$i',
pubkey: 'alice',
content: 'Message $i',
createdAt: 1000 + i,
extraTags: personal
? [
['e', threadIdThreadDashRoot, '', 'reply'],
]
: const [],
),
];
final messagesNotifier = _FakeMessagesNotifier(
newestPage,
olderPages: [olderPage],
);
final channelsNotifier = _FakeChannelsNotifier(
[_testChannel],
observedUnread: {
_channelId: [
makeObservedUnreadEvent(
id: 'msg21',
createdAt: 1021,
rootId: personal ? threadIdThreadDashRoot : null,
highPriority: false,
channelType: 'stream',
isThreadedReply: personal,
),
],
},
);
final readState = _SynchronousReadStateNotifier(
const ReadStateState(
isReady: true,
pubkey: 'self',
contexts: {_channelId: 1020},
version: 0,
),
);
await tester.pumpWidget(
_buildTestable(
personalChat: personal,
messages: const [],
messagesNotifier: messagesNotifier,
channelsNotifier: channelsNotifier,
readStateNotifier: readState,
users: const {
'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'),
},
),
);
await tester.pumpAndSettle();
await tester.tap(
find.byKey(const ValueKey('channel-jump-to-oldest-unread')),
);
await tester.pumpAndSettle();
expect(findRichText('Message 21'), findsOneWidget);
expect(findRichText('Message 50'), findsNothing);
expect(messagesNotifier.fetchOlderCalls, 1);
},
);
final readState = _SynchronousReadStateNotifier(
const ReadStateState(
isReady: true,
pubkey: 'self',
contexts: {_channelId: 1020},
version: 0,
),
);
await tester.pumpWidget(
_buildTestable(
messages: const [],
messagesNotifier: messagesNotifier,
channelsNotifier: channelsNotifier,
readStateNotifier: readState,
users: const {
'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'),
},
),
);
await tester.pumpAndSettle();
await tester.tap(
find.byKey(const ValueKey('channel-jump-to-oldest-unread')),
);
await tester.pumpAndSettle();
expect(findRichText('Message 21'), findsOneWidget);
expect(findRichText('Message 50'), findsNothing);
expect(messagesNotifier.fetchOlderCalls, 1);
});
}
testWidgets('does not load history for threaded-only unread events', (
tester,
) async {
@@ -10328,7 +10460,307 @@ void main() {
});
});
for (final visibility in ['open', 'private']) {
testWidgets(
'ordinary $visibility send survives a Solo offline-cache failure',
(tester) async {
final sender = _RecordingOrdinarySender();
final channel = Channel(
id: _channelId,
name: 'Ordinary',
channelType: 'stream',
visibility: visibility,
description: '',
createdBy: 'self',
createdAt: DateTime(2026),
memberCount: 2,
isMember: true,
);
await tester.pumpWidget(
_buildTestable(
messages: [],
disableRetries: true,
sender: sender,
organization: Stream.error(const MeCacheException()),
home: ChannelDetailPage(channel: channel),
),
);
await tester.pumpAndSettle();
await tester
.widget<ComposeBar>(find.byType(ComposeBar))
.onSend('Still usable', []);
expect(sender.sent, ['Still usable']);
},
);
}
testWidgets(
'affirmative ordinary route sends without waiting for Solo lookup',
(tester) async {
final sender = _RecordingOrdinarySender();
final pending = StreamController<MeGroups>();
addTearDown(pending.close);
await tester.pumpWidget(
_buildTestable(
messages: [],
sender: sender,
organization: pending.stream,
home: ChannelDetailPage(
channel: _testChannel,
placement: ChannelRoutePlacement.ordinary,
),
),
);
await tester.pump();
expect(find.byType(ComposeBar), findsOneWidget);
await tester
.widget<ComposeBar>(find.byType(ComposeBar))
.onSend('Immediate', []);
expect(sender.sent, ['Immediate']);
await tester.pumpWidget(const SizedBox());
await tester.pump(const Duration(seconds: 3));
},
);
testWidgets('affirmative Solo route never falls back to ordinary sending', (
tester,
) async {
final sender = _RecordingOrdinarySender();
await tester.pumpWidget(
_buildTestable(
messages: [],
personalChat: true,
disableRetries: true,
sender: sender,
organization: Stream.error(const MeCacheException()),
home: ChannelDetailPage(
channel: _testChannel,
personalChat: true,
placement: ChannelRoutePlacement.ordinary,
),
),
);
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('me-assistant-button')), findsOneWidget);
await expectLater(
tester
.widget<ComposeBar>(find.byType(ComposeBar))
.onSend('Must not leak', []),
throwsStateError,
);
expect(sender.sent, isEmpty);
});
testWidgets('identified Solo sends through the Solo sender', (tester) async {
final ordinary = _RecordingOrdinarySender(),
solo = _RecordingOrdinarySender();
await tester.pumpWidget(
_buildTestable(
messages: [],
personalChat: true,
sender: ordinary,
soloSender: solo,
members: [
ChannelMember(pubkey: 'agent', role: 'bot', joinedAt: DateTime(2026)),
],
),
);
await tester.pumpAndSettle();
await tester.widget<ComposeBar>(find.byType(ComposeBar)).onSend(
'Solo message',
['agent'],
);
expect(solo.sent, ['Solo message']);
expect(ordinary.sent, isEmpty);
});
testWidgets('unknown Solo placement remains readable but cannot send', (
tester,
) async {
await tester.pumpWidget(
_buildTestable(
disableRetries: true,
placement: ChannelRoutePlacement.unresolved,
organization: Stream.error(StateError('offline')),
channel: Channel(
id: _channelId,
name: 'Unresolved private chat',
channelType: 'stream',
visibility: 'private',
description: '',
createdBy: 'self',
createdAt: DateTime(2026),
memberCount: 2,
isMember: true,
),
messages: [
_textMsg(id: 'visible', pubkey: 'alice', content: 'Cached message'),
],
),
);
await tester.pumpAndSettle();
expect(findRichText('Cached message'), findsOneWidget);
final compose = tester.widget<ComposeBar>(find.byType(ComposeBar));
await expectLater(
compose.onSend('Do not route without placement', []),
throwsStateError,
);
});
testWidgets(
'default Activity/deep-link entry resolves Solo and keeps replies inline',
(tester) async {
await tester.pumpWidget(
_buildTestable(
personalChat: true,
messages: [
_textMsg(id: threadIdRoot, pubkey: 'self', content: 'Question'),
_textMsg(
id: threadIdReplyZero,
pubkey: 'alice',
content: 'Agent answer',
extraTags: [
['e', threadIdRoot, '', 'reply'],
],
),
],
home: ChannelDetailPage(
channel: Channel(
id: _channelId,
name: 'Solo chat',
channelType: 'stream',
visibility: 'private',
description: '',
createdBy: 'self',
createdAt: DateTime(2026),
memberCount: 2,
isMember: true,
),
initialMessageId: threadIdReplyZero,
initialThreadRootId: threadIdRoot,
),
),
);
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('me-assistant-button')), findsOneWidget);
expect(findRichText('Agent answer'), findsOneWidget);
expect(find.byType(ThreadDetailPage), findsNothing);
final container = ProviderScope.containerOf(
tester.element(find.byType(ChannelDetailPage)),
);
final notifier =
container.read(meMessagesProvider(_channelId).notifier)
as _FakeMessagesNotifier;
expect(notifier.loadedEventIds, contains(threadIdReplyZero));
},
);
group('Typing indicator', () {
testWidgets('Me reply consumes typing and grows its pending bubble', (
tester,
) async {
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
final prompt = _textMsg(
id: threadIdRoot,
pubkey: 'self',
content: 'Hello',
createdAt: now - 3,
);
final typing = _FakeTypingNotifier(const []);
await tester.pumpWidget(
_buildTestable(
personalChat: true,
messages: [prompt],
typingNotifier: typing,
users: const {
'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'),
},
),
);
await tester.pumpAndSettle();
typing.setEntries([
TypingEntry(
pubkey: 'alice',
threadHeadId: threadIdRoot,
expiresAtMs: (now - 1) * 1000 + 8000,
),
]);
await tester.pump();
await tester.pump(const Duration(milliseconds: 250));
expect(find.byKey(const ValueKey('agent-typing-bubble')), findsOneWidget);
final container = ProviderScope.containerOf(
tester.element(find.byType(ChannelDetailPage)),
);
final messages =
container.read(meMessagesProvider(_channelId).notifier)
as _FakeMessagesNotifier;
messages.setMessages([
prompt,
_textMsg(
id: 'reply',
pubkey: 'alice',
content: 'Here is the reply',
createdAt: now,
extraTags: [
['e', threadIdRoot, '', 'reply'],
],
),
]);
await tester.pump();
expect(
find.byKey(const ValueKey('inline-agent-typing-alice')),
findsNothing,
);
final handoff = tester.widget<AgentReplyHandoff>(
find.byKey(const ValueKey('reply-handoff-reply')),
);
expect(handoff.animate, isTrue);
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('agent-typing-bubble')), findsNothing);
expect(findRichText('Here is the reply'), findsOneWidget);
});
for (final personal in [true, false]) {
testWidgets('reply-scoped typing is visible only in flat Me: $personal', (
tester,
) async {
final typing = _FakeTypingNotifier(const []);
await tester.pumpWidget(
_buildTestable(
personalChat: personal,
messages: [
_textMsg(id: 'prompt', pubkey: 'self', content: 'Hello'),
],
typingNotifier: typing,
disableAnimations: true,
users: const {
'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'),
},
),
);
await tester.pumpAndSettle();
typing.setEntries(const [
TypingEntry(
pubkey: 'alice',
threadHeadId: 'prompt',
expiresAtMs: 9999999999999,
),
]);
await tester.pumpAndSettle();
expect(
find.byKey(const ValueKey('inline-agent-typing-alice')),
personal ? findsOneWidget : findsNothing,
);
expect(find.text('Alice is typing…'), findsNothing);
typing.setEntries(const []);
await tester.pumpAndSettle();
expect(
find.byKey(const ValueKey('inline-agent-typing-alice')),
findsNothing,
);
});
}
testWidgets('shows single typer', (tester) async {
await tester.pumpWidget(
_buildTestable(
@@ -16247,6 +16679,13 @@ class _FakeMessagesNotifier extends ChannelMessagesNotifier {
final List<List<NostrEvent>> _olderPages;
final bool failOlderFetch;
final Map<String, ChannelWindowThreadSummary> summaries;
final loadedEventIds = <String>{};
@override
Future<void> loadEventsById(Iterable<String> eventIds) async {
loadedEventIds.addAll(eventIds);
await super.loadEventsById(eventIds);
}
int fetchOlderCalls = 0;
_FakeMessagesNotifier(
@@ -17226,3 +17665,19 @@ class _MutableColorProfileNotifier extends ProfileNotifier {
void setProfileState(AsyncValue<UserProfile?> value) => state = value;
}
class _RecordingOrdinarySender implements SendMessage {
final sent = <String>[];
@override
Future<void> call({
required String channelId,
required String content,
String? parentEventId,
String? rootEventId,
List<String>? mentionPubkeys,
Channel? channel,
List<List<String>> mediaTags = const [],
}) async {
sent.add(content);
}
}

View File

@@ -1,4 +1,5 @@
import 'dart:async';
import 'package:buzz/features/channels/me/me_messages_provider.dart';
import 'dart:collection';
import 'dart:convert';
@@ -44,6 +45,65 @@ void test(String description, Future<void> Function() body) {
}
void main() {
test(
'Solo notification target does not skip the intervening history',
() async {
final relay = _CursorRelay();
final container = _buildContainer(relay);
addTearDown(container.dispose);
container.read(meMessagesProvider(_channelId));
await relay.subscribed;
await _pumpEventQueue();
final notifier = container.read(meMessagesProvider(_channelId).notifier);
await notifier.loadEventsById(['older']);
expect(
container.read(meMessagesProvider(_channelId)).value!.first.id,
'older',
);
expect(await notifier.fetchOlder(), isTrue);
expect(await notifier.fetchOlder(), isTrue);
expect(await notifier.fetchOlder(), isFalse);
expect(container.read(meMessagesProvider(_channelId)).value!.length, 351);
},
);
test('Solo paginates through more than 100 equal-second events', () async {
final relay = _CursorRelay();
final container = _buildContainer(relay);
addTearDown(container.dispose);
container.read(meMessagesProvider(_channelId));
await relay.subscribed;
await _pumpEventQueue();
final notifier = container.read(meMessagesProvider(_channelId).notifier);
expect(await notifier.fetchOlder(), isTrue);
expect(await notifier.fetchOlder(), isTrue);
expect(await notifier.fetchOlder(), isFalse);
final events = container.read(meMessagesProvider(_channelId)).value!;
expect(events.length, 351);
expect(events.first.id, 'older');
expect(events.map((e) => e.id).toSet().length, 351);
});
test(
'Me reads channel replies without the top-level window filter',
() async {
final relay = _RecordingRelaySessionNotifier(
historyResults: [
[_event(id: 'history', createdAt: 10)],
],
);
final container = _buildContainer(relay);
addTearDown(container.dispose);
container.read(meMessagesProvider(_channelId));
await relay.subscribed;
await _pumpEventQueue();
expect(relay.queryFilters, isEmpty);
expect(relay.historyFilters.single.extensions['top_level'], isNot(true));
expect(
container.read(meMessagesProvider(_channelId)).value!.map((e) => e.id),
['history'],
);
},
);
test('live window without deep links does not rescan flattened ids', () async {
var historyIdReads = 0;
final history = _IdReadTrackingEvent(
@@ -5709,3 +5769,31 @@ class _RecordingRelaySessionNotifier extends RelaySessionNotifier {
}
}
}
class _CursorRelay extends _RecordingRelaySessionNotifier {
final events = [
for (var i = 0; i < 350; i++)
_event(id: i.toString().padLeft(64, '0'), createdAt: 20),
_event(id: 'older', createdAt: 19),
];
@override
Future<List<NostrEvent>> fetchHistory(
NostrFilter filter, {
Duration timeout = const Duration(seconds: 8),
}) async {
if (filter.ids != null) {
return events.where((e) => filter.ids!.contains(e.id)).toList();
}
final before = filter.extensions['before_id'] as String?;
return events
.where(
(e) =>
filter.until == null ||
e.createdAt < filter.until! ||
(e.createdAt == filter.until &&
(before == null || e.id.compareTo(before) > 0)),
)
.take(filter.limit)
.toList();
}
}

View File

@@ -0,0 +1,209 @@
import 'package:buzz/features/channels/channel_typing_provider.dart';
import 'package:buzz/shared/relay/relay.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../helpers/thread_ids.dart';
class _Relay extends RelaySessionNotifier {
late void Function(NostrEvent) receive;
int calls = 0;
bool reject = false;
void Function(String)? close;
int active = 0;
int peak = 0;
void setStatus(SessionStatus status) => state = SessionState(status: status);
@override
SessionState build() => const SessionState(status: SessionStatus.connected);
@override
Future<void Function()> subscribe(
NostrFilter filter,
void Function(NostrEvent) onEvent, {
void Function(String)? onClosed,
}) async {
expect(filter.kinds, containsAll([20002, 9, 40002, 40008]));
calls++;
close = onClosed;
if (reject) throw StateError('rejected');
receive = onEvent;
active++;
if (active > peak) peak = active;
return () {
active--;
};
}
}
void main() {
test(
'initial rejection retries with a bound and no uncaught error',
() async {
final relay = _Relay()..reject = true;
final container = ProviderContainer(
overrides: [relaySessionProvider.overrideWith(() => relay)],
);
container.listen(channelTypingProvider('chat'), (_, _) {});
await container.pump();
expect(relay.calls, 1);
for (final seconds in [1, 2, 4, 8]) {
await Future<void>.delayed(Duration(milliseconds: seconds * 1000 + 20));
}
expect(relay.calls, 4);
container.dispose();
},
);
test(
'terminal close clears typing and retries; disposal cancels recovery',
() async {
final relay = _Relay();
final container = ProviderContainer(
overrides: [relaySessionProvider.overrideWith(() => relay)],
);
final provider = channelTypingProvider('chat');
container.listen(provider, (_, _) {});
await container.pump();
relay.receive(
NostrEvent(
id: 'typing',
pubkey: 'agent',
kind: 20002,
createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000,
content: '',
sig: '',
tags: [
['h', 'chat'],
],
),
);
expect(container.read(provider), hasLength(1));
relay.close!('terminal');
await container.pump();
expect(container.read(provider), isEmpty);
expect(relay.active, 0);
await Future<void>.delayed(const Duration(milliseconds: 1100));
expect(relay.active, 1);
expect(relay.calls, 2);
relay.close!('terminal again');
await container.pump();
container.dispose();
await Future<void>.delayed(const Duration(milliseconds: 2100));
expect(relay.calls, 2);
expect(relay.active, 0);
},
);
test(
'terminal closure loops stay bounded until sustained recovery',
() async {
final relay = _Relay();
final container = ProviderContainer(
overrides: [relaySessionProvider.overrideWith(() => relay)],
);
addTearDown(container.dispose);
container.listen(channelTypingProvider('chat'), (_, _) {});
await container.pump();
for (final delay in [1, 2, 4]) {
final calls = relay.calls;
relay.close!('rejected after admission');
await container.pump();
if (delay > 1) {
await Future<void>.delayed(const Duration(milliseconds: 1100));
expect(relay.calls, calls);
}
await Future<void>.delayed(
Duration(milliseconds: delay * 1000 + 50 - (delay > 1 ? 1100 : 0)),
);
expect(relay.calls, calls + 1);
}
relay.close!('fourth rejection');
await container.pump();
await Future<void>.delayed(const Duration(milliseconds: 1100));
expect(relay.calls, 4);
relay.setStatus(SessionStatus.disconnected);
await container.pump();
relay.setStatus(SessionStatus.connected);
await container.pump();
relay.close!('first rejection after reconnect');
await container.pump();
await Future<void>.delayed(const Duration(milliseconds: 1100));
final recoveredCalls = relay.calls;
await Future<void>.delayed(const Duration(milliseconds: 8100));
relay.close!('closure after sustained health');
await container.pump();
await Future<void>.delayed(const Duration(milliseconds: 1100));
expect(relay.calls, recoveredCalls + 1);
expect(relay.active, 1);
},
);
test('transport rebuilds release each typing subscription', () async {
final relay = _Relay();
final container = ProviderContainer(
overrides: [relaySessionProvider.overrideWith(() => relay)],
);
container.listen(channelTypingProvider('chat'), (_, _) {});
await container.pump();
for (var i = 0; i < 4; i++) {
relay.setStatus(SessionStatus.disconnected);
await container.pump();
expect(relay.active, 0);
relay.setStatus(SessionStatus.connected);
await container.pump();
expect(relay.active, 1);
}
expect(relay.peak, 1);
container.dispose();
await Future<void>.delayed(Duration.zero);
expect(relay.active, 0);
});
test(
'reply clears its scope immediately and suppresses late pulses',
() async {
final relay = _Relay();
final container = ProviderContainer(
overrides: [relaySessionProvider.overrideWith(() => relay)],
);
addTearDown(container.dispose);
final provider = channelTypingProvider('chat');
container.read(provider);
await Future<void>.value();
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
void emit(int kind, String agent, String root, int time) => relay.receive(
NostrEvent(
id: '$kind-$agent-$root-$time',
pubkey: agent,
kind: kind,
createdAt: time,
content: '',
sig: '',
tags: [
['h', 'chat'],
['e', root, '', 'root'],
['e', threadIdParent, '', 'reply'],
],
),
);
emit(20002, 'a', threadIdRoot, now - 2);
emit(20002, 'a', threadIdOtherRoot, now - 2);
emit(20002, 'b', threadIdRoot, now - 2);
expect(container.read(provider), hasLength(3));
emit(9, 'a', threadIdRoot, now - 1);
expect(container.read(provider).map((e) => (e.pubkey, e.threadHeadId)), [
('a', threadIdOtherRoot),
('b', threadIdRoot),
]);
emit(
20002,
'a',
threadIdRoot,
now,
); // Newer but inside desktop's quiet interval.
emit(20002, 'a', threadIdRoot, now - 2); // Delayed old pulse.
expect(container.read(provider), hasLength(2));
emit(
20002,
'c',
threadIdRoot,
now - 9,
); // Expired history is not live typing.
expect(container.read(provider), hasLength(2));
},
);
}

View File

@@ -3,6 +3,7 @@ import 'package:buzz/shared/widgets/app_route_observer.dart';
import 'package:buzz/features/pairing/pairing_provider.dart';
import 'package:buzz/shared/community/paired_community_landing.dart';
import 'dart:async';
import 'package:shared_preferences/shared_preferences.dart';
import '../profile/presence_snapshot_test.dart'
show PresenceTestRelay, presenceEvent;
@@ -667,6 +668,56 @@ void main() {
expect(appBar.frostedBlurSigma, 23.12);
});
testWidgets('Home keeps header blur after returning from search', (
tester,
) async {
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
tester.view.physicalSize = const Size(320, 320);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await tester.pumpWidget(
buildTestable(
realHome: true,
topSectionGradient: const LinearGradient(
colors: [Colors.yellow, Colors.blue],
),
overrides: [
savedPrefsProvider.overrideWithValue(prefs),
channelsProvider.overrideWith(() => _FakeNotifier(testChannels)),
],
),
);
await tester.pumpAndSettle();
final position = tester
.state<ScrollableState>(
find
.descendant(
of: find.byType(CustomScrollView),
matching: find.byType(Scrollable),
)
.first,
)
.position;
position.jumpTo(position.maxScrollExtent);
await tester.pumpAndSettle();
final header = find.byType(FrostedAppBar).first;
expect(
find.descendant(of: header, matching: find.byType(BackdropFilter)),
findsOneWidget,
);
final offset = position.pixels;
await tester.tap(find.byTooltip('Search'));
await tester.pumpAndSettle();
tester.state<NavigatorState>(find.byType(Navigator).first).pop();
await tester.pumpAndSettle();
expect(position.pixels, offset);
expect(
find.descendant(of: header, matching: find.byType(BackdropFilter)),
findsOneWidget,
);
});
for (final customRoute in [false, true]) {
testWidgets(
'Home replays native scroll state on return: custom=$customRoute',

View File

@@ -0,0 +1,84 @@
import 'package:buzz/features/channels/channel_identity_names_provider.dart';
import 'package:buzz/features/channels/channel_typing_provider.dart';
import 'package:buzz/features/channels/inline_agent_typing.dart';
import 'package:buzz/shared/identity_names/identity_names.dart';
import 'package:buzz/shared/profile/user_cache_provider.dart';
import 'package:buzz/shared/profile/user_profile.dart';
import 'package:buzz/shared/theme/theme.dart';
import 'package:buzz/shared/widgets/avatar_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
const agent =
'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
const profile = UserProfile(pubkey: agent, displayName: 'Test agent');
class Profiles extends UserCacheNotifier {
@override
Map<String, UserProfile> build() => {agent: profile};
}
Widget surface({bool reduced = false, bool typing = true}) => ProviderScope(
overrides: [
userCacheProvider.overrideWith(Profiles.new),
channelIdentityNamesProvider('chat').overrideWithValue(
const IdentityNameSources(profiles: {agent: profile}).scope([agent]),
),
],
child: MaterialApp(
theme: AppTheme.light(),
home: MediaQuery(
data: MediaQueryData(disableAnimations: reduced),
child: Scaffold(
body: InlineAgentTyping(
channelId: 'chat',
entries: [
if (typing) const TypingEntry(pubkey: agent, expiresAtMs: 8000),
],
),
),
),
),
);
void main() {
testWidgets(
'message rail, author and dots replace status text without a timestamp',
(tester) async {
await tester.pumpWidget(surface());
expect(find.text('Test agent'), findsOneWidget);
expect(find.byType(AvatarImage), findsOneWidget);
expect(find.textContaining('typing'), findsNothing);
expect(find.byType(Text), findsNWidgets(2)); // Name and avatar initial.
expect(find.byKey(const ValueKey('agent-typing-dot-2')), findsOneWidget);
expect(find.byKey(const ValueKey('agent-typing-dot-0')), findsOneWidget);
final dot = find.byKey(const ValueKey('agent-typing-dot-0'));
final initial = tester
.widget<Transform>(dot)
.transform
.getTranslation()
.y;
await tester.pump(const Duration(milliseconds: 240));
expect(
tester.widget<Transform>(dot).transform.getTranslation().y,
lessThan(initial),
);
expect(
tester.getTopLeft(find.text('Test agent')).dx,
messageAvatarSize + messageAvatarContentGap + Grid.xs,
);
await tester.pumpWidget(surface(typing: false));
expect(find.byType(AvatarImage), findsNothing);
expect(find.text('Working…'), findsNothing);
},
);
testWidgets('Reduce Motion leaves static dots and no active animation', (
tester,
) async {
await tester.pumpWidget(surface(reduced: true));
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('agent-typing-dot-2')), findsOneWidget);
expect(find.byType(ShaderMask), findsNothing);
expect(tester.binding.hasScheduledFrame, isFalse);
});
}

View File

@@ -0,0 +1,118 @@
import 'package:buzz/features/channels/channel.dart';
import 'package:buzz/features/channels/channels_provider.dart';
import 'package:buzz/features/channels/me/me_page.dart';
import 'package:buzz/shared/me/me_assistants.dart';
import 'package:buzz/shared/me/me_chat_creation.dart';
import 'package:buzz/shared/me/me_groups.dart';
import 'package:buzz/shared/me/me_sync.dart';
import 'package:buzz/shared/relay/relay.dart';
import 'package:buzz/shared/theme/theme.dart';
import 'package:buzz/shared/widgets/frosted_app_bar.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:nostr/nostr.dart' as nostr;
import 'package:shared_preferences/shared_preferences.dart';
class _Channels extends ChannelsNotifier {
@override
Future<List<Channel>> build() async => [];
}
class _Relay extends RelaySessionNotifier {
VoidCallback? beforeRead;
int reads = 0;
@override
Future<List<NostrEvent>> queryRelay(
List<NostrFilter> filters, {
Duration timeout = const Duration(seconds: 8),
}) async {
reads++;
beforeRead?.call();
throw StateError('ownership lookup interrupted');
}
}
void main() {
for (final disconnect in [true, false]) {
testWidgets(
'chat accepted before ${disconnect ? "disconnect" : "preflight failure"} is durable',
(tester) async {
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
final config = RelayConfig(
baseUrl: 'https://relay.test',
nsec: nostr.Nip19.encode(
prefix: nostr.Nip19Prefix.nsec,
data: '0' * 63 + '1',
),
);
final relay = _Relay();
var sync = MeSync(relay, config, prefs: prefs);
relay.beforeRead = () =>
expectSync(MeChatCreation.pending(sync)?.name, 'Saved chat');
late ProviderContainer container;
var forms = 0;
const bridge = MethodChannel('buzz/profile_text_editor');
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(bridge, (
call,
) async {
forms++;
if (forms > 1) return null;
if (disconnect) {
sync.disposed = true;
sync = MeSync(relay, config, prefs: prefs, connected: false);
container.invalidate(meSyncProvider);
}
return {'name': 'Saved chat', 'assistant': 'agent'};
});
addTearDown(
() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
bridge,
null,
),
);
await tester.pumpWidget(
ProviderScope(
overrides: [
meSyncProvider.overrideWith((ref) => sync),
meGroupsProvider.overrideWith(
(ref) => Stream.value(const MeGroups()),
),
meAssistantsProvider.overrideWith((ref) async => []),
channelsProvider.overrideWith(_Channels.new),
],
child: MaterialApp(theme: AppTheme.light(), home: const MePage()),
),
);
await tester.pumpAndSettle();
container = ProviderScope.containerOf(
tester.element(find.byType(MePage)),
);
tester
.widget<FrostedAppBar>(find.byType(FrostedAppBar))
.nativeActions!
.firstWhere((a) => a.label == 'New chat')
.onPressed!();
await tester.pumpAndSettle();
final saved = MeChatCreation.pending(
MeSync(relay, config, prefs: prefs),
);
expect(
saved,
isNotNull,
reason: 'Accepted chat must survive before preflight succeeds',
);
expect(saved!.name, 'Saved chat');
expect(saved.agent, 'agent');
expect(
prefs.getString(MeChatCreation.storageKey(sync)),
isNot(contains('Saved chat')),
);
expect(relay.reads, disconnect ? 0 : 1);
},
variant: TargetPlatformVariant.only(TargetPlatform.iOS),
);
}
}

View File

@@ -0,0 +1,423 @@
import 'dart:async';
import 'dart:ui' show SemanticsAction;
import 'package:buzz/features/channels/me/me_chat_assistant.dart';
import 'package:buzz/features/channels/me/me_assistant_picker.dart';
import 'package:buzz/features/channels/channel_management_provider.dart';
import 'package:buzz/shared/me/me_assistants.dart';
import 'package:buzz/shared/me/me_agent_change.dart';
import 'package:buzz/shared/me/me_sync.dart';
import 'package:buzz/shared/mentions/agent_identity_provider.dart';
import 'package:buzz/shared/profile/user_cache_provider.dart';
import 'package:buzz/shared/profile/user_profile.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:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:nostr/nostr.dart' as nostr;
import 'package:shared_preferences/shared_preferences.dart';
class _Profiles extends UserCacheNotifier {
@override
Map<String, UserProfile> build() => {
'a': const UserProfile(pubkey: 'a', displayName: 'Alpha'),
'b': const UserProfile(pubkey: 'b', displayName: 'Beta'),
};
}
class _Harness extends HookConsumerWidget {
final ValueChanged<List<String>> onSend;
const _Harness(this.onSend);
@override
Widget build(BuildContext context, WidgetRef ref) {
final assistant = useMeChatAssistant(
context,
ref,
enabled: true,
channelId: 'chat',
initial: null,
messages: const [],
);
return Scaffold(
appBar: AppBar(actions: [assistant.action]),
body: Column(
children: [
?assistant.pendingNotice,
TextButton(
onPressed: () async => onSend(
await meRecipients(ref, 'chat', assistant.selected, const []),
),
child: const Text('Send'),
),
],
),
);
}
}
void main() {
for (final keyFallback in [false, true]) {
final target = keyFallback ? '0123456789abcdef' * 4 : 'b';
final targetName = keyFallback ? '01234567…cdef' : 'Beta';
for (final action in ['Resume', 'Later', 'Discard choice']) {
testWidgets(
'header restores pending agent choice: $action fallback=$keyFallback',
(tester) async {
final semantics = tester.ensureSemantics();
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
final sync = MeSync(
RelaySessionNotifier(),
RelayConfig(
baseUrl: 'https://relay.test',
nsec: nostr.Nip19.encode(
prefix: nostr.Nip19Prefix.nsec,
data: '0' * 63 + '1',
),
),
prefs: prefs,
);
await prefs.setString(MeAgentChange.selectionKey(sync, 'chat'), 'a');
await MeAgentChange.create(sync, 'chat', target).save(sync, 'chat');
await tester.pumpWidget(
ProviderScope(
overrides: [
meSyncProvider.overrideWithValue(sync),
meAssistantsProvider.overrideWith(
(ref) async => [
AgentDirectoryEntry(pubkey: 'a', displayName: 'Alpha'),
AgentDirectoryEntry(
pubkey: target,
displayName: keyFallback ? null : 'Beta',
),
],
),
channelMembersProvider('chat').overrideWith(
(ref) async => [
for (final id in ['a', target])
ChannelMember(
pubkey: id,
role: 'bot',
joinedAt: DateTime(2026),
),
],
),
userCacheProvider.overrideWith(_Profiles.new),
],
child: MaterialApp(
theme: AppTheme.light(),
home: _Harness((_) {}),
),
),
);
await tester.pumpAndSettle();
expect(find.text('Finish choosing $targetName'), findsOneWidget);
expect(
tester
.getSemantics(
find.widgetWithText(
TextButton,
'Finish choosing $targetName',
),
)
.getSemanticsData()
.hasAction(SemanticsAction.tap),
isTrue,
);
expect(
find.byTooltip('Pending agent change: $targetName'),
findsOneWidget,
);
await tester.tap(find.text('Finish choosing $targetName'));
await tester.pumpAndSettle();
expect(
find.textContaining('Your choice of $targetName'),
findsOneWidget,
);
expect(
find.textContaining('keeps any access already granted'),
findsOneWidget,
);
expect(find.text('Finish changing your agent?'), findsOneWidget);
await tester.tap(find.text(action));
await tester.pumpAndSettle();
expect(
prefs.getString(MeAgentChange.selectionKey(sync, 'chat')),
action == 'Resume' ? target : 'a',
);
expect(
MeAgentChange.pending(sync, 'chat') != null,
action == 'Later',
);
expect(
find.text('Finish choosing $targetName'),
action == 'Later' ? findsOneWidget : findsNothing,
);
semantics.dispose();
},
);
}
}
for (final scenario in ['valid', 'revoked', 'removed']) {
testWidgets('parallel send checks handle $scenario recipients', (
tester,
) async {
final owned = Completer<bool>();
final membership = Completer<List<ChannelMember>>();
var ownershipStarted = false;
var membershipStarted = false;
late WidgetRef widgetRef;
final sync = MeSync(
RelaySessionNotifier(),
RelayConfig(
baseUrl: 'https://relay.test',
nsec: nostr.Nip19.encode(
prefix: nostr.Nip19Prefix.nsec,
data:
'0000000000000000000000000000000000000000000000000000000000000001',
),
),
);
await tester.pumpWidget(
ProviderScope(
overrides: [
meSyncProvider.overrideWithValue(sync),
meAssistantOwnershipProvider('a').overrideWith((ref) {
ownershipStarted = true;
return owned.future;
}),
channelMembersProvider('chat').overrideWith((ref) {
membershipStarted = true;
return membership.future;
}),
],
child: Consumer(
builder: (context, ref, child) {
widgetRef = ref;
return const SizedBox.shrink();
},
),
),
);
final result = meRecipients(widgetRef, 'chat', 'a', const []);
expect(ownershipStarted, isTrue);
expect(membershipStarted, isTrue);
final assertion = expectLater(
result,
scenario == 'valid' ? completion(['a']) : throwsStateError,
);
owned.complete(scenario != 'revoked');
membership.complete(
scenario == 'removed'
? []
: [
ChannelMember(
pubkey: 'a',
role: 'bot',
joinedAt: DateTime(2026),
),
],
);
await assertion;
});
}
NostrEvent message(String author, List<String> recipients, int time) =>
NostrEvent(
id: '$time',
pubkey: author,
kind: EventKind.streamMessage,
createdAt: time,
content: 'hello',
tags: [
for (final p in recipients) ['p', p],
],
sig: '',
);
test('last agent inference uses newest-first equal-second ID ordering', () {
final older = NostrEvent(
id: 'z',
pubkey: 'me',
kind: EventKind.streamMessage,
createdAt: 2,
content: '',
tags: const [
['p', 'a'],
],
sig: '',
);
final newer = NostrEvent(
id: 'a',
pubkey: 'me',
kind: EventKind.streamMessage,
createdAt: 2,
content: '',
tags: const [
['p', 'b'],
],
sig: '',
);
for (final input in [
[older, newer],
[newer, older],
]) {
expect(
resolveMeAssistant(
selected: null,
available: {'a', 'b'},
viewer: 'me',
messages: input,
),
'b',
);
}
});
test(
'inherits the sole agent and does not reuse a removed explicit choice',
() {
expect(
resolveMeAssistant(
selected: null,
available: {'a'},
viewer: 'me',
messages: [],
),
'a',
);
expect(
resolveMeAssistant(
selected: 'b',
available: {'a'},
viewer: 'me',
messages: [],
),
isNull,
);
},
);
test(
'inherits last desktop recipient but leaves ambiguous recipients unset',
() {
expect(
resolveMeAssistant(
selected: null,
available: {'a', 'b'},
viewer: 'me',
messages: [
message('me', ['a'], 1),
message('other', ['a'], 3),
message('me', ['b'], 2),
],
),
'b',
);
expect(
resolveMeAssistant(
selected: null,
available: {'a', 'b'},
viewer: 'me',
messages: [
message('me', ['a', 'b'], 2),
message('me', ['a'], 1),
],
),
isNull,
);
},
);
testWidgets(
'header avatar inherits agent, routes plain send, and remembers a change',
(tester) async {
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
final sync = MeSync(
RelaySessionNotifier(),
RelayConfig(
baseUrl: 'https://relay.test',
nsec: nostr.Nip19.encode(
prefix: nostr.Nip19Prefix.nsec,
data:
'0000000000000000000000000000000000000000000000000000000000000001',
),
),
prefs: prefs,
);
var members = ['a'];
var directoryReads = 0;
var ownershipReads = 0;
List<String>? sent;
await tester.pumpWidget(
ProviderScope(
overrides: [
meSyncProvider.overrideWithValue(sync),
meAssistantOwnershipProvider.overrideWith((ref, key) async {
ownershipReads++;
return key == 'a' || key == 'b';
}),
meAssistantsProvider.overrideWith((ref) async {
directoryReads++;
return const [
AgentDirectoryEntry(pubkey: 'a', displayName: 'Alpha'),
AgentDirectoryEntry(pubkey: 'b', displayName: 'Beta'),
];
}),
channelMembersProvider('chat').overrideWith(
(ref) async => [
for (final id in members)
ChannelMember(
pubkey: id,
role: 'bot',
joinedAt: DateTime(2026),
),
],
),
userCacheProvider.overrideWith(_Profiles.new),
],
child: MaterialApp(
theme: AppTheme.light(),
home: _Harness((value) => sent = value),
),
),
);
await tester.pumpAndSettle();
expect(find.byTooltip('Change agent: Alpha'), findsOneWidget);
expect(find.byType(AvatarImage), findsOneWidget);
expect(find.text('Choose follow-up assistant'), findsNothing);
final readsBeforeSend = directoryReads;
await tester.tap(find.text('Send'));
await tester.pumpAndSettle();
expect(sent, ['a']);
expect(directoryReads, readsBeforeSend);
expect(ownershipReads, 1);
final readsBeforeOpening = directoryReads;
await tester.tap(find.byTooltip('Change agent: Alpha'));
await tester.pumpAndSettle();
expect(directoryReads, readsBeforeOpening);
expect(find.text('Agents · 2'), findsOneWidget);
// Only the sheet heading says Agent; member rows have no role subtitle.
expect(find.text('Agent'), findsOneWidget);
final list = tester.widget<ListView>(find.byType(ListView));
expect(
list.padding,
const EdgeInsets.fromLTRB(Grid.gutter, 0, Grid.gutter, Grid.xs),
);
members = ['a', 'b'];
ProviderScope.containerOf(
tester.element(find.byType(_Harness)),
).invalidate(channelMembersProvider('chat'));
await tester.pumpAndSettle();
await tester.tap(find.text('Beta'));
await tester.pumpAndSettle();
expect(find.byTooltip('Change agent: Beta'), findsOneWidget);
expect(
prefs.getString('me-assistant:${sync.community}:${sync.pubkey}:chat'),
'b',
);
await tester.tap(find.text('Send'));
await tester.pumpAndSettle();
expect(sent, ['b']);
expect(ownershipReads, 2);
},
);
}

View File

@@ -0,0 +1,340 @@
import 'dart:async';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:buzz/shared/me/me_chat_creation.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:nostr/nostr.dart' as nostr;
import 'package:buzz/shared/relay/relay.dart';
import 'package:buzz/shared/me/me_assistants.dart';
import 'package:buzz/shared/theme/buzz_icons.dart';
import 'package:buzz/shared/widgets/frosted_app_bar.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:buzz/features/channels/channel.dart';
import 'package:buzz/features/channels/channels_provider.dart';
import 'package:buzz/features/channels/me/me_page.dart';
import 'package:buzz/shared/me/me_groups.dart';
import 'package:buzz/shared/me/me_sync.dart';
import 'package:buzz/shared/theme/theme.dart';
class FakeChannels extends ChannelsNotifier {
@override
Future<List<Channel>> build() async => [
for (final (id, name, member) in [
('personal', 'Design agent', true),
('ordinary', 'General', true),
('inaccessible', 'Hidden chat', false),
])
Channel(
id: id,
name: name,
channelType: 'stream',
visibility: 'private',
description: '',
createdBy: 'owner',
createdAt: DateTime(2026),
memberCount: 2,
isMember: member,
),
];
}
void main() {
testWidgets(
'live desktop placement displays projects and only accessible Me chats',
(tester) async {
final updates = StreamController<MeGroups>();
addTearDown(updates.close);
await tester.pumpWidget(
ProviderScope(
overrides: [
meGroupsProvider.overrideWith((ref) => updates.stream),
channelsProvider.overrideWith(FakeChannels.new),
],
child: MaterialApp(theme: AppTheme.light(), home: const MePage()),
),
);
updates.add(
const MeGroups(
groups: [
{'id': 'project', 'name': 'App work', 'defaultTemplateId': ''},
],
assignments: {'personal': 'project'},
channels: ['personal', 'inaccessible'],
),
);
await tester.pumpAndSettle();
expect(find.text('Search chats'), findsNothing);
expect(find.byType(TextField), findsNothing);
expect(find.byIcon(BuzzIcons.plus), findsOneWidget);
expect(find.byIcon(BuzzIcons.folderPlus), findsOneWidget);
final header = tester.widget<FrostedAppBar>(find.byType(FrostedAppBar));
expect(header.nativeLargeTitle, isTrue);
expect(header.nativeActions!.map((action) => action.icon), [
BuzzIcons.folderPlus,
BuzzIcons.plus,
]);
expect(find.text('App work'), findsOneWidget);
expect(find.text('Design agent'), findsOneWidget);
expect(find.text('General'), findsNothing);
expect(find.text('Hidden chat'), findsNothing);
expect(find.text('Chats'), findsOneWidget);
await tester.tap(find.text('App work'));
await tester.pumpAndSettle();
expect(find.text('Design agent'), findsNothing);
await tester.tap(find.text('App work'));
await tester.pumpAndSettle();
expect(find.text('Design agent'), findsOneWidget);
updates.add(
const MeGroups(
groups: [
{'id': 'project', 'name': 'App work', 'defaultTemplateId': ''},
],
channels: ['personal'],
),
);
await tester.pumpAndSettle();
expect(find.text('No chats in this project'), findsOneWidget);
expect(find.text('Design agent'), findsOneWidget);
await tester.tap(find.text('Chats'));
await tester.pumpAndSettle();
expect(find.text('Design agent'), findsNothing);
expect(find.text('No chats in this project'), findsOneWidget);
await tester.tap(find.text('Chats'));
await tester.pumpAndSettle();
expect(find.text('Design agent'), findsOneWidget);
updates.add(const MeGroups());
await tester.pumpAndSettle();
expect(find.text('Design agent'), findsNothing);
expect(find.text('A place to work with your agents'), findsOneWidget);
},
);
testWidgets('new chat opens both fields in one dismissible sheet', (
tester,
) async {
final sync = MeSync(
RelaySessionNotifier(),
RelayConfig(
baseUrl: 'https://relay.test',
nsec: nostr.Nip19.encode(
prefix: nostr.Nip19Prefix.nsec,
data:
'0000000000000000000000000000000000000000000000000000000000000001',
),
),
);
await tester.pumpWidget(
ProviderScope(
overrides: [
meGroupsProvider.overrideWith(
(ref) => Stream.value(const MeGroups()),
),
meSyncProvider.overrideWithValue(sync),
channelsProvider.overrideWith(FakeChannels.new),
meAssistantsProvider.overrideWith((ref) async => []),
],
child: MaterialApp(theme: AppTheme.light(), home: const MePage()),
),
);
await tester.pumpAndSettle();
await tester.tap(find.byTooltip('New chat'));
await tester.pumpAndSettle();
expect(find.byType(BottomSheet), findsOneWidget);
expect(
find.byKey(const ValueKey('new-session-name')).hitTestable(),
findsOneWidget,
);
expect(
find.byKey(const ValueKey('new-session-assistant')).hitTestable(),
findsOneWidget,
);
await tester.enterText(
find.byKey(const ValueKey('new-session-name')),
'Design session',
);
await tester.tap(find.text('Create chat'));
await tester.pumpAndSettle();
expect(find.text('Enter a name and choose an agent.'), findsOneWidget);
expect(find.text('Design session'), findsOneWidget);
});
testWidgets('iOS new chat uses the native profile form with both fields', (
tester,
) async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
addTearDown(() => debugDefaultTargetPlatformOverride = null);
const channel = MethodChannel('buzz/profile_text_editor');
final calls = <MethodCall>[];
var directoryLoads = 0;
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(channel, (
call,
) async {
calls.add(call);
return null;
});
addTearDown(
() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
channel,
null,
),
);
final sync = MeSync(
RelaySessionNotifier(),
RelayConfig(
baseUrl: 'https://relay.test',
nsec: nostr.Nip19.encode(
prefix: nostr.Nip19Prefix.nsec,
data:
'0000000000000000000000000000000000000000000000000000000000000001',
),
),
);
await tester.pumpWidget(
ProviderScope(
overrides: [
meGroupsProvider.overrideWith(
(ref) => Stream.value(const MeGroups()),
),
meSyncProvider.overrideWithValue(sync),
channelsProvider.overrideWith(FakeChannels.new),
meAssistantsProvider.overrideWith((ref) async {
directoryLoads++;
return [];
}),
],
child: MaterialApp(theme: AppTheme.light(), home: const MePage()),
),
);
await tester.pumpAndSettle();
final bar = tester.widget<FrostedAppBar>(find.byType(FrostedAppBar));
bar.nativeActions!
.firstWhere((action) => action.label == 'New chat')
.onPressed!();
await tester.pumpAndSettle();
expect(directoryLoads, 1);
expect(calls, hasLength(1));
expect(calls.single.method, 'presentChat');
final args = calls.single.arguments as Map;
expect(args['title'], 'New chat');
expect(args['placeholder'], 'Chat name');
expect(args['assistants'], isEmpty);
expect(args['containerCornerRadius'], Radii.container);
expect(find.byType(BottomSheet), findsNothing);
await tester.pumpWidget(const SizedBox.shrink());
debugDefaultTargetPlatformOverride = null;
});
for (final resume in [true, false]) {
testWidgets('iOS new chat recovers a durable creation: resume=$resume', (
tester,
) async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
addTearDown(() => debugDefaultTargetPlatformOverride = null);
const channel = MethodChannel('buzz/profile_text_editor');
final calls = <MethodCall>[];
var directoryLoads = 0;
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(channel, (
call,
) async {
calls.add(call);
return null;
});
addTearDown(
() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
channel,
null,
),
);
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
final sync = MeSync(
RelaySessionNotifier(),
RelayConfig(
baseUrl: 'https://relay.test',
nsec: nostr.Nip19.encode(
prefix: nostr.Nip19Prefix.nsec,
data:
'0000000000000000000000000000000000000000000000000000000000000001',
),
),
prefs: prefs,
);
await const MeChatCreation(
'same-id',
'Unfinished chat',
'agent',
).save(sync);
final restarted = MeSync(
RelaySessionNotifier(),
sync.config,
prefs: prefs,
);
await tester.pumpWidget(
ProviderScope(
overrides: [
meGroupsProvider.overrideWith(
(ref) => Stream.value(const MeGroups()),
),
meSyncProvider.overrideWithValue(restarted),
channelsProvider.overrideWith(FakeChannels.new),
meAssistantsProvider.overrideWith((ref) async {
directoryLoads++;
return [];
}),
],
child: MaterialApp(theme: AppTheme.light(), home: const MePage()),
),
);
await tester.pumpAndSettle();
final bar = tester.widget<FrostedAppBar>(find.byType(FrostedAppBar));
bar.nativeActions!
.firstWhere((action) => action.label == 'New chat')
.onPressed!();
await tester.pumpAndSettle();
await tester.tap(find.text(resume ? 'Resume' : 'Discard setup'));
await tester.pumpAndSettle();
expect(directoryLoads, 1);
expect(calls, hasLength(1));
expect(calls.single.method, 'presentChat');
final args = calls.single.arguments as Map;
expect(args['title'], 'New chat');
expect(args['initialValue'], resume ? 'Unfinished chat' : '');
expect(args['selectedAssistant'], resume ? 'agent' : isNull);
expect(args['fieldsLocked'], resume);
expect(args['error'], resume ? contains('Resume') : isNull);
expect(
MeChatCreation.pending(restarted)?.id,
resume ? 'same-id' : isNull,
);
expect(args['placeholder'], 'Chat name');
expect(args['assistants'], isEmpty);
expect(args['containerCornerRadius'], Radii.container);
expect(find.byType(BottomSheet), findsNothing);
await tester.pumpWidget(const SizedBox.shrink());
debugDefaultTargetPlatformOverride = null;
});
}
testWidgets('unavailable preferences show retry instead of empty state', (
tester,
) async {
await tester.pumpWidget(
ProviderScope(
overrides: [
meGroupsProvider.overrideWith(
(ref) => Stream<MeGroups>.error(StateError('offline')),
),
channelsProvider.overrideWith(FakeChannels.new),
],
child: MaterialApp(theme: AppTheme.light(), home: const MePage()),
),
);
await tester.pumpAndSettle();
expect(find.text('Try again'), findsOneWidget);
expect(find.text('A place to work with your agents'), findsNothing);
});
}

View File

@@ -0,0 +1,284 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:buzz/features/channels/channel.dart';
import 'package:buzz/features/channels/channels_provider.dart';
import 'package:buzz/features/channels/me/me_page.dart';
import 'package:buzz/features/channels/me/me_unread_provider.dart';
import 'package:buzz/features/channels/unread_badge/observed_unread_event.dart';
import 'package:buzz/features/home/home_page.dart';
import 'package:buzz/shared/me/me_assistants.dart';
import 'package:buzz/shared/me/me_groups.dart';
import 'package:buzz/shared/me/me_sync.dart';
import 'package:buzz/shared/read_state/read_state_provider.dart';
import 'package:buzz/shared/theme/theme.dart';
Channel chat(String id, {bool member = true, bool archived = false}) => Channel(
id: id,
name: id,
channelType: 'stream',
visibility: 'private',
description: '',
createdBy: 'owner',
createdAt: DateTime(2026),
lastMessageAt: DateTime.fromMillisecondsSinceEpoch(20000),
memberCount: 2,
isMember: member,
archivedAt: archived ? DateTime(2026) : null,
);
class _Channels extends ChannelsNotifier {
var all = [
chat('solo'),
chat('ordinary'),
chat('archived', archived: true),
chat('nonmember', member: false),
];
int timestamp = 20;
@override
Future<List<Channel>> build() async => all;
@override
Map<String, int> get latestObservedByChannel => {
for (final c in all) c.id: timestamp,
};
@override
Map<String, Map<String, ObservedUnreadEvent>>
get observedUnreadEventsByChannel => {
for (final c in all)
c.id: {
'reply': ObservedUnreadEvent(
id: 'reply',
createdAt: timestamp,
rootId: 'root',
highPriority: false,
countsTowardBadge: true,
countsTowardAppBadge: false,
),
},
};
void reply() {
timestamp++;
all = [
for (final c in all)
c.copyWith(
lastMessageAt: DateTime.fromMillisecondsSinceEpoch(timestamp * 1000),
),
];
state = AsyncData([...all]);
}
}
class _Reads extends ReadStateNotifier {
final bool empty;
final bool ready;
_Reads({this.empty = false, this.ready = true});
@override
void seedContextRead(String key, int timestamp) => read(key, timestamp);
@override
ReadStateState build() => ReadStateState(
isReady: ready,
pubkey: 'owner',
contexts: empty ? {} : {'solo': 10},
version: 0,
);
void finishLoading() => state = ReadStateState(
isReady: true,
pubkey: state.pubkey,
contexts: state.contexts,
version: state.version + 1,
);
void resetIdentity() => state = const ReadStateState(
isReady: true,
pubkey: 'other-owner',
contexts: {},
version: 0,
);
void read(String key, int timestamp) =>
state = state.copyWithContext(key, timestamp);
void force() => state = ReadStateState(
isReady: true,
pubkey: 'owner',
contexts: state.contexts,
version: state.version + 1,
forcedUnreadContexts: const {'msg:reply': 'solo'},
);
}
const organization = MeGroups(
groups: [
{'id': 'project', 'name': 'Project', 'defaultTemplateId': ''},
],
assignments: {'solo': 'project'},
channels: ['solo', 'archived', 'nonmember'],
);
void main() {
for (final duringSeed in [false, true]) {
test(
'fresh Solo history is gated; new reply during seeding=$duringSeed badges',
() async {
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
final channels = _Channels();
final reads = _Reads(empty: true);
final container = ProviderContainer(
overrides: [
savedPrefsProvider.overrideWithValue(prefs),
channelsProvider.overrideWith(() => channels),
readStateProvider.overrideWith(() => reads),
meGroupsProvider.overrideWith(
(ref) =>
Stream.value(const MeGroups(channels: ['solo', 'ordinary'])),
),
],
);
addTearDown(container.dispose);
container.listen(channelsProvider, (_, _) {});
container.listen(meGroupsProvider, (_, _) {});
await container.read(channelsProvider.future);
await container.read(meGroupsProvider.future);
final observed = <Set<String>>[];
container.listen(
meUnreadChannelIdsProvider,
(_, next) => observed.add(next),
fireImmediately: true,
);
expect(container.read(meUnreadChannelIdsProvider), isEmpty);
if (duringSeed) {
channels.reply();
expect(container.read(meUnreadChannelIdsProvider), isEmpty);
}
await container.pump();
expect(
container.read(readStateProvider).effectiveTimestamp('solo'),
20,
);
if (!duringSeed) {
expect(observed.every((ids) => ids.isEmpty), isTrue);
channels.reply();
}
await container.pump();
expect(container.read(meUnreadChannelIdsProvider), {
'solo',
'ordinary',
});
expect(
container.read(readStateProvider).effectiveTimestamp('solo'),
20,
);
reads.resetIdentity();
expect(container.read(meUnreadChannelIdsProvider), isEmpty);
await container.pump();
expect(
container.read(readStateProvider).effectiveTimestamp('solo'),
21,
);
expect(container.read(meUnreadChannelIdsProvider), isEmpty);
channels.reply();
await container.pump();
expect(container.read(meUnreadChannelIdsProvider), {
'solo',
'ordinary',
});
},
);
}
for (final loading in [false, true]) {
testWidgets(
'Solo retains row, section, and tab unread after loading=$loading',
(tester) async {
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
final channels = _Channels();
final reads = _Reads(empty: loading, ready: !loading);
await tester.pumpWidget(
ProviderScope(
overrides: [
savedPrefsProvider.overrideWithValue(prefs),
channelsProvider.overrideWith(() => channels),
readStateProvider.overrideWith(() => reads),
meGroupsProvider.overrideWith(
(ref) => Stream.value(organization),
),
meAssistantsProvider.overrideWith((ref) async => []),
],
child: MaterialApp(
theme: AppTheme.light(),
home: Consumer(
builder: (context, ref, _) => HomePage(
settingsPageBuilder: (_) => const SizedBox(),
mePageBuilder: (_) => const MePage(),
hasUnreadInbox: false,
hasUnreadSolo: ref
.watch(meUnreadChannelIdsProvider)
.isNotEmpty,
),
),
),
),
);
await tester.pumpAndSettle();
final container = ProviderScope.containerOf(
tester.element(find.byType(HomePage)),
);
if (loading) {
expect(container.read(meUnreadChannelIdsProvider), isEmpty);
expect(find.bySemanticsLabel('Solo, unread'), findsNothing);
expect(
container.read(readStateProvider).effectiveTimestamp('solo'),
isNull,
);
channels.reply();
await tester.pumpAndSettle();
expect(container.read(meUnreadChannelIdsProvider), isEmpty);
expect(
container.read(readStateProvider).effectiveTimestamp('solo'),
isNull,
);
reads.finishLoading();
await tester.pumpAndSettle();
expect(
container.read(readStateProvider).effectiveTimestamp('solo'),
20,
);
}
expect(container.read(meUnreadChannelIdsProvider), {'solo'});
expect(find.bySemanticsLabel('Solo, unread'), findsOneWidget);
expect(find.text('solo'), findsNothing);
await tester.tap(find.byTooltip('Solo'));
await tester.pumpAndSettle();
expect(
tester.widget<Text>(find.text('solo')).style?.fontWeight,
FontWeight.w700,
);
expect(find.bySemanticsLabel('solo, unread'), findsOneWidget);
await tester.tap(find.text('Project'));
await tester.pumpAndSettle();
expect(find.text('solo'), findsNothing);
expect(
find.byKey(const ValueKey('solo-section-unread-Project')),
findsOneWidget,
);
reads.read('msg:reply', channels.timestamp);
await tester.pumpAndSettle();
expect(
find.byKey(const ValueKey('solo-section-unread-Project')),
findsNothing,
);
await tester.tap(find.byTooltip('Home'));
await tester.pumpAndSettle();
expect(find.bySemanticsLabel('Solo, unread'), findsNothing);
channels.reply();
await tester.pumpAndSettle();
expect(find.bySemanticsLabel('Solo, unread'), findsOneWidget);
reads.read('thread:root', channels.timestamp);
await tester.pumpAndSettle();
expect(find.bySemanticsLabel('Solo, unread'), findsNothing);
reads.force();
await tester.pumpAndSettle();
expect(find.bySemanticsLabel('Solo, unread'), findsOneWidget);
},
);
}
}

View File

@@ -0,0 +1,183 @@
import 'dart:async';
import 'package:buzz/features/channels/channel.dart';
import 'package:buzz/features/channels/channels_provider.dart';
import 'package:buzz/features/channels/me/me_page.dart';
import 'package:buzz/shared/crypto/nip44.dart';
import 'package:buzz/shared/me/me_assistants.dart';
import 'package:buzz/shared/me/me_groups.dart';
import 'package:buzz/shared/me/me_sync.dart';
import 'package:buzz/shared/relay/relay.dart';
import 'package:buzz/shared/theme/theme.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:nostr/nostr.dart' as nostr;
import 'package:shared_preferences/shared_preferences.dart';
import 'package:shared_preferences_platform_interface/shared_preferences_platform_interface.dart';
import '../../../shared/me/me_sync_test.dart' show FakeRelay;
const _id = '11111111-1111-4111-8111-111111111111';
class _Channels extends ChannelsNotifier {
@override
Future<List<Channel>> build() async => [
Channel(
id: _id,
name: 'Saved chat',
channelType: 'stream',
visibility: 'private',
description: '',
createdBy: 'owner',
createdAt: DateTime(2026),
memberCount: 2,
isMember: true,
),
];
}
class _Store extends InMemorySharedPreferencesStore {
bool fail = false;
Completer<bool>? blocked;
_Store() : super.empty();
@override
Future<bool> setValue(String type, String key, Object value) async {
if (key.startsWith('flutter.me-cache:')) {
if (fail) return false;
if (blocked != null && !await blocked!.future) return false;
}
return super.setValue(type, key, value);
}
}
class _Navigation extends NavigatorObserver {
int pushes = 0;
@override
void didPush(Route<dynamic> route, Route<dynamic>? previousRoute) {
pushes++;
}
}
void main() {
testWidgets(
'offline-save warning keeps Solo rows usable until retry succeeds',
(tester) async {
SharedPreferences.setMockInitialValues({});
final store = _Store();
SharedPreferencesStorePlatform.instance = store;
final prefs = await SharedPreferences.getInstance();
final relay = FakeRelay();
const secret =
'0000000000000000000000000000000000000000000000000000000000000001';
final service = MeSync(
relay,
RelayConfig(
baseUrl: 'https://relay.test',
nsec: nostr.Nip19.encode(
prefix: nostr.Nip19Prefix.nsec,
data: secret,
),
),
prefs: prefs,
);
NostrEvent head(int time) => NostrEvent.fromJson(
nostr.Event.from(
kind: 30078,
createdAt: time,
secretKey: secret,
content: nip44Encrypt(
service.key,
const MeGroups(channels: [_id]).encode(service.community),
),
tags: [
['d', MeGroups.coordinate(service.community)],
['t', MeGroups.tag],
],
).toMap(),
);
final old = head(1), fresh = head(2);
await service.cache(old);
relay.events = [fresh];
store.fail = true;
final navigation = _Navigation();
final navigator = GlobalKey<NavigatorState>();
await tester.pumpWidget(
ProviderScope(
overrides: [
savedPrefsProvider.overrideWithValue(prefs),
meSyncProvider.overrideWithValue(service),
meAssistantsProvider.overrideWith((ref) async => []),
channelsProvider.overrideWith(_Channels.new),
],
child: MaterialApp(
theme: AppTheme.light(),
navigatorKey: navigator,
navigatorObservers: [navigation],
home: const MePage(),
),
),
);
await tester.pumpAndSettle();
expect(find.text('Saved chat'), findsOneWidget);
expect(
find.text(
'Your chats are available, but couldn’t be saved for offline use.',
),
findsOneWidget,
);
expect(find.text('Couldn’t load your chats and projects.'), findsNothing);
expect(service.cachedSnapshot()?.event?.id, old.id);
await tester.tap(find.text('Saved chat'));
await tester.pump();
expect(navigation.pushes, 2);
navigator.currentState!.pop();
await tester.pumpAndSettle();
store.fail = false;
store.blocked = Completer<bool>();
await tester.tap(find.text('Retry offline save'));
await tester.pumpAndSettle();
expect(find.text('Saved chat'), findsOneWidget);
expect(find.text('Retry offline save'), findsOneWidget);
store.blocked!.complete(true);
await tester.pumpAndSettle();
expect(find.text('Retry offline save'), findsNothing);
expect(find.text('Saved chat'), findsOneWidget);
expect(service.cachedSnapshot()?.event?.id, fresh.id);
final invalid = NostrEvent.fromJson({
...head(3).toJson(),
'sig': '0' * 128,
});
relay.emit!(invalid);
await tester.pumpAndSettle();
expect(find.text('Saved chat'), findsOneWidget);
expect(find.text('Retry live updates'), findsOneWidget);
expect(service.cachedSnapshot()?.event?.id, fresh.id);
relay.events = [invalid];
await tester.tap(find.text('Retry live updates'));
await tester.pumpAndSettle();
expect(find.text('Retry live updates'), findsOneWidget);
expect(find.text('Saved chat'), findsOneWidget);
relay.events = [fresh];
await tester.tap(find.text('Retry live updates'));
await tester.pumpAndSettle();
expect(find.text('Retry live updates'), findsNothing);
relay.closeSubscription!('terminal CLOSED');
await tester.pumpAndSettle();
expect(find.text('Saved chat'), findsOneWidget);
expect(find.text('Retry live updates'), findsOneWidget);
final pendingRefresh = Completer<List<NostrEvent>>();
relay.pendingRead = pendingRefresh.future;
await tester.tap(find.text('Retry live updates'));
await tester.pumpAndSettle();
expect(find.text('Saved chat'), findsOneWidget);
expect(find.text('Retry live updates'), findsOneWidget);
pendingRefresh.complete([fresh]);
await tester.pumpAndSettle();
expect(find.text('Retry live updates'), findsNothing);
relay.closeSubscription!('closed again');
await tester.pumpAndSettle();
expect(find.text('Retry live updates'), findsOneWidget);
},
);
}

View File

@@ -0,0 +1,292 @@
import 'package:buzz/features/channels/channel.dart';
import 'package:buzz/features/channels/channels_provider.dart';
import 'package:buzz/features/channels/me/me_page.dart';
import 'package:buzz/shared/me/me_assistants.dart';
import 'package:buzz/shared/me/me_groups.dart';
import 'package:buzz/shared/me/me_project_creation.dart';
import 'package:buzz/shared/me/me_sync.dart';
import 'package:buzz/shared/relay/relay.dart';
import 'package:buzz/shared/theme/theme.dart';
import 'package:buzz/shared/widgets/frosted_app_bar.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:nostr/nostr.dart' as nostr;
import 'package:shared_preferences/shared_preferences.dart';
import 'package:shared_preferences_platform_interface/shared_preferences_platform_interface.dart';
class _FailingStore extends InMemorySharedPreferencesStore {
final bool throws;
_FailingStore(this.throws) : super.empty();
@override
Future<bool> setValue(String type, String key, Object value) async {
if (throws) throw StateError('disk failure');
return false;
}
}
class _Channels extends ChannelsNotifier {
@override
Future<List<Channel>> build() async => [];
}
class _Relay extends RelaySessionNotifier {
List<NostrEvent> events = [];
VoidCallback? beforeRead;
bool failRead = false;
bool failAfterPublish = false;
int writes = 0;
@override
Future<List<NostrEvent>> fetchHistory(
NostrFilter filter, {
Duration timeout = const Duration(seconds: 8),
}) async {
beforeRead?.call();
if (failRead) throw StateError('interrupted before the first head');
return events;
}
@override
Future<NostrEvent> publish(
NostrEvent event, {
Duration timeout = const Duration(seconds: 8),
}) async {
events = [event];
writes++;
if (failAfterPublish) throw StateError('lost acknowledgement');
return event;
}
}
void main() {
final config = RelayConfig(
baseUrl: 'https://relay.test',
nsec: nostr.Nip19.encode(
prefix: nostr.Nip19Prefix.nsec,
data: '0' * 63 + '1',
),
);
for (final throws in [false, true]) {
testWidgets(
'failed project write ($throws) reopens the entered name without claiming recovery',
(tester) async {
SharedPreferences.setMockInitialValues({});
SharedPreferencesStorePlatform.instance = _FailingStore(throws);
final prefs = await SharedPreferences.getInstance();
final relay = _Relay();
final sync = MeSync(relay, config, prefs: prefs);
const channel = MethodChannel('buzz/profile_text_editor');
var forms = 0;
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
channel,
(call) async {
forms++;
if (forms == 1) return 'My entered name';
expectSync(
(call.arguments as Map)['initialValue'],
'My entered name',
);
return null;
},
);
addTearDown(
() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
channel,
null,
),
);
await tester.pumpWidget(
ProviderScope(
overrides: [
meSyncProvider.overrideWithValue(sync),
meGroupsProvider.overrideWith(
(ref) => Stream.value(const MeGroups()),
),
meAssistantsProvider.overrideWith((ref) async => []),
channelsProvider.overrideWith(_Channels.new),
],
child: MaterialApp(theme: AppTheme.light(), home: const MePage()),
),
);
await tester.pumpAndSettle();
tester
.widget<FrostedAppBar>(find.byType(FrostedAppBar))
.nativeActions!
.firstWhere((a) => a.label == 'New project')
.onPressed!();
await tester.pumpAndSettle();
expect(find.text('Project was not saved'), findsOneWidget);
expect(find.textContaining('Project setup saved'), findsNothing);
expect(MeProjectCreation.pending(sync), isNull);
expect(relay.writes, 0);
await tester.tap(find.text('Try again'));
await tester.pumpAndSettle();
expect(forms, 2);
},
variant: TargetPlatformVariant.only(TargetPlatform.iOS),
);
}
for (final recovery in ['Resume', 'Discard setup']) {
for (final offline in [true, false]) {
testWidgets(
'accepted project survives ${offline ? "offline" : "interrupted head"} restart with $recovery',
(tester) async {
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
final relay = _Relay()..failRead = !offline;
var sync = MeSync(relay, config, prefs: prefs, connected: !offline);
var forms = 0;
relay.beforeRead = () => expectSync(
MeProjectCreation.pending(sync)?.name,
forms == 1 ? 'Private project' : 'Replacement project',
);
const channel = MethodChannel('buzz/profile_text_editor');
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
channel,
(call) async {
expectSync(call.method, 'presentProject');
forms++;
return forms == 1 ? 'Private project' : 'Replacement project';
},
);
addTearDown(
() => tester.binding.defaultBinaryMessenger
.setMockMethodCallHandler(channel, null),
);
Future<void> mount() async {
await tester.pumpWidget(
ProviderScope(
key: UniqueKey(),
overrides: [
meSyncProvider.overrideWithValue(sync),
meGroupsProvider.overrideWith(
(ref) => Stream.value(const MeGroups()),
),
meAssistantsProvider.overrideWith((ref) async => []),
channelsProvider.overrideWith(_Channels.new),
],
child: MaterialApp(
theme: AppTheme.light(),
home: const MePage(),
),
),
);
await tester.pumpAndSettle();
}
Future<void> open() async {
tester
.widget<FrostedAppBar>(find.byType(FrostedAppBar))
.nativeActions!
.firstWhere((a) => a.label == 'New project')
.onPressed!();
await tester.pumpAndSettle();
}
await mount();
await open();
final saved = MeProjectCreation.pending(sync);
expect(
saved,
isNotNull,
reason: 'Accepted project intent must be durable before networking',
);
final pending = saved!;
expect(pending.name, 'Private project');
expect(
prefs.getString(MeProjectCreation.storageKey(sync)),
isNot(contains('Private project')),
);
expect(relay.writes, 0);
sync = MeSync(relay, config, prefs: prefs);
relay.failRead = false;
await mount();
await open();
expect(find.textContaining('Private project'), findsOneWidget);
await tester.tap(find.text('Later'));
await tester.pumpAndSettle();
expect(MeProjectCreation.pending(sync)?.id, pending.id);
expect(relay.writes, 0);
await open();
await tester.tap(find.text(recovery));
await tester.pumpAndSettle();
expect(forms, recovery == 'Resume' ? 1 : 2);
expect(
MeProjectCreation.pending(sync),
isNull,
reason: tester
.widgetList<Text>(find.byType(Text))
.map((t) => t.data)
.join(' | '),
);
expect(relay.writes, 1);
expect(
sync.decode(relay.events.single).groups.single['id'],
recovery == 'Resume' ? pending.id : isNot(pending.id),
);
expect(
sync.decode(relay.events.single).groups.single['name'],
recovery == 'Resume' ? pending.name : 'Replacement project',
);
},
variant: TargetPlatformVariant.only(TargetPlatform.iOS),
);
}
}
test(
'scoped offline discard compares the reviewed intent and allows a different project',
() async {
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
final sync = MeSync(_Relay(), config, prefs: prefs, connected: false);
const original = MeProjectCreation('project', 'Private project');
await original.save(sync);
for (final other in [
RelayConfig(baseUrl: 'https://other.test', nsec: config.nsec),
RelayConfig(
baseUrl: config.baseUrl,
nsec: nostr.Nip19.encode(
prefix: nostr.Nip19Prefix.nsec,
data: '0' * 63 + '2',
),
),
]) {
expect(
MeProjectCreation.pending(MeSync(_Relay(), other, prefs: prefs)),
isNull,
);
}
await expectLater(
const MeProjectCreation('different', 'Private project').discard(sync),
throwsStateError,
);
expect(MeProjectCreation.pending(sync)?.id, original.id);
await original.discard(sync);
await const MeProjectCreation('new', 'Different project').save(sync);
await expectLater(original.discard(sync), throwsStateError);
expect(MeProjectCreation.pending(sync)?.id, 'new');
},
);
test(
'lost publication acknowledgement retries the same ID without duplicating it',
() async {
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
final relay = _Relay()..failAfterPublish = true;
final sync = MeSync(relay, config, prefs: prefs);
const intent = MeProjectCreation('project', 'Name');
await expectLater(intent.complete(sync), throwsStateError);
final restarted = MeSync(relay, config, prefs: prefs);
relay.failAfterPublish = false;
await expectLater(
MeProjectCreation.pending(restarted)!.complete(restarted),
throwsStateError,
);
await MeProjectCreation.pending(restarted)!.complete(restarted);
expect(MeProjectCreation.pending(restarted), isNull);
expect(relay.writes, 1);
expect(restarted.decode(relay.events.single).groups, hasLength(1));
},
);
}

View File

@@ -811,6 +811,24 @@ void main() {
});
group('buildMainTimelineEntries', () {
test(
'Me keeps agent replies inline without duplicate thread summaries',
() {
final messages = formatTimeline([
_textMsg(id: 'root', createdAt: 1000),
_replyMsg(id: 'answer', parentId: 'root', createdAt: 2000),
_textMsg(id: 'followup', createdAt: 3000),
]);
final entries = buildMainTimelineEntries(messages, flat: true);
expect(entries.map((e) => e.message.id), [
'root',
'answer',
'followup',
]);
expect(entries.every((e) => e.summary == null), isTrue);
},
);
test('returns only root messages', () {
final messages = formatTimeline([
_textMsg(id: threadIdA, createdAt: 1000),

View File

@@ -1,6 +1,6 @@
import 'package:buzz/shared/widgets/frosted_app_bar.dart';
import 'package:buzz/features/home/home_page.dart';
import 'package:buzz/features/search/search_page.dart';
import 'package:buzz/features/channels/channels_page.dart';
import 'package:buzz/features/profile/profile_avatar.dart';
import 'package:buzz/shared/theme/theme.dart';
import 'package:flutter/foundation.dart';
@@ -30,6 +30,7 @@ void main() {
child: child!,
),
home: HomePage(
mePageBuilder: (_) => const Center(child: Text('Personal workspace')),
settingsPageBuilder: _buildSettingsPage,
hasUnreadInbox: unreadInboxCount > 0,
),
@@ -131,71 +132,87 @@ void main() {
findsOneWidget,
);
expect(find.bySemanticsLabel('Activity, unread'), findsNothing);
final semantics = tester.ensureSemantics();
await tester.tap(find.byTooltip('Solo'));
await tester.pumpAndSettle();
expect(find.text('Personal workspace'), findsOneWidget);
expect(find.byType(SearchPage), findsNothing);
expect(
tester
.getSemantics(find.bySemanticsLabel('Search'))
.flagsCollection
.isSelected
.toString(),
'Tristate.isFalse',
);
await tester.tap(find.byTooltip('Search'));
await tester.pump(const Duration(milliseconds: 250));
await tester.pump();
await tester.tap(find.byTooltip('Home'));
await tester.pumpAndSettle();
tester
.widget<FrostedAppBar>(find.byType(FrostedAppBar))
.nativeActions!
.firstWhere((action) => action.label == 'Search')
.onPressed!();
await tester.pumpAndSettle();
expect(find.byType(SearchPage), findsOneWidget);
final searchRoute = ModalRoute.of(tester.element(find.byType(SearchPage)))!;
expect(searchRoute.allowSnapshotting, isFalse);
expect(searchRoute.opaque, isTrue);
expect(searchRoute.transitionDuration, const Duration(milliseconds: 500));
expect(
tester
.getSemantics(find.bySemanticsLabel('Search'))
.flagsCollection
.isSelected
.toString(),
'Tristate.isTrue',
searchRoute.reverseTransitionDuration,
const Duration(milliseconds: 500),
);
semantics.dispose();
Navigator.of(tester.element(find.byType(SearchPage))).pop();
await tester.pumpAndSettle();
expect(find.byType(SearchPage), findsNothing);
await tester.pumpWidget(const SizedBox.shrink());
await tester.pump();
debugDefaultTargetPlatformOverride = null;
});
testWidgets('shows icon-only navigation and an aligned quick action', (
for (final reducedMotion in [false, true]) {
testWidgets('search slides vertically, reduced motion: $reducedMotion', (
tester,
) async {
await tester.pumpWidget(
await buildHome(disableAnimations: reducedMotion),
);
await tester.pumpAndSettle();
await tester.tap(find.byTooltip('Search'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
final search = find.byType(SearchPage);
expect(search, findsOneWidget);
expect(tester.getTopLeft(search).dx, 0);
expect(tester.getTopLeft(search).dy, reducedMotion ? 0 : greaterThan(0));
await tester.pumpAndSettle();
expect(tester.getTopLeft(search).dy, 0);
expect(ModalRoute.of(tester.element(search))!.opaque, isFalse);
Navigator.of(tester.element(search)).pop();
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
if (reducedMotion) {
expect(search, findsNothing);
} else {
expect(tester.getTopLeft(search).dy, greaterThan(0));
}
await tester.pumpAndSettle();
expect(search, findsNothing);
});
}
testWidgets('keeps creation beside the tabs and search beside the avatar', (
tester,
) async {
await tester.pumpWidget(await buildHome());
await tester.pump();
expect(find.byKey(const ValueKey('home-ios-glass-tabs')), findsNothing);
expect(find.text('Home'), findsNothing);
expect(find.text('Activity'), findsNothing);
expect(find.text('Search'), findsNothing);
expect(find.bySemanticsLabel('Home'), findsOneWidget);
expect(find.bySemanticsLabel('Activity'), findsOneWidget);
expect(find.bySemanticsLabel('Search'), findsOneWidget);
final quickAction = find.byTooltip('Create or start conversation');
expect(quickAction, findsOneWidget);
final launcherSize = tester.getSize(
find.byType(ChannelQuickActionsLauncher),
);
expect(launcherSize.width, 800);
expect(launcherSize.height, greaterThan(0));
final motionRect = tester.getRect(
find.byKey(const Key('channel-quick-actions-motion')),
);
expect(motionRect.width, const Size.square(56).width);
expect(motionRect.left, greaterThanOrEqualTo(0));
expect(tester.getSize(quickAction), const Size.square(56));
final quickActionRect = tester.getRect(quickAction);
expect(quickActionRect.left, greaterThanOrEqualTo(0));
expect(quickActionRect.top, greaterThanOrEqualTo(0));
expect(quickActionRect.right, lessThanOrEqualTo(800));
expect(quickActionRect.bottom, lessThanOrEqualTo(600));
final homeDestinationRect = tester.getRect(find.bySemanticsLabel('Home'));
final plus = find.byTooltip('Create or start conversation');
final search = find.byTooltip('Search');
final avatar = find.byType(ProfileAvatar);
expect(plus, findsOneWidget);
expect(search, findsOneWidget);
expect(tester.getCenter(search).dy, lessThan(tester.getCenter(plus).dy));
expect(tester.getCenter(search).dy, tester.getCenter(avatar).dy);
expect(
quickActionRect.center.dy,
closeTo(homeDestinationRect.center.dy, 0.01),
tester.getCenter(plus).dy,
closeTo(tester.getCenter(find.byTooltip('Home')).dy, 0.01),
);
await tester.tap(plus);
await tester.pumpAndSettle();
expect(find.text('Create channel'), findsOneWidget);
expect(find.text('New direct message'), findsOneWidget);
expect(find.text('Browse channels'), findsOneWidget);
});
testWidgets('keeps the Buzz backdrop behind the scalable Home screen', (
@@ -354,7 +371,7 @@ void main() {
await tester.pump();
expect(hapticCalls, hasLength(1));
await tester.tap(find.byTooltip('Search'));
await tester.tap(find.byTooltip('Solo'));
await tester.pump();
expect(hapticCalls, hasLength(2));
});
@@ -518,7 +535,6 @@ void main() {
expect(bodyOpacity.opacity, closeTo(1, 0.001));
expect(appBarOpacity.opacity, closeTo(1, 0.001));
});
testWidgets('scales and fades the quick action as tabs change', (
tester,
) async {

View File

@@ -12,20 +12,26 @@ import 'package:buzz/features/search/search_page.dart';
import 'package:buzz/features/search/search_provider.dart';
import 'package:buzz/shared/theme/theme.dart';
import 'package:buzz/shared/mentions/agent_identity_provider.dart';
import 'package:buzz/shared/widgets/frosted_app_bar.dart';
import 'package:buzz/shared/widgets/concentric_sheet_surface.dart';
import 'package:buzz/shared/widgets/ios_glass_navigation_button.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_test/flutter_test.dart';
import '../../helpers/widget_helpers.dart';
void main() {
for (final reducedMotion in [false, true]) {
testWidgets(
'iOS search field rises above tappable chips, reduced motion=$reducedMotion',
(tester) async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
for (final platform in [TargetPlatform.android, TargetPlatform.iOS]) {
for (final scale in [1.0, 2.0]) {
testWidgets('search controls stay fixed on $platform at scale $scale', (
tester,
) async {
debugDefaultTargetPlatformOverride = platform;
addTearDown(() => debugDefaultTargetPlatformOverride = null);
tester.view.physicalSize = const Size(390, 844);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await tester.pumpWidget(
WidgetHelpers.testable(
overrides: [
@@ -39,8 +45,7 @@ void main() {
],
child: MediaQuery(
data: MediaQueryData(
disableAnimations: reducedMotion,
textScaler: TextScaler.linear(reducedMotion ? 2 : 1),
textScaler: TextScaler.linear(scale),
padding: const EdgeInsets.only(top: 59),
),
child: const SearchPage(),
@@ -49,206 +54,135 @@ void main() {
);
await tester.pumpAndSettle();
final field = find.byKey(const Key('search-field-container'));
final idle = tester.getRect(field);
final input = tester.element(find.byType(TextField));
await tester.tap(find.byKey(const Key('search-field')));
await tester.pump();
await tester.pump(const Duration(milliseconds: 80));
final middle = tester.getRect(field);
await tester.pumpAndSettle();
final active = tester.getRect(field);
final cancel = find.byKey(const Key('search-ios-cancel'));
final cancelRect = tester.getRect(cancel);
expect(cancel.hitTestable(), findsOneWidget);
expect(cancelRect.left - active.right, closeTo(Grid.xxs, 0.01));
expect(
tester.getRect(find.byType(FrostedAppBar)).right - cancelRect.right,
closeTo(Grid.gutter, 0.01),
);
expect(active.top, lessThan(idle.top));
expect(active.width, lessThan(idle.width));
expect(active.top, greaterThanOrEqualTo(59));
if (!reducedMotion) {
expect(middle.top, greaterThan(active.top));
expect(middle.top, lessThan(idle.top));
final filters = find.byKey(const Key('search-header-filters'));
final close = find.byKey(const Key('search-close'));
final fieldRect = tester.getRect(field);
final filtersRect = tester.getRect(filters);
expect(find.text('Search'), findsNothing);
expect(find.text('Cancel'), findsNothing);
expect(fieldRect.top, greaterThanOrEqualTo(59));
expect(tester.getRect(close).left, greaterThan(fieldRect.right));
expect(tester.getSize(close), const Size(48, 48));
for (final label in ['All', 'Messages', 'Channels', 'People']) {
expect(
platform == TargetPlatform.iOS
? find.byTooltip(label)
: find.text(label).hitTestable(),
findsOneWidget,
);
}
final input = tester.element(find.byType(TextField));
await tester.tap(find.byType(TextField));
await tester.pumpAndSettle();
expect(tester.getRect(field), fieldRect);
expect(tester.getRect(filters), filtersRect);
expect(tester.element(find.byType(TextField)), same(input));
expect(
tester.widget<TextField>(find.byType(TextField)).focusNode!.hasFocus,
isTrue,
);
final filters = tester.getRect(
find.byKey(const Key('search-header-filters')),
);
expect(filters.top - active.bottom, closeTo(Grid.xxs, 0.01));
expect(
tester.getRect(find.byType(FrostedAppBar)).bottom,
greaterThanOrEqualTo(filters.bottom),
);
for (final label in ['All', 'Messages', 'Channels', 'People']) {
expect(find.text(label).hitTestable(), findsOneWidget);
}
await tester.enterText(find.byType(TextField), 'design');
await tester.tap(cancel);
if (platform == TargetPlatform.iOS) {
await _pressNativeFilter(tester, 'Channels');
} else {
await tester.tap(find.text('Channels'));
}
await tester.pumpAndSettle();
expect(tester.getRect(field).top, closeTo(idle.top, 0.01));
await tester.pumpWidget(const SizedBox());
expect(tester.getRect(field), fieldRect);
expect(tester.getRect(filters), filtersRect);
expect(
tester
.widget<ConcentricSheetSurface>(
find.byType(ConcentricSheetSurface),
)
.usesGlass,
isTrue,
);
expect(
find.byType(IosGlassNavigationButton),
platform == TargetPlatform.iOS ? findsNWidgets(5) : findsNothing,
);
expect(tester.takeException(), isNull);
await tester.pumpWidget(const SizedBox.shrink());
debugDefaultTargetPlatformOverride = null;
},
);
});
}
}
testWidgets('iOS recent rows move once through title collapse', (
testWidgets('iOS native filter press selects Channels and filters results', (
tester,
) async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
addTearDown(() => debugDefaultTargetPlatformOverride = null);
final state = SearchState(
query: 'design',
channelResults: [
Channel(
id: 'design',
name: 'design',
channelType: 'stream',
visibility: 'open',
description: 'Design discussion',
memberCount: 4,
createdBy: 'test',
createdAt: DateTime(2025),
isMember: true,
),
],
userResults: const [DirectoryUser(pubkey: 'maya', displayName: 'Maya')],
);
await tester.pumpWidget(
WidgetHelpers.testable(
overrides: [
searchProvider.overrideWith(
() => _FakeSearchNotifier(const SearchState.initial()),
),
searchProvider.overrideWith(() => _FakeSearchNotifier(state)),
recentSearchesProvider.overrideWith(
() => _FakeRecentSearchesNotifier(
List.generate(30, (i) => 'Recent query $i'),
),
() => _FakeRecentSearchesNotifier(const []),
),
profileProvider.overrideWith(() => _FakeProfileNotifier()),
channelsProvider.overrideWith(() => _FakeChannelsNotifier()),
],
child: const SearchPage(),
),
);
await tester.pumpAndSettle();
final row = find.text('Recent query 1');
final list = find.byKey(const Key('recent-searches-list'));
final gesture = await tester.startGesture(tester.getCenter(list));
await gesture.moveBy(const Offset(0, -20));
await tester.pump();
for (var i = 0; i < 5; i++) {
final before = tester.getTopLeft(row).dy;
await gesture.moveBy(const Offset(0, -20));
await tester.pump();
expect(before - tester.getTopLeft(row).dy, closeTo(20, 1));
}
await gesture.up();
await tester.pumpWidget(const SizedBox());
final channel = find.byKey(const ValueKey('search-channel-row-design'));
final person = find.byKey(const ValueKey('search-person-row-maya'));
expect(channel, findsOneWidget);
expect(person, findsOneWidget);
expect(
tester.widget<IosGlassNavigationButton>(_nativeFilter('All')).isSelected,
isTrue,
);
expect(
tester
.widget<IosGlassNavigationButton>(_nativeFilter('Channels'))
.isSelected,
isFalse,
);
await _pressNativeFilter(tester, 'Channels');
await tester.pumpAndSettle();
expect(
tester
.widget<IosGlassNavigationButton>(_nativeFilter('Channels'))
.isSelected,
isTrue,
);
expect(
tester.widget<IosGlassNavigationButton>(_nativeFilter('All')).isSelected,
isFalse,
);
expect(channel, findsOneWidget);
expect(person, findsNothing);
expect(tester.takeException(), isNull);
await tester.pumpWidget(const SizedBox.shrink());
debugDefaultTargetPlatformOverride = null;
});
testWidgets('reselecting Search uses the field activation path', (
testWidgets('X dismisses search while the keyboard is focused', (
tester,
) async {
final tabReselection = ValueNotifier(0);
addTearDown(tabReselection.dispose);
await tester.pumpWidget(
WidgetHelpers.testable(
overrides: [
searchProvider.overrideWith(
() => _FakeSearchNotifier(const SearchState.initial()),
),
recentSearchesProvider.overrideWith(
() => _FakeRecentSearchesNotifier(const []),
),
profileProvider.overrideWith(() => _FakeProfileNotifier()),
],
child: SearchPage(tabReselection: tabReselection),
),
);
await tester.pumpAndSettle();
expect(find.byKey(const Key('search-cancel')), findsNothing);
tabReselection.value++;
await tester.pump();
await tester.pump();
expect(find.byKey(const Key('search-cancel')), findsOneWidget);
expect(
tester.widget<TextField>(find.byType(TextField)).focusNode?.hasFocus,
isTrue,
);
final focusNode = tester
.widget<TextField>(find.byType(TextField))
.focusNode!;
// Reproduce the real tab-tap ordering where the destination callback can
// run immediately before the same pointer gesture dismisses the field.
tabReselection.value++;
focusNode.unfocus();
await tester.pump();
await tester.pump();
expect(find.byKey(const Key('search-cancel')), findsOneWidget);
expect(focusNode.hasFocus, isTrue);
expect(
tester
.widget<AnimatedOpacity>(
find.byKey(const Key('search-header-title-opacity')),
)
.opacity,
0,
reason: 'The tab gesture must not paint a close-and-reopen flicker.',
);
});
testWidgets('uses the shared frosted navigation surface', (tester) async {
await tester.pumpWidget(
WidgetHelpers.testable(
overrides: [
searchProvider.overrideWith(
() => _FakeSearchNotifier(const SearchState.initial()),
),
recentSearchesProvider.overrideWith(
() => _FakeRecentSearchesNotifier(const []),
),
profileProvider.overrideWith(() => _FakeProfileNotifier()),
],
child: const SearchPage(),
),
);
await tester.pumpAndSettle();
final appBar = tester.widget<FrostedAppBar>(find.byType(FrostedAppBar));
expect(appBar.gradient, isNull);
expect(appBar.frosted, isTrue);
expect(appBar.showBottomDivider, isTrue);
expect(appBar.bottomDividerOpacity, 0.07);
expect(appBar.bottomHeight, 57);
expect(appBar.leading, isNull);
expect(appBar.centerTitle, isFalse);
expect(find.text('Search'), findsOneWidget);
final promptText = find.descendant(
of: find.byKey(const Key('search-field-container')),
matching: find.byType(Text),
);
expect(
tester.getRect(promptText).left,
closeTo(
tester.getRect(find.byKey(const Key('search-moving-icon'))).right +
Grid.xxs,
0.01,
),
);
expect(
tester.getRect(promptText).center.dy,
closeTo(
tester
.getRect(find.byKey(const Key('search-field-container')))
.center
.dy,
0.5,
),
);
});
testWidgets('empty state preserves large accessible text scaling', (
tester,
) async {
tester.view.physicalSize = const Size(320, 640);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(
WidgetHelpers.testable(
overrides: [
@@ -261,75 +195,31 @@ void main() {
profileProvider.overrideWith(() => _FakeProfileNotifier()),
],
child: Builder(
builder: (context) => MediaQuery(
data: MediaQuery.of(
builder: (context) => TextButton(
onPressed: () => Navigator.of(
context,
).copyWith(textScaler: const TextScaler.linear(2)),
child: const SearchPage(),
).push(MaterialPageRoute<void>(builder: (_) => const SearchPage())),
child: const Text('Open search'),
),
),
),
);
await tester.tap(find.text('Open search'));
await tester.pumpAndSettle();
final appBar = tester.widget<FrostedAppBar>(find.byType(FrostedAppBar));
final titleStyle = appBar.titleStyle!;
expect(titleStyle.fontSize, 22);
expect(
tester
.getSize(
find
.descendant(
of: find.byType(FrostedAppBar),
matching: find.byType(ClipRect),
)
.first,
)
.height,
closeTo(
frostedAppBarHeight(
tester.element(find.byType(FrostedAppBar)),
titleStyle: titleStyle,
bottomHeight: appBar.bottomHeight,
),
0.01,
),
);
final emptyState = find.byKey(const Key('search-empty-state'));
final searchField = find.byKey(const Key('search-field-container'));
final searchFieldContext = tester.element(searchField);
final bodyStyle = Theme.of(searchFieldContext).textTheme.bodyMedium!;
final scaledLineHeight =
MediaQuery.textScalerOf(searchFieldContext).scale(bodyStyle.fontSize!) *
bodyStyle.height!;
expect(
find.descendant(of: emptyState, matching: find.byType(FittedBox)),
findsNothing,
);
expect(
tester.getSize(searchField).height,
greaterThanOrEqualTo(scaledLineHeight + Grid.xxs * 2),
);
final prompt = tester.widget<Text>(
find.descendant(
of: find.byKey(const Key('search-field-container')),
matching: find.byType(Text),
),
);
expect(prompt.style?.fontSize, 15);
expect(prompt.maxLines, 1);
expect(prompt.overflow, TextOverflow.ellipsis);
expect(
tester
.getSize(find.descendant(of: emptyState, matching: find.byType(Text)))
.height,
greaterThan(32),
);
expect(tester.takeException(), isNull);
await tester.tap(find.byType(TextField));
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField), 'draft');
await tester.tap(find.byTooltip('Close search'));
await tester.pumpAndSettle();
expect(find.byType(SearchPage), findsNothing);
expect(find.text('Open search'), findsOneWidget);
});
testWidgets('search filters grow with accessible text', (tester) async {
testWidgets('reselecting Search restores input focus without moving tabs', (
tester,
) async {
final reselection = ValueNotifier(0);
addTearDown(reselection.dispose);
await tester.pumpWidget(
WidgetHelpers.testable(
overrides: [
@@ -341,197 +231,24 @@ void main() {
),
profileProvider.overrideWith(() => _FakeProfileNotifier()),
],
child: Builder(
builder: (context) => MediaQuery(
data: MediaQuery.of(
context,
).copyWith(textScaler: const TextScaler.linear(2)),
child: const SearchPage(),
),
),
child: SearchPage(tabReselection: reselection),
),
);
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('search-field')));
await tester.pumpAndSettle();
final filters = find.byKey(const Key('search-header-filters'));
final activeField = find.byKey(const Key('search-field-container'));
final cancel = find.byKey(const Key('search-cancel'));
expect(filters, findsOneWidget);
expect(cancel, findsOneWidget);
expect(
tester.getRect(activeField).right,
lessThanOrEqualTo(tester.getRect(cancel).left),
reason: 'Scaled Cancel must not overlap the active search field.',
);
expect(tester.getSize(filters).height, greaterThan(Grid.xl));
expect(
tester.getSize(filters).height,
greaterThanOrEqualTo(
tester.getSize(find.text('Messages')).height + Grid.xs * 2,
),
);
expect(tester.takeException(), isNull);
});
testWidgets('focus slides Cancel in beside the search field', (tester) async {
await tester.pumpWidget(
WidgetHelpers.testable(
overrides: [
searchProvider.overrideWith(
() => _FakeSearchNotifier(const SearchState.initial()),
),
recentSearchesProvider.overrideWith(
() => _FakeRecentSearchesNotifier(const []),
),
profileProvider.overrideWith(() => _FakeProfileNotifier()),
],
child: const SearchPage(),
),
);
await tester.pumpAndSettle();
final searchField = find.byKey(const Key('search-field'));
final editingField = find.byType(TextField);
final searchFieldContainer = find.byKey(
const Key('search-field-container'),
);
final unfocusedWidth = tester.getSize(searchFieldContainer).width;
final unfocusedTop = tester.getRect(searchFieldContainer).top;
expect(find.byKey(const Key('search-cancel')), findsNothing);
await tester.tap(searchField);
await tester.pump();
final cancel = find.byKey(const Key('search-cancel'));
expect(cancel, findsOneWidget);
expect(
tester.getSize(cancel).height,
greaterThanOrEqualTo(Grid.xl),
reason: 'Cancel must keep a 48dp touch target.',
);
final input = tester.widget<TextField>(editingField);
expect(input.decoration?.hintText, isNull);
expect(input.textInputAction, TextInputAction.search);
expect(
input.focusNode?.hasFocus,
isTrue,
reason: 'Tapping the idle search field opens the native keyboard.',
);
final enteringSlide = tester.widget<SlideTransition>(
find.ancestor(of: cancel, matching: find.byType(SlideTransition)).first,
);
expect(enteringSlide.position.value.dx, greaterThan(0));
await tester.pump(const Duration(milliseconds: 160));
final focusedWidth = tester.getSize(searchFieldContainer).width;
final focusedRect = tester.getRect(searchFieldContainer);
expect(focusedWidth, lessThan(unfocusedWidth));
expect(
focusedRect.top,
lessThan(unfocusedTop),
reason: 'The active field translates upward into the title row.',
);
expect(find.byKey(const Key('search-header-filters')), findsOneWidget);
final filtersRect = tester.getRect(
final filters = tester.getRect(
find.byKey(const Key('search-header-filters')),
);
expect(
filtersRect.top - focusedRect.bottom,
Grid.xxs,
reason: 'Filters keep one compact spacing token below the controls.',
);
final settledSlide = tester.widget<SlideTransition>(
find.ancestor(of: cancel, matching: find.byType(SlideTransition)).first,
);
expect(settledSlide.position.value, Offset.zero);
final movingIcon = find.byKey(const Key('search-moving-icon'));
final iconScale = tester.widget<AnimatedScale>(
find.ancestor(of: movingIcon, matching: find.byType(AnimatedScale)),
);
final movingField = tester.widget<AnimatedPositioned>(
find.ancestor(of: movingIcon, matching: find.byType(AnimatedPositioned)),
);
expect(iconScale.scale, lessThan(1));
expect(movingField.top, Grid.half);
final appBarRect = tester.getRect(find.byType(FrostedAppBar));
expect(
appBarRect.bottom - filtersRect.bottom,
closeTo(Grid.xxs + 1, 0.01),
reason:
'The filter row keeps the same spacing below it, plus the divider.',
);
expect(
appBarRect.contains(focusedRect.center),
isTrue,
reason: 'The translated field remains inside the app bar hit-test box.',
);
await tester.enterText(editingField, 'design');
await tester.tap(cancel);
await tester.pump();
final titleOpacity = tester.widget<AnimatedOpacity>(
find.byKey(const Key('search-header-title-opacity')),
);
expect(
titleOpacity.opacity,
1,
reason:
'The title fades beneath the returning field instead of appearing after it.',
);
await tester.pump(const Duration(milliseconds: 159));
expect(
tester
.widget<AnimatedOpacity>(
find.byKey(const Key('search-header-title-opacity')),
)
.opacity,
1,
);
reselection.value++;
await tester.pumpAndSettle();
expect(find.byKey(const Key('search-cancel')), findsNothing);
expect(
tester.getSize(searchFieldContainer).width,
closeTo(unfocusedWidth, 0.01),
);
});
testWidgets('keeps the search prompt calm until it is focused', (
tester,
) async {
await tester.pumpWidget(
WidgetHelpers.testable(
overrides: [
searchProvider.overrideWith(
() => _FakeSearchNotifier(const SearchState.initial()),
),
recentSearchesProvider.overrideWith(
() => _FakeRecentSearchesNotifier(const []),
),
profileProvider.overrideWith(() => _FakeProfileNotifier()),
],
child: const SearchPage(),
),
);
final focus = tester.widget<TextField>(find.byType(TextField)).focusNode!;
expect(focus.hasFocus, isTrue);
reselection.value++;
focus.unfocus();
await tester.pumpAndSettle();
final searchField = find.byKey(const Key('search-field'));
final searchFieldContainer = find.byKey(
const Key('search-field-container'),
);
expect(find.text('Messages'), findsNothing);
expect(tester.getSize(searchFieldContainer).height, greaterThan(36));
await tester.tap(searchField);
await tester.pumpAndSettle();
expect(find.text('Messages'), findsOneWidget);
expect(focus.hasFocus, isTrue);
expect(
tester.getSize(searchFieldContainer).height,
greaterThanOrEqualTo(36),
tester.getRect(find.byKey(const Key('search-header-filters'))),
filters,
);
});
@@ -558,7 +275,7 @@ void main() {
await tester.tap(searchField);
await tester.pumpAndSettle();
await tester.enterText(searchField, 'draft');
await tester.tap(find.byKey(const Key('search-cancel')));
await tester.enterText(find.byKey(const Key('search-field')), '');
await tester.pumpAndSettle();
expect(find.byKey(const Key('recent-searches-list')), findsNothing);
@@ -566,8 +283,12 @@ void main() {
await tester.pumpAndSettle();
await tester.enterText(searchField, 'design systems');
await tester.testTextInput.receiveAction(TextInputAction.search);
await tester.pump();
await tester.tap(find.byKey(const Key('search-cancel')));
await tester.pumpAndSettle();
expect(find.byKey(const Key('search-close')), findsOneWidget);
// Submission dismisses editing immediately. Reopen before clearing.
await tester.tap(searchField);
await tester.pumpAndSettle();
await tester.enterText(find.byKey(const Key('search-field')), '');
await tester.pumpAndSettle();
expect(find.byKey(const Key('recent-searches-list')), findsOneWidget);
@@ -616,7 +337,7 @@ void main() {
expect(input.focusNode?.hasFocus, isTrue);
expect(find.text("No results for 'launch plan'"), findsOneWidget);
await tester.tap(find.byKey(const Key('search-cancel')));
await tester.enterText(find.byKey(const Key('search-field')), '');
await tester.pumpAndSettle();
expect(find.byKey(const Key('recent-search-0')), findsOneWidget);
expect(find.text('launch plan'), findsOneWidget);
@@ -1126,6 +847,27 @@ void main() {
});
}
Finder _nativeFilter(String label) => find.byWidgetPredicate(
(widget) => widget is IosGlassNavigationButton && widget.label == label,
);
Future<void> _pressNativeFilter(WidgetTester tester, String label) async {
final view = tester.widget<UiKitView>(
find.descendant(of: _nativeFilter(label), matching: find.byType(UiKitView)),
);
// Simulate the engine creating this view, then deliver the native tap through
// the same per-view channel used by the production UIKit control.
const viewId = 42;
view.onPlatformViewCreated!(viewId);
await tester.pump();
const channel = MethodChannel('buzz/navigation_glass/$viewId');
await tester.binding.defaultBinaryMessenger.handlePlatformMessage(
channel.name,
channel.codec.encodeMethodCall(const MethodCall('pressed')),
(_) {},
);
}
class _FakeSearchNotifier extends SearchNotifier {
_FakeSearchNotifier(this.initialState);

View File

@@ -135,7 +135,9 @@ void main() {
final appBar = tester
.widgetList<FrostedAppBar>(find.byType(FrostedAppBar))
.first;
appBar.nativeActions!.single.onPressed!();
appBar.nativeActions!
.singleWhere((action) => action.label == 'Settings')
.onPressed!();
} else {
await tester.tap(find.byType(ProfileAvatar));
}

View File

@@ -5,6 +5,7 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:nostr/nostr.dart' as nostr;
import 'package:pointycastle/digests/sha256.dart';
import 'package:buzz/shared/crypto/nip_oa.dart';
import 'package:buzz/shared/relay/nostr_models.dart';
String _sha256Hex(String input) {
final digest = SHA256Digest().process(Uint8List.fromList(utf8.encode(input)));
@@ -25,6 +26,46 @@ void main() {
final owner = nostr.Keys.generate();
final agent = nostr.Keys.generate();
for (final (conditions, expected) in [
('', true),
('kind=0&created_at>99&created_at<101', true),
('kind=1', false),
('created_at>100', false),
('created_at<100', false),
('kind=0&created_at>100', false),
]) {
test('profile evaluates signed conditions $conditions', () {
final tag = authTag(owner, agent.public, conditions: conditions);
NostrEvent event(List<List<String>> tags, {int kind = 0}) =>
NostrEvent.fromJson(
nostr.Event.from(
kind: kind,
content: '{}',
secretKey: agent.secret,
createdAt: 100,
tags: tags,
).toMap(),
);
expect(
verifiedProfileOaOwnerPubkey(event([tag])),
expected ? owner.public : null,
);
expect(
verifiedProfileOaOwnerPubkey(
event([
tag,
['auth'],
]),
),
isNull,
);
expect(verifiedProfileOaOwnerPubkey(event([tag, tag])), isNull);
expect(verifiedProfileOaOwnerPubkey(event([tag], kind: 1)), isNull);
final forged = event([tag]).toJson()..['sig'] = '0' * 128;
expect(verifiedProfileOaOwnerPubkey(NostrEvent.fromJson(forged)), isNull);
});
}
test('returns the owner pubkey for a valid auth tag', () {
final tag = authTag(owner, agent.public);
expect(

View File

@@ -0,0 +1,120 @@
import 'package:buzz/shared/me/me_agent_change.dart';
import 'package:buzz/shared/me/me_sync.dart';
import 'package:buzz/shared/relay/relay.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:nostr/nostr.dart' as nostr;
import 'package:shared_preferences/shared_preferences.dart';
import 'package:shared_preferences_platform_interface/shared_preferences_platform_interface.dart';
class _Store extends InMemorySharedPreferencesStore {
bool failPreference = false;
bool failJournal = false;
_Store() : super.empty();
@override
Future<bool> setValue(String valueType, String key, Object value) async {
if (failJournal && key.endsWith(':pending')) return false;
if (failPreference &&
key.startsWith('flutter.me-assistant:') &&
!key.endsWith(':pending')) {
return false;
}
return super.setValue(valueType, key, value);
}
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
late SharedPreferences prefs;
late _Store store;
setUp(() async {
SharedPreferences.setMockInitialValues({});
store = _Store();
SharedPreferencesStorePlatform.instance = store;
prefs = await SharedPreferences.getInstance();
});
MeSync service({String relay = 'relay.test', String secret = '1'}) => MeSync(
RelaySessionNotifier(),
RelayConfig(
baseUrl: 'https://$relay',
nsec: nostr.Nip19.encode(
prefix: nostr.Nip19Prefix.nsec,
data: '0' * 63 + secret,
),
),
prefs: prefs,
);
for (final boundary in ['before add', 'after add', 'preference']) {
test('restart resumes agent change after $boundary failure', () async {
var member = false;
var adds = 0;
var fail = true;
final intent = MeAgentChange.create(service(), 'chat', 'private-agent');
Future<void> ensure() async {
expect(
MeAgentChange.pending(service(), 'chat')?.agent,
'private-agent',
);
if (fail && boundary == 'before add') throw StateError('crash');
if (!member) {
member = true;
adds++;
}
if (fail && boundary == 'after add') throw StateError('lost ack');
}
store.failPreference = boundary == 'preference';
await expectLater(
intent.complete(service(), 'chat', ensureMembership: ensure),
throwsStateError,
);
expect(
prefs.getString(
'${MeAgentChange.selectionKey(service(), 'chat')}:pending',
),
isNot(contains('private-agent')),
);
final restored = MeAgentChange.pending(service(), 'chat')!;
fail = false;
store.failPreference = false;
await restored.complete(service(), 'chat', ensureMembership: ensure);
expect(adds, 1);
expect(
prefs.getString(MeAgentChange.selectionKey(service(), 'chat')),
'private-agent',
);
expect(MeAgentChange.pending(service(), 'chat'), isNull);
});
}
test('failed durable write never grants access', () async {
store.failJournal = true;
var accessed = false;
await expectLater(
MeAgentChange.create(service(), 'chat', 'agent').complete(
service(),
'chat',
ensureMembership: () async {
accessed = true;
},
),
throwsStateError,
);
expect(accessed, isFalse);
});
test('journal isolates scope and compares exact reviewed record', () async {
final sync = service();
final first = MeAgentChange.create(sync, 'chat', 'agent');
await first.save(sync, 'chat');
expect(MeAgentChange.pending(service(relay: 'other.test'), 'chat'), isNull);
expect(MeAgentChange.pending(service(secret: '2'), 'chat'), isNull);
expect(MeAgentChange.pending(sync, 'other-chat'), isNull);
await expectLater(
first.discard(service(relay: 'other.test'), 'chat'),
throwsStateError,
);
await first.discard(sync, 'chat');
final second = MeAgentChange.create(sync, 'chat', 'agent');
await second.save(sync, 'chat');
await expectLater(first.discard(sync, 'chat'), throwsStateError);
expect(MeAgentChange.pending(sync, 'chat'), isNotNull);
});
}

View File

@@ -0,0 +1,239 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:buzz/shared/me/me_assistants.dart';
import 'package:buzz/shared/me/me_sync.dart';
import 'package:buzz/shared/relay/relay.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:nostr/nostr.dart' as nostr;
import 'package:pointycastle/digests/sha256.dart';
class _Relay extends RelaySessionNotifier {
final List<List<NostrEvent>> pages;
final requestedPages = <int>[];
final requestedTags = <Map<String, dynamic>>[];
final requestedAuthors = <List<String>?>[];
_Relay(this.pages);
@override
SessionState build() => const SessionState(status: SessionStatus.connected);
@override
Future<List<NostrEvent>> queryRelay(
List<NostrFilter> filters, {
Duration timeout = const Duration(seconds: 8),
}) async {
expect(filters.single.kinds, [0]);
requestedTags.add(filters.single.toJson());
requestedAuthors.add(filters.single.authors);
final page = filters.single.toJson()['page'] as int;
requestedPages.add(page);
return pages[page - 1];
}
}
void main() {
final owner = nostr.Keys.generate();
final other = nostr.Keys.generate();
final agent = nostr.Keys.generate();
NostrEvent profile(
nostr.Keys signer, {
nostr.Keys? ownedBy,
int time = 1,
String conditions = '',
Object? content,
}) {
final digest = SHA256Digest()
.process(
Uint8List.fromList(
utf8.encode('nostr:agent-auth:${signer.public}:$conditions'),
),
)
.map((v) => v.toRadixString(16).padLeft(2, '0'))
.join();
return NostrEvent.fromJson(
nostr.Event.from(
kind: 0,
content: jsonEncode(content ?? {'display_name': 'My assistant'}),
secretKey: signer.secret,
createdAt: time,
tags: [
if (ownedBy != null)
[
'auth',
ownedBy.public,
conditions,
nostr.Schnorr.sign(secretKey: ownedBy.secret, message: digest),
],
],
).toMap(),
);
}
ProviderContainer setup(_Relay relay) {
final sync = MeSync(
relay,
RelayConfig(
baseUrl: 'https://relay.test',
nsec: nostr.Nip19.encode(
prefix: nostr.Nip19Prefix.nsec,
data: owner.secret,
),
),
);
final container = ProviderContainer(
overrides: [
relaySessionProvider.overrideWith(() => relay),
meSyncProvider.overrideWithValue(sync),
],
);
addTearDown(container.dispose);
return container;
}
test('finds owned kind 0 agents without legacy directory entries', () async {
final valid = profile(agent, ownedBy: owner);
final forged = valid.toJson()..['pubkey'] = other.public;
final container = setup(
_Relay([
[
profile(owner),
profile(nostr.Keys.generate(), ownedBy: other),
NostrEvent.fromJson(forged),
valid,
],
]),
);
final choices = await container.read(meAssistantsProvider.future);
expect(choices.map((a) => a.pubkey), [agent.public]);
expect(choices.single.displayName, 'My assistant');
});
for (final malformed in [7, true, 'wrong', <String, Object>{}, <Object>[]]) {
test(
'malformed optional profile fields do not poison directory: $malformed',
() async {
final broken = nostr.Keys.generate();
final container = setup(
_Relay([
[
profile(
broken,
ownedBy: owner,
content: {
for (final field in [
'display_name',
'name',
'respond_to',
'respond_to_allowlist',
'channel_ids',
])
field: malformed,
},
),
profile(agent, ownedBy: owner),
],
]),
);
final choices = await container.read(meAssistantsProvider.future);
expect(
choices.singleWhere((a) => a.pubkey == agent.public).displayName,
'My assistant',
);
expect(
await isOwnedMeAssistant(
container.read(meSyncProvider),
agent.public,
),
isTrue,
);
},
);
}
test('latest profile removes obsolete ownership', () async {
final container = setup(
_Relay([
[profile(agent, ownedBy: owner), profile(agent, time: 2)],
]),
);
expect(await container.read(meAssistantsProvider.future), isEmpty);
});
test('reads beyond the first directory page', () async {
final relay = _Relay([
[for (var i = 0; i < 100; i++) profile(owner, time: i + 1)],
[profile(agent, ownedBy: owner)],
]);
expect(
(await setup(relay).read(meAssistantsProvider.future)).single.pubkey,
agent.public,
);
expect(relay.requestedPages, [1, 2]);
});
test(
'directory discovery is bounded even when the relay keeps paging',
() async {
final repeated = [
for (var i = 0; i < 99; i++) profile(agent, ownedBy: owner, time: i),
];
final relay = _Relay([
for (var page = 0; page < 11; page++)
[...repeated, profile(agent, ownedBy: owner, time: 100 + page)],
]);
await expectLater(
setup(relay).read(meAssistantsProvider.future),
throwsStateError,
);
expect(relay.requestedPages.length, 10);
for (final filter in relay.requestedTags) {
expect(filter.containsKey('#auth'), isFalse);
expect(filter['limit'], 100);
}
},
);
for (final conditions in ['kind=1', 'created_at>1', 'created_at<1']) {
test(
'discovery and send revalidation reject inapplicable $conditions',
() async {
final relay = _Relay([
[profile(agent, ownedBy: owner, conditions: conditions)],
]);
final container = setup(relay);
expect(await container.read(meAssistantsProvider.future), isEmpty);
expect(
await isOwnedMeAssistant(
container.read(meSyncProvider),
agent.public,
),
isFalse,
);
},
);
}
test('creation revalidates only the chosen assistant', () async {
final relay = _Relay([
[profile(agent, ownedBy: owner)],
]);
final container = setup(relay);
expect(
await isOwnedMeAssistant(container.read(meSyncProvider), agent.public),
isTrue,
);
expect(relay.requestedAuthors, [
[agent.public],
]);
});
test(
'creation rejects revoked ownership and unrelated returned profiles',
() async {
final relay = _Relay([
[
profile(agent, ownedBy: owner),
profile(agent, time: 2),
profile(other, ownedBy: owner),
],
]);
final container = setup(relay);
expect(
await isOwnedMeAssistant(container.read(meSyncProvider), agent.public),
isFalse,
);
},
);
}

View File

@@ -0,0 +1,137 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:nostr/nostr.dart' as nostr;
import 'package:shared_preferences/shared_preferences.dart';
import 'package:buzz/shared/me/me_chat_creation.dart';
import 'package:buzz/shared/me/me_sync.dart';
import 'package:buzz/shared/relay/relay.dart';
void main() {
test('missing durable storage blocks every relay side effect', () async {
final sync = MeSync(
RelaySessionNotifier(),
RelayConfig(
baseUrl: 'https://relay.test',
nsec: nostr.Nip19.encode(
prefix: nostr.Nip19Prefix.nsec,
data: '0' * 63 + '1',
),
),
);
var touchedRelay = false;
await expectLater(
const MeChatCreation('id', 'Name', 'agent').complete<String>(
sync,
findChannel: () async {
touchedRelay = true;
return null;
},
createChannel: () async => 'id',
hasAgent: () async => false,
addAgent: () async {},
isPlaced: () async => false,
place: () async {},
),
throwsStateError,
);
expect(touchedRelay, isFalse);
});
test(
'discard preserves partial relay state and permits a different intent offline',
() async {
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
final config = RelayConfig(
baseUrl: 'https://relay.test',
nsec: nostr.Nip19.encode(
prefix: nostr.Nip19Prefix.nsec,
data: '0' * 63 + '1',
),
);
final online = MeSync(RelaySessionNotifier(), config, prefs: prefs);
const intent = MeChatCreation('old', 'Old', 'revoked-agent');
await intent.save(online);
final offline = MeSync(
RelaySessionNotifier(),
config,
prefs: prefs,
connected: false,
);
await expectLater(
const MeChatCreation('wrong', 'Old', 'revoked-agent').discard(offline),
throwsStateError,
);
expect(MeChatCreation.pending(offline)?.id, 'old');
await intent.discard(offline);
expect(MeChatCreation.pending(offline), isNull);
await const MeChatCreation('new', 'New', 'other-agent').save(online);
expect(MeChatCreation.pending(online)?.id, 'new');
},
);
for (final crashAfter in [0, 1, 2, 3]) {
test('restart resumes the same chat after boundary $crashAfter', () async {
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
final config = RelayConfig(
baseUrl: 'https://relay.test',
nsec: nostr.Nip19.encode(
prefix: nostr.Nip19Prefix.nsec,
data: '0' * 63 + '1',
),
);
MeSync service() => MeSync(RelaySessionNotifier(), config, prefs: prefs);
const intent = MeChatCreation('same-id', 'Private name', 'agent');
var created = false, member = false, placed = false, crash = true;
final writes = [0, 0, 0];
void boundary(int point) {
expect(MeChatCreation.pending(service())?.id, intent.id);
if (crash && crashAfter == point) throw StateError('process stopped');
}
Future<String> run(MeSync sync, MeChatCreation intent) =>
intent.complete<String>(
sync,
findChannel: () async {
boundary(0);
return created ? intent.id : null;
},
createChannel: () async {
created = true;
writes[0]++;
boundary(1);
return intent.id;
},
hasAgent: () async => member,
addAgent: () async {
member = true;
writes[1]++;
boundary(2);
},
isPlaced: () async => placed,
place: () async {
placed = true;
writes[2]++;
boundary(3);
},
);
await expectLater(run(service(), intent), throwsStateError);
final restarted = service();
final pending = MeChatCreation.pending(restarted)!;
expect(pending.name, intent.name);
expect(pending.agent, intent.agent);
expect(
prefs.getString(MeChatCreation.storageKey(restarted)),
isNot(contains(intent.name)),
);
crash = false;
expect(await run(restarted, pending), intent.id);
expect(writes, [1, 1, 1]);
expect(MeChatCreation.pending(restarted), isNull);
final other = MeSync(
RelaySessionNotifier(),
RelayConfig(baseUrl: 'https://other.test', nsec: config.nsec),
prefs: prefs,
);
expect(MeChatCreation.pending(other), isNull);
});
}
}

View File

@@ -0,0 +1,93 @@
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:buzz/shared/me/me_groups.dart';
void main() {
const origin = 'https://buzz.block.builderlab.xyz';
const channel = '11111111-1111-4111-8111-111111111111';
Map<String, dynamic> record() => {
'version': 1,
'community': origin,
'deleted': false,
'value': {
'type': 'groups',
'id': 'me',
'groups': [
{
'id': 'project-one',
'name': 'Mobile',
'defaultTemplateId': 'me-section-one',
},
],
'assignments': {channel: 'project-one'},
'channels': [channel],
},
};
test('reads desktop groups and preserves defaults when placing a chat', () {
final source = record();
final groups = MeGroups.decode(jsonEncode(source), origin);
expect(groups.groups.single['defaultTemplateId'], 'me-section-one');
expect(jsonDecode(groups.place(channel).encode(origin)), source);
expect(
MeGroups.coordinate(origin),
'buzz-me-kit-v1:https%3A%2F%2Fbuzz.block.builderlab.xyz:groups:me',
);
});
test('rejects another community and independent Messages organization', () {
expect(
() => MeGroups.decode(jsonEncode(record()), 'https://other.test'),
throwsFormatException,
);
final source = record();
(source['value'] as Map)['id'] = 'messages';
expect(
() => MeGroups.decode(jsonEncode(source), origin),
throwsFormatException,
);
});
test(
'tombstones clear personal placement without inferring old sessions',
() {
final source = record()..['deleted'] = true;
expect(MeGroups.decode(jsonEncode(source), origin).channels, isEmpty);
},
);
test('rejects duplicate placement and missing project references', () {
final source = record();
(source['value'] as Map)['channels'] = [channel, channel];
expect(
() => MeGroups.decode(jsonEncode(source), origin),
throwsFormatException,
);
(source['value'] as Map)['channels'] = [channel];
(source['value'] as Map)['assignments'] = {channel: 'gone'};
expect(
() => MeGroups.decode(jsonEncode(source), origin),
throwsFormatException,
);
});
test('project names use the editors grapheme limit', () {
for (final character in ['🌳', '👨‍👩‍👧‍👦', 'e\u0301']) {
MeGroups groups(int count) => MeGroups(
groups: [
{'id': 'project', 'name': character * count, 'defaultTemplateId': ''},
],
);
final encoded = groups(120).encode(origin);
expect(
MeGroups.decode(encoded, origin).groups.single['name'],
character * 120,
);
expect(() => groups(121).encode(origin), throwsFormatException);
}
});
test('capacity is measured in UTF-8 bytes before publishing', () {
final groups = MeGroups(
groups: List.generate(
100,
(i) => {'id': 'g$i', 'name': '🌳' * 60, 'defaultTemplateId': ''},
),
);
expect(() => groups.encode(origin), throwsFormatException);
});
}

View File

@@ -0,0 +1,163 @@
import 'package:buzz/shared/me/me_agent_change.dart';
import 'package:buzz/shared/me/me_chat_creation.dart';
import 'package:buzz/shared/me/me_project_creation.dart';
import 'package:buzz/shared/me/me_groups.dart';
import 'package:buzz/shared/me/me_sync.dart';
import 'package:buzz/shared/relay/relay.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:nostr/nostr.dart' as nostr;
import 'package:shared_preferences/shared_preferences.dart';
import 'package:shared_preferences_platform_interface/shared_preferences_platform_interface.dart';
class _Store extends InMemorySharedPreferencesStore {
String? failure;
_Store() : super.empty();
@override
Future<bool> remove(String key) async {
if (failure == 'throw') throw StateError('disk failure');
if (failure == 'false') return false;
return super.remove(key);
}
}
class _Sync extends MeSync {
MeGroups groups = const MeGroups();
_Sync(SharedPreferences prefs)
: super(
RelaySessionNotifier(),
RelayConfig(
baseUrl: 'https://relay.test',
nsec: nostr.Nip19.encode(
prefix: nostr.Nip19Prefix.nsec,
data: '0' * 63 + '1',
),
),
prefs: prefs,
);
@override
Future<NostrEvent?> head() async => null;
@override
Future<void> update(MeGroups Function(MeGroups) change) async {
groups = change(groups);
}
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
for (final kind in ['chat', 'project', 'agent']) {
for (final action in ['complete', 'discard']) {
for (final failure in ['false', 'throw']) {
test(
'$kind $action retains same-process and restarted recovery after $failure removal',
() async {
SharedPreferences.setMockInitialValues({});
final store = _Store();
SharedPreferencesStorePlatform.instance = store;
final prefs = await SharedPreferences.getInstance();
final sync = _Sync(prefs);
const chat = MeChatCreation('chat-id', 'Chat', 'agent');
const project = MeProjectCreation('project-id', 'Project');
final agent = MeAgentChange.create(sync, 'chat-id', 'agent');
var created = false, member = false, placed = false;
var creates = 0, adds = 0, placements = 0;
Future<void> save() async {
if (kind == 'chat') await chat.save(sync);
if (kind == 'project') await project.save(sync);
if (kind == 'agent') await agent.save(sync, 'chat-id');
}
Object? pending() => switch (kind) {
'chat' => MeChatCreation.pending(sync)?.id,
'project' => MeProjectCreation.pending(sync)?.id,
_ => MeAgentChange.pending(sync, 'chat-id')?.agent,
};
Future<void> run() async {
if (action == 'discard') {
if (kind == 'chat') await chat.discard(sync);
if (kind == 'project') await project.discard(sync);
if (kind == 'agent') await agent.discard(sync, 'chat-id');
} else if (kind == 'chat') {
await chat.complete(
sync,
findChannel: () async => created ? 'chat-id' : null,
createChannel: () async {
creates++;
created = true;
return 'chat-id';
},
hasAgent: () async => member,
addAgent: () async {
adds++;
member = true;
},
isPlaced: () async => placed,
place: () async {
placements++;
placed = true;
},
);
} else if (kind == 'project') {
await project.complete(sync);
} else {
await agent.complete(
sync,
'chat-id',
ensureMembership: () async {
if (!member) {
adds++;
member = true;
}
},
);
}
}
await save();
final original = pending();
store.failure = failure;
await expectLater(run(), throwsStateError);
expect(
pending(),
original,
); // Reopening without restarting recovers.
await prefs.reload();
expect(pending(), original); // Disk agrees after restart.
store.failure = null;
await run();
expect(pending(), isNull);
if (action == 'complete') {
if (kind == 'chat') {
expect([creates, adds, placements], [1, 1, 1]);
}
if (kind == 'project') expect(sync.groups.groups.length, 1);
if (kind == 'agent') expect(adds, 1);
}
},
);
}
}
}
test('chat completion preserves a replacement journal', () async {
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
final sync = _Sync(prefs);
const intent = MeChatCreation('first', 'First', 'agent');
await expectLater(
intent.complete(
sync,
findChannel: () async => 'first',
createChannel: () async => 'first',
hasAgent: () async => true,
addAgent: () async {},
isPlaced: () async => false,
place: () async {
await prefs.remove(MeChatCreation.storageKey(sync));
await const MeChatCreation('second', 'Second', 'agent').save(sync);
},
),
throwsStateError,
);
expect(MeChatCreation.pending(sync)?.id, 'second');
});
}

View File

@@ -0,0 +1,66 @@
import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:buzz/shared/me/me_groups.dart';
import 'package:buzz/shared/me/me_sync.dart';
import 'package:buzz/shared/me/me_search_placement.dart';
void main() {
for (final error in [
const FormatException('malformed'),
StateError('unavailable'),
]) {
test('ordinary search remains navigable when Me is $error', () async {
final container = ProviderContainer(
overrides: [
meGroupsProvider.overrideWith((ref) => Stream.error(error)),
],
retry: (_, _) => null,
);
addTearDown(container.dispose);
expect(
await container.read(meSearchPlacementProvider('ordinary').future),
isFalse,
);
});
}
testWidgets('unresponsive Me lookup has a bounded ordinary fallback', (
tester,
) async {
final stream = StreamController<MeGroups>();
final container = ProviderContainer(
overrides: [meGroupsProvider.overrideWith((ref) => stream.stream)],
);
addTearDown(() async {
container.dispose();
await stream.close();
});
final result = container.read(meSearchPlacementProvider('ordinary').future);
await tester.pump();
await tester.pump(const Duration(seconds: 2));
expect(await result, isFalse);
});
test(
'cached personal placement is used without waiting for refresh',
() async {
final container = ProviderContainer(
overrides: [
meGroupsProvider.overrideWith(
(ref) => Stream.value(const MeGroups(channels: ['personal'])),
),
],
);
addTearDown(container.dispose);
container.listen(meGroupsProvider, (_, _) {});
await container.read(meGroupsProvider.future);
expect(
await container.read(meSearchPlacementProvider('personal').future),
isTrue,
);
expect(
await container.read(meSearchPlacementProvider('ordinary').future),
isFalse,
);
},
);
}

View File

@@ -0,0 +1,797 @@
import 'dart:async';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:nostr/nostr.dart' as nostr;
import 'package:shared_preferences/shared_preferences.dart';
import 'package:shared_preferences_platform_interface/shared_preferences_platform_interface.dart';
import 'package:buzz/shared/crypto/nip44.dart';
import 'package:buzz/shared/me/me_groups.dart';
import 'package:buzz/shared/me/me_chat_creation.dart';
import 'package:buzz/shared/me/me_assistants.dart';
import 'package:buzz/shared/me/me_sync.dart';
import 'package:buzz/shared/me/me_recovery.dart';
import 'package:buzz/shared/relay/relay.dart';
import 'package:buzz/shared/theme/theme_provider.dart';
class _Config extends RelayConfigNotifier {
final RelayConfig config;
_Config(this.config);
@override
RelayConfig build() => config;
void switchTo(RelayConfig next) => state = next;
}
class FakeRelay extends RelaySessionNotifier {
@override
SessionState build() => const SessionState(status: SessionStatus.connected);
void setStatus(SessionStatus status) => state = SessionState(status: status);
int directoryQueries = 0;
@override
Future<List<NostrEvent>> queryRelay(
List<NostrFilter> filters, {
Duration timeout = const Duration(seconds: 8),
}) async {
directoryQueries++;
return [];
}
List<NostrEvent> events = [];
Future<List<NostrEvent>>? pendingRead;
void Function(String)? closeSubscription;
void Function(NostrEvent)? emit;
@override
Future<void Function()> subscribe(
NostrFilter filter,
void Function(NostrEvent) onEvent, {
void Function(String message)? onClosed,
}) async {
closeSubscription = onClosed;
emit = onEvent;
return () {};
}
int writes = 0;
bool loseAcknowledgment = false;
void Function()? beforeRead;
@override
Future<List<NostrEvent>> fetchHistory(
NostrFilter filter, {
Duration timeout = const Duration(seconds: 8),
}) async {
beforeRead?.call();
return pendingRead ?? events;
}
@override
Future<NostrEvent> publish(
NostrEvent event, {
Duration timeout = const Duration(seconds: 8),
}) async {
writes++;
events = [event];
if (loseAcknowledgment) throw TimeoutException('no acknowledgment');
return event;
}
}
class _FailingCacheStore extends InMemorySharedPreferencesStore {
String? failure;
String? removeFailure;
Completer<bool>? blockedWrite;
int cacheWrites = 0;
_FailingCacheStore() : super.empty();
@override
Future<bool> setValue(String type, String key, Object value) {
if (key.startsWith('flutter.me-cache:')) {
cacheWrites++;
if (blockedWrite != null) return blockedWrite!.future;
if (failure == 'throw') throw StateError('disk error');
if (failure == 'false') return Future.value(false);
}
return super.setValue(type, key, value);
}
@override
Future<bool> remove(String key) async {
if (key.startsWith('flutter.me-pending:')) {
if (removeFailure == 'false') return false;
if (removeFailure == 'throw') throw StateError('remove failed');
}
return super.remove(key);
}
}
void main() {
const secret =
'0000000000000000000000000000000000000000000000000000000000000001';
final nsec = nostr.Nip19.encode(prefix: nostr.Nip19Prefix.nsec, data: secret);
const origin = 'https://relay.test';
final public = nostr.Keys(secret).public;
NostrEvent signed({
String community = origin,
String signingKey = secret,
int timestamp = 1,
MeGroups groups = const MeGroups(),
}) => NostrEvent.fromJson(
nostr.Event.from(
kind: 30078,
content: nip44Encrypt(
getConversationKey(secret, public),
groups.encode(community),
),
secretKey: signingKey,
createdAt: timestamp,
tags: [
['d', MeGroups.coordinate(community)],
['t', MeGroups.tag],
],
verify: false,
).toMap(),
);
for (final failure in ['false', 'throw']) {
test(
'chat recovery reconciles its confirmed Solo journal after $failure cleanup',
() async {
SharedPreferences.setMockInitialValues({});
final store = _FailingCacheStore()..removeFailure = failure;
SharedPreferencesStorePlatform.instance = store;
final prefs = await SharedPreferences.getInstance();
final relay = FakeRelay();
final sync = MeSync(
relay,
RelayConfig(baseUrl: origin, nsec: nsec),
prefs: prefs,
);
const id = '11111111-1111-4111-8111-111111111111';
const intent = MeChatCreation(id, 'Chat', 'agent');
Future<String> complete() => intent.complete(
sync,
findChannel: () async => id,
createChannel: () async => id,
hasAgent: () async => true,
addAgent: () async {},
isPlaced: () async =>
relay.events.isNotEmpty &&
sync.decode(relay.events.single).channels.contains(id),
place: () => sync.update((current) => current.place(id)),
);
await expectLater(complete(), throwsStateError);
expect(prefs.getString(sync.pendingKey), isNotNull);
expect(MeChatCreation.pending(sync), isNotNull);
store.removeFailure = null;
await expectLater(complete(), throwsStateError);
expect(prefs.getString(sync.pendingKey), isNull);
expect(MeChatCreation.pending(sync), isNotNull);
expect(await complete(), id);
expect(MeChatCreation.pending(sync), isNull);
expect(relay.writes, 1);
},
);
}
for (final failure in ['false', 'throw']) {
test(
'pending-save discard retains recovery after $failure removal',
() async {
SharedPreferences.setMockInitialValues({});
final store = _FailingCacheStore();
SharedPreferencesStorePlatform.instance = store;
final prefs = await SharedPreferences.getInstance();
final service = MeSync(
FakeRelay(),
RelayConfig(baseUrl: origin, nsec: nsec),
prefs: prefs,
);
await prefs.setString(service.pendingKey, 'reviewed');
store.removeFailure = failure;
await expectLater(service.discardPending('reviewed'), throwsStateError);
expect(prefs.getString(service.pendingKey), 'reviewed');
store.removeFailure = null;
await service.discardPending('reviewed');
expect(prefs.getString(service.pendingKey), isNull);
},
);
}
test(
'dirty newest snapshot survives transport reconnect and remains scoped',
() async {
SharedPreferences.setMockInitialValues({});
final store = _FailingCacheStore();
SharedPreferencesStorePlatform.instance = store;
final prefs = await SharedPreferences.getInstance();
final relay = FakeRelay();
final config = _Config(RelayConfig(baseUrl: origin, nsec: nsec));
final container = ProviderContainer(
overrides: [
relayConfigProvider.overrideWith(() => config),
relaySessionProvider.overrideWith(() => relay),
savedPrefsProvider.overrideWithValue(prefs),
],
);
addTearDown(container.dispose);
final service = container.read(meSyncProvider);
container.listen(meAssistantsProvider, (_, _) {});
await container.read(meAssistantsProvider.future);
expect(relay.directoryQueries, 1);
final a = signed(timestamp: 1);
final b = signed(
timestamp: 2,
groups: const MeGroups(
groups: [
{'id': 'new', 'name': 'Newest', 'defaultTemplateId': ''},
],
),
);
await service.cache(a);
relay.events = [a];
container.listen(meGroupsProvider, (_, _) {});
container.listen(meOfflineSaveWarningProvider, (_, _) {});
await container.read(meGroupsProvider.future);
store.failure = 'false';
relay.emit!(b);
await Future<void>.delayed(Duration.zero);
expect(
container.read(meGroupsProvider).value!.groups.single['name'],
'Newest',
);
expect(service.cacheWriteFailed.value, isTrue);
relay.setStatus(SessionStatus.disconnected);
await container.pump();
expect(container.read(meSyncProvider), same(service));
expect(service.connected, isFalse);
expect(
container.read(meGroupsProvider).value!.groups.single['name'],
'Newest',
);
expect(container.read(meOfflineSaveWarningProvider).value, isTrue);
relay.setStatus(SessionStatus.connected);
await container.pump();
await container.read(meGroupsProvider.future);
await Future<void>.delayed(Duration.zero);
expect(container.read(meSyncProvider), same(service));
expect(service.connected, isTrue);
await container.read(meAssistantsProvider.future);
expect(relay.directoryQueries, 2);
expect(
container.read(meGroupsProvider).value!.groups.single['name'],
'Newest',
);
expect(service.cacheWriteFailed.value, isTrue);
store.failure = null;
container.invalidate(meGroupsProvider);
await container.read(meGroupsProvider.future);
await Future<void>.delayed(Duration.zero);
expect(service.cachedSnapshot()?.event?.id, b.id);
expect(service.cacheWriteFailed.value, isFalse);
config.switchTo(RelayConfig(baseUrl: 'https://other.test', nsec: nsec));
await container.pump();
final other = container.read(meSyncProvider);
expect(service.disposed, isTrue);
expect(other.latestCacheCandidate, isNull);
expect(other.cacheWriteFailed.value, isFalse);
},
);
test(
'newer failed cache fences older arrivals and Retry retains newest',
() async {
SharedPreferences.setMockInitialValues({});
final store = _FailingCacheStore();
SharedPreferencesStorePlatform.instance = store;
final prefs = await SharedPreferences.getInstance();
final relay = FakeRelay();
final service = MeSync(
relay,
RelayConfig(baseUrl: origin, nsec: nsec),
prefs: prefs,
);
final initial = signed(timestamp: 1);
await service.cache(initial);
relay.events = [initial];
final container = ProviderContainer(
overrides: [meSyncProvider.overrideWithValue(service)],
);
addTearDown(container.dispose);
final seen = <String>[];
container.listen(meGroupsProvider, (_, value) {
if (value.hasValue) {
seen.add(
value.value!.groups.firstOrNull?['name'] as String? ?? 'initial',
);
}
});
container.listen(meOfflineSaveWarningProvider, (_, _) {});
await container.read(meGroupsProvider.future);
await Future<void>.delayed(Duration.zero);
final newer = signed(
timestamp: 3,
groups: const MeGroups(
groups: [
{'id': 'new', 'name': 'New', 'defaultTemplateId': ''},
],
),
);
final older = signed(
timestamp: 2,
groups: const MeGroups(
groups: [
{'id': 'old', 'name': 'Old', 'defaultTemplateId': ''},
],
),
);
store.blockedWrite = Completer<bool>();
relay.emit!(newer);
await Future<void>.delayed(Duration.zero);
final writes = store.cacheWrites;
relay.emit!(older);
// Also exercise the persistence fence independently of the reader.
final olderWrite = service.cache(older);
final blockedWrite = store.blockedWrite!;
store.blockedWrite = null;
blockedWrite.complete(false);
await olderWrite;
await Future<void>.delayed(Duration.zero);
expect(store.cacheWrites, writes);
expect(seen, isNot(contains('Old')));
expect(
container.read(meGroupsProvider).value!.groups.single['name'],
'New',
);
expect(service.cacheWriteFailed.value, isTrue);
expect(service.cachedSnapshot()?.event?.id, initial.id);
store.blockedWrite = null;
relay.events = [older];
container.invalidate(meGroupsProvider);
await Future<void>.delayed(Duration.zero);
await Future<void>.delayed(Duration.zero);
expect(service.cacheWriteFailed.value, isFalse);
expect(service.cachedSnapshot()?.event?.id, newer.id);
},
);
for (final absent in [false, true]) {
test(
'confirmed cleanup preserves ${absent ? 'already absent' : 'replaced'} journal',
() async {
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
final relay = FakeRelay();
final service = MeSync(
relay,
RelayConfig(baseUrl: origin, nsec: nsec),
prefs: prefs,
);
relay.beforeRead = () {
if (relay.writes > 0) {
if (absent) {
unawaited(prefs.remove(service.pendingKey));
} else {
unawaited(
prefs.setString(service.pendingKey, 'another pending record'),
);
}
}
};
if (absent) {
await service.update((v) => v);
expect(prefs.getString(service.pendingKey), isNull);
} else {
await expectLater(service.update((v) => v), throwsStateError);
expect(prefs.getString(service.pendingKey), 'another pending record');
}
},
);
}
for (final recovered in [false, true]) {
for (final failure in ['false', 'throw']) {
test(
'confirmed ${recovered ? 'recovery' : 'normal'} cleanup $failure remains retryable',
() async {
SharedPreferences.setMockInitialValues({});
final store = _FailingCacheStore();
SharedPreferencesStorePlatform.instance = store;
final prefs = await SharedPreferences.getInstance();
final relay = FakeRelay();
final config = RelayConfig(baseUrl: origin, nsec: nsec);
var service = MeSync(relay, config, prefs: prefs);
if (recovered) {
relay.loseAcknowledgment = true;
await expectLater(
service.update((v) => v),
throwsA(isA<TimeoutException>()),
);
relay.loseAcknowledgment = false;
service = MeSync(relay, config, prefs: prefs);
}
store.removeFailure = failure;
await expectLater(
service.update((v) => v),
throwsA(
isA<StateError>().having(
(e) => e.message,
'message',
contains('cleanup on this phone failed'),
),
),
);
await prefs.reload();
expect(prefs.getString(service.pendingKey), isNotNull);
expect(relay.writes, 1);
store.removeFailure = null;
await expectLater(
MeSync(relay, config, prefs: prefs).update((v) => v),
throwsA(
isA<StateError>().having(
(e) => e.message,
'message',
contains('previous Solo save is confirmed'),
),
),
);
expect(prefs.getString(service.pendingKey), isNull);
expect(relay.writes, 1);
},
);
}
}
for (final failure in ['false', 'throw']) {
test(
'snapshot write $failure exposes failure and Retry persists the same event',
() async {
SharedPreferences.setMockInitialValues({});
final store = _FailingCacheStore();
SharedPreferencesStorePlatform.instance = store;
final prefs = await SharedPreferences.getInstance();
final relay = FakeRelay();
final config = RelayConfig(baseUrl: origin, nsec: nsec);
final service = MeSync(relay, config, prefs: prefs);
final old = signed(timestamp: 1);
await service.cache(old);
final fresh = signed(
timestamp: 2,
groups: const MeGroups(
channels: ['11111111-1111-4111-8111-111111111111'],
),
);
relay.events = [fresh];
store.failure = failure;
final container = ProviderContainer(
overrides: [meSyncProvider.overrideWithValue(service)],
);
addTearDown(container.dispose);
final failed = Completer<void>();
container.listen(meGroupsProvider, (_, _) {});
container.listen(meOfflineSaveWarningProvider, (_, next) {
if (next.value == true && !failed.isCompleted) failed.complete();
});
await failed.future;
expect(container.read(meGroupsProvider).hasError, isFalse);
expect(container.read(meOfflineSaveWarningProvider).value, isTrue);
expect(service.cachedSnapshot()?.event?.id, old.id);
store.failure = null;
container.invalidate(meGroupsProvider);
final restored = Completer<void>();
final subscription = container.listen(meOfflineSaveWarningProvider, (
_,
next,
) {
if (next.value == false && !restored.isCompleted) restored.complete();
});
await restored.future;
subscription.close();
await prefs.reload();
final restarted = MeSync(
FakeRelay(),
config,
prefs: prefs,
connected: false,
);
expect(restarted.cachedSnapshot()?.event?.id, fresh.id);
},
);
}
test(
'offline relaunch emits encrypted cached organization and isolates accounts',
() async {
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
final config = RelayConfig(baseUrl: origin, nsec: nsec);
final online = MeSync(FakeRelay(), config, prefs: prefs);
const groups = MeGroups(
groups: [
{'id': 'project', 'name': 'Private project', 'defaultTemplateId': ''},
],
);
await online.cache(signed(groups: groups, timestamp: 3));
await online.cache(signed(timestamp: 2));
await online.cache(null);
expect(
prefs.getString(online.cacheKey),
isNot(contains('Private project')),
);
final offline = MeSync(
FakeRelay(),
config,
prefs: prefs,
connected: false,
);
final container = ProviderContainer(
overrides: [meSyncProvider.overrideWithValue(offline)],
retry: (_, _) => null,
);
addTearDown(container.dispose);
container.listen(meGroupsProvider, (_, _) {});
expect(
(await container.read(meGroupsProvider.future)).groups.single['name'],
'Private project',
);
await Future<void>.delayed(Duration.zero);
expect(container.read(meGroupsProvider).hasError, isFalse);
for (final other in [
RelayConfig(baseUrl: 'https://other.test', nsec: nsec),
RelayConfig(
baseUrl: origin,
nsec: nostr.Nip19.encode(
prefix: nostr.Nip19Prefix.nsec,
data: '0' * 63 + '2',
),
),
]) {
expect(
MeSync(
FakeRelay(),
other,
prefs: prefs,
connected: false,
).cachedSnapshot(),
isNull,
);
}
},
);
test(
'corrupt cache refreshes online and persists the verified replacement',
() async {
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
final relay = FakeRelay()..events = [signed(timestamp: 5)];
final service = MeSync(
relay,
RelayConfig(baseUrl: origin, nsec: nsec),
prefs: prefs,
);
await prefs.setString(service.cacheKey, 'corrupt');
final container = ProviderContainer(
overrides: [meSyncProvider.overrideWithValue(service)],
retry: (_, _) => null,
);
addTearDown(container.dispose);
container.listen(meGroupsProvider, (_, _) {});
expect((await container.read(meGroupsProvider.future)).channels, isEmpty);
await Future<void>.delayed(Duration.zero);
expect(service.cachedSnapshot()?.event?.id, relay.events.single.id);
},
);
test(
'empty organization survives relaunch without relay connectivity',
() async {
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
final config = RelayConfig(baseUrl: origin, nsec: nsec);
await MeSync(FakeRelay(), config, prefs: prefs).cache(null);
final offline = MeSync(
FakeRelay(),
config,
prefs: prefs,
connected: false,
);
expect(offline.cachedSnapshot()?.groups.channels, isEmpty);
},
);
test(
'cached organization emits before refresh and survives a closed subscription',
() async {
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
final response = Completer<List<NostrEvent>>();
final relay = FakeRelay()..pendingRead = response.future;
final service = MeSync(
relay,
RelayConfig(baseUrl: origin, nsec: nsec),
prefs: prefs,
);
final old = signed(timestamp: 1);
await service.cache(old);
final container = ProviderContainer(
overrides: [meSyncProvider.overrideWithValue(service)],
retry: (_, _) => null,
);
addTearDown(container.dispose);
container.listen(meGroupsProvider, (_, _) {});
expect((await container.read(meGroupsProvider.future)).groups, isEmpty);
final fresh = signed(
timestamp: 2,
groups: const MeGroups(
groups: [
{'id': 'new', 'name': 'From desktop', 'defaultTemplateId': ''},
],
),
);
response.complete([fresh]);
await Future<void>.delayed(Duration.zero);
expect(
container.read(meGroupsProvider).value?.groups.single['name'],
'From desktop',
);
expect(service.cachedSnapshot()?.event?.id, fresh.id);
relay.closeSubscription!('offline');
await Future<void>.delayed(Duration.zero);
expect(service.syncClosed.value, isTrue);
expect(container.read(meGroupsProvider).hasError, isFalse);
expect(
container.read(meGroupsProvider).value?.groups.single['name'],
'From desktop',
);
},
);
test('verifies signature, account and encrypted community envelope', () {
final sync = MeSync(FakeRelay(), RelayConfig(baseUrl: origin, nsec: nsec));
expect(sync.decode(signed()).channels, isEmpty);
expect(
() => sync.decode(signed(community: 'https://other.test')),
throwsFormatException,
);
expect(
() => sync.decode(
signed(
signingKey:
'0000000000000000000000000000000000000000000000000000000000000002',
),
),
throwsFormatException,
);
final tampered = signed().toJson()..['sig'] = '0' * 128;
expect(() => sync.decode(NostrEvent.fromJson(tampered)), throwsA(anything));
});
test('equal timestamp selects the lowest event ID', () {
final a = signed(timestamp: 2);
final b = signed(timestamp: 2);
expect(meEventIsNewer(a, b), a.id.compareTo(b.id) < 0);
expect(meEventIsNewer(signed(timestamp: 3), a), isTrue);
});
test('scope disposal during preparation prevents publication', () async {
final relay = FakeRelay();
final sync = MeSync(relay, RelayConfig(baseUrl: origin, nsec: nsec));
relay.beforeRead = () => sync.disposed = true;
await expectLater(sync.update((v) => v), throwsStateError);
expect(relay.writes, 0);
});
test('concurrent desktop change blocks stale replacement', () async {
final relay = FakeRelay()..events = [signed()];
final sync = MeSync(relay, RelayConfig(baseUrl: origin, nsec: nsec));
var reads = 0;
relay.beforeRead = () {
if (++reads == 2) relay.events = [signed(timestamp: 2)];
};
await expectLater(sync.update((v) => v), throwsStateError);
expect(relay.writes, 0);
});
test(
'unconfirmed save survives service restart and never silently replaces it',
() async {
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
final relay = FakeRelay()..loseAcknowledgment = true;
final config = RelayConfig(baseUrl: origin, nsec: nsec);
final first = MeSync(relay, config, prefs: prefs);
await expectLater(
first.update((v) => v),
throwsA(isA<TimeoutException>()),
);
final pending = prefs.getString(first.pendingKey);
expect(pending, isNotNull);
expect(
jsonDecode(pending!)['event']['content'],
isNot(contains('groups')),
);
final second = MeSync(relay, config, prefs: prefs);
await expectLater(second.update((v) => v), throwsStateError);
expect(relay.writes, 1);
expect(prefs.getString(first.pendingKey), isNull);
},
);
test(
'superseded pending save rebases local additions after restart',
() async {
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
final relay = FakeRelay();
final config = RelayConfig(baseUrl: origin, nsec: nsec);
final sync = MeSync(relay, config, prefs: prefs);
const base = MeGroups(
groups: [
{'id': 'old', 'name': 'Original', 'defaultTemplateId': ''},
],
);
const local = MeGroups(
groups: [
{'id': 'old', 'name': 'Original', 'defaultTemplateId': ''},
{'id': 'local', 'name': 'Phone project', 'defaultTemplateId': ''},
],
);
const remote = MeGroups(
groups: [
{'id': 'old', 'name': 'Desktop rename', 'defaultTemplateId': ''},
{'id': 'remote', 'name': 'Desktop project', 'defaultTemplateId': ''},
],
);
await prefs.setString(
sync.pendingKey,
jsonEncode({
'event': signed(groups: local, timestamp: 2).toJson(),
'base': signed(groups: base, timestamp: 1).toJson(),
}),
);
relay.events = [signed(groups: remote, timestamp: 3)];
final restarted = MeSync(relay, config, prefs: prefs);
var newIntentRan = false;
await expectLater(
restarted.update((v) {
newIntentRan = true;
return v;
}),
throwsStateError,
);
expect(newIntentRan, isFalse);
expect(
restarted.decode(relay.events.single).groups.map((g) => g['name']),
['Desktop rename', 'Desktop project', 'Phone project'],
);
expect(prefs.getString(sync.pendingKey), isNull);
expect(relay.writes, 1);
},
);
test('conflicting edits remain durable until explicit discard', () async {
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
final relay = FakeRelay();
final sync = MeSync(
relay,
RelayConfig(baseUrl: origin, nsec: nsec),
prefs: prefs,
);
MeGroups group(String name) => MeGroups(
groups: [
{'id': 'project', 'name': name, 'defaultTemplateId': ''},
],
);
final stored = jsonEncode({
'event': signed(groups: group('Phone'), timestamp: 2).toJson(),
'base': signed(groups: group('Original')).toJson(),
});
await prefs.setString(sync.pendingKey, stored);
final remote = signed(groups: group('Desktop'), timestamp: 3);
relay.events = [remote];
await expectLater(
sync.update((v) => v),
throwsA(isA<MeRecoveryConflict>()),
);
expect(prefs.getString(sync.pendingKey), stored);
expect(relay.writes, 0);
await expectLater(sync.discardPending('stale snapshot'), throwsStateError);
expect(prefs.getString(sync.pendingKey), stored);
await sync.discardPending(stored);
await sync.update(
(v) => MeGroups(
groups: [
...v.groups,
{'id': 'new', 'name': 'New', 'defaultTemplateId': ''},
],
),
);
expect(sync.decode(relay.events.single).groups.map((g) => g['name']), [
'Desktop',
'New',
]);
expect(prefs.getString(sync.pendingKey), isNull);
});
}

View File

@@ -28,6 +28,29 @@ void main() {
),
);
testWidgets('only animated agent artwork shrinks inside its unchanged frame', (
tester,
) async {
const poster = 'https://relay.example/poster.png';
final animated =
'$poster#buzz-anim=${Uri.encodeComponent('https://relay.example/animation.png')}';
const preset =
'https://dwwgwmfqqjotj.cloudfront.net/avatars/20260925T154625166Z/poster/pollies/pollies-10.png';
for (final url in [poster, animated, preset]) {
await tester.pumpWidget(subject(url, isAgent: true));
final clip = find.byType(ClipRRect);
final scale = find.descendant(of: clip, matching: find.byType(Transform));
if (url != poster) {
expect(scale, findsOneWidget);
expect(tester.widget<Transform>(scale).transform.entry(0, 0), 0.85);
final content = find.byType(AvatarImageContent);
expect(tester.getSize(content), tester.getSize(clip));
} else {
expect(scale, findsNothing);
}
}
});
testWidgets('clips agents to a 30 percent squircle', (tester) async {
await tester.pumpWidget(subject(null, isAgent: true));