mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
## Summary This PR does two things. It updates the mobile testing conventions to favor a few journey tests over many small logic tests. It also applies those conventions to the existing mobile suite, one commit per step. ### Testing conventions (`AGENTS.md`, `mobile/README.md`) - Start with one widget journey per user-visible flow, driven through the real widgets, notifiers and coordinators. Assert what the user sees and what is durably saved. - Cover retry, cancel and resume as steps inside the journey, not as separate tests. - Fake only external boundaries (network, relay, OS, browser, storage). - Use focused, table-driven tests only for security and data-loss invariants, protocol or crypto vectors, and pure functions with many input combinations. - When review finds a defect, extend an existing journey or table instead of adding a test per finding. - Delete a lower-level test only once a journey demonstrably catches the same failures. - Every test must fail when the production code it guards is broken. These conventions keep the Review-Proven Rules' safety bar and change only how coverage is organized. ### Suite inventory and plan The suite has 234 Dart test files, about 110k lines and about 2,600 declared tests. Native tests are separate: BuzzPushKit, RunnerTests, NativeControlTests and Android. Line-saving estimates are hypotheses, not a deletion budget. Each deletion needs a replacement that rejects a representative production mutation. 1. **CI and reliability.** Run the native tests CI skipped, make time-dependent tests deterministic, and make tests that could not fail falsifiable. *(done, see below)* 2. **Shared harness.** Add one fake relay (socket plus HTTP boundary, with the real `RelaySessionNotifier`) in `test/helpers/`. Today 38 files each write their own `RelaySessionNotifier` fake. *(done, see below)* 3. **Per-area journeys.** Convert page tests that fake the flow's own notifiers (for example, `channel_detail_page_test` overrides about 60 providers). Fold near-duplicates into tables. Remove constant-pinning, pixel-geometry and call-count tests once a journey covers the outcome. Split `channel_detail_page_test.dart` (17.7k lines). Each area commit reports before/after counts and the mutation it rejects. 4. **CI speed.** Shard by file instead of by test, since every shard currently compiles the whole suite. *(done, see below)* Keep as-is: relay protocol and NIP handling, crypto vectors, auth, push security, persistence and journal invariants, recipient routing, and real-socket transport and cancellation tests. ### Step 1 changes - **Android CI** runs the 23 JVM unit tests, which were never run before. It retries the APK build once, only on SDK or plugin download failures (the cause of 4 recent `main` failures). - **iOS CI** runs all RunnerTests (previously 2 of 99) and the previously uncalled Me-agent accessibility script. The Swift Native lane builds the simulator app once, in the background while the simulator boots and runs the native-control scripts, instead of twice. The lane stays non-required. - **Stale RunnerTests.** Two reaction-details tests still expected the filter list that #8280 replaced; they now cover the toggle list. #8393 moved navigation bar controls onto a shared glass surface and made unknown Tabler symbols assert. The avatar and members-activity tests now find controls on that surface, three tests use real symbol names, and a 240 pt title width pinned to the old control sizes is removed (its gap assertions remain). - **Known bug, not fixed here.** Since #8393 the DM title avatar image is nil, because `makeItem` returns avatar data as a custom-view item. No screen passes a title avatar yet. The test marks it `XCTExpectFailure`. - **Typing retry** and **sections backoff** tests use fake time instead of about 37 s of real sleeps and a `Stopwatch < 100ms` threshold. - **Manage-channel sheet tests** previously asserted inside callbacks that the sheet's `catch (_)` swallows. A wrong saved description made the test loop until killed. The tests now record arguments, assert outside the caught code, and cancel instead of re-presenting endlessly. - **`video_viewer_transport_test` removed.** It exercised only `package:http`. Removing the production `request.sink.close()` already fails `video_viewer_test`. ### Step 2 changes - **`test/helpers/fake_relay.dart`** fakes only the websocket (through the session's socket factory) and the HTTP bridge (through its client). The real `RelaySessionNotifier` still connects from `build()`, handles OK and CLOSED, batches events, backs off and replays subscriptions. - The fake serves the NIP-CW channel window. It can hold OK and live echo independently, reject a chosen publish, and drop the connection. Any request it cannot answer faithfully fails the test at teardown, because the app often catches relay errors and falls back. - **`channel_messages_journey_test`**: history through the channel window, a live message, a send that is pending until OK and stays one row after the echo, a rejected send rolled back, then drop and reconnect with missed and later messages. - **`channel_send_journey_test`**: type and send on `ChannelDetailPage` with no feature-notifier overrides. The row and sending spinner show at once, OK settles them, and a rejected send puts the text back in the composer and shows a snackbar. - Existing per-file relay fakes and `SidebarRelay` are unchanged. Step 3 migrates them area by area. ### Step 4 changes - `scripts/mobile-test-shard.sh` splits the 235 test files across the 3 shards, balanced by line count. `flutter test --total-shards` split tests, so each shard compiled and loaded every file. Locally at 4 workers, one shard took 63 s instead of 73 s and used 40% less CPU. `channel_detail_page_test.dart` (17.7k lines) is now the long pole; splitting it in step 3 shortens the slowest shard. Out of scope here: requiring branches to be up to date before merge (a repository setting), and the onboarding tests in #8375. ## Validation - Mutation checks: - Typing retry bound: test fails. - Sections backoff reset: test fails. - Wrong saved description: fails in about 11 s. The old tests hung instead. - Removed sink close: `video_viewer_test` fails. - Changed Dart files pass locally, and `flutter analyze` is clean. - Android `testDebugUnitTest` passes locally (23 tests). The Me-agent accessibility script passes on a local simulator. - Step 2 mutation checks, each made a journey fail: accepted OK ignored, rejected row not rolled back, optimistic row not added, draft not restored, local message left pending after OK, channel window replaced by the fallback, live subscriptions not replayed after reconnect. - Both journeys run in about 1 s on fake time. - All 99 RunnerTests pass on an iPhone 16 iOS 26.5 simulator, the CI destination, after rebasing on #8398. - The shard script partitions all 235 test files exactly once. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Wes <wesbillman@users.noreply.github.com> Co-authored-by: Carl <32a2e2c9d428ee08902cab75d956da2c1d235a22d4766b0dd4138bf6e2e5db1d@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
924 lines
29 KiB
Dart
924 lines
29 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:buzz/features/channels/channel.dart';
|
|
import 'package:buzz/features/channels/channel_actions_sheet.dart';
|
|
import 'package:buzz/features/channels/channel_management_provider.dart';
|
|
import 'package:buzz/features/channels/channel_sections/channel_sections_provider.dart';
|
|
import 'package:buzz/features/channels/channel_sections/channel_sections_storage.dart';
|
|
import 'package:buzz/features/channels/manage_channel_sheet.dart';
|
|
import 'package:buzz/shared/mentions/agent_identity_provider.dart';
|
|
import 'package:buzz/shared/relay/relay.dart';
|
|
import 'package:buzz/shared/theme/theme.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
|
|
const _currentPubkey = 'me';
|
|
|
|
Channel _channel({
|
|
String type = 'stream',
|
|
bool isArchived = false,
|
|
bool isMember = true,
|
|
}) => Channel(
|
|
id: 'channel-id',
|
|
name: type == 'dm' ? 'Alice' : 'general',
|
|
channelType: type,
|
|
visibility: 'open',
|
|
description: '',
|
|
createdBy: 'owner',
|
|
createdAt: DateTime(2025),
|
|
memberCount: 2,
|
|
isMember: isMember,
|
|
archivedAt: isArchived ? DateTime(2025, 1, 2) : null,
|
|
);
|
|
|
|
Widget _app({
|
|
required Channel channel,
|
|
required Future<List<ChannelMember>> Function() loadMembers,
|
|
bool isUnread = false,
|
|
AsyncValue<Map<String, String>> agentOwners = const AsyncValue.data(
|
|
<String, String>{},
|
|
),
|
|
ChannelActions Function(Ref ref)? createChannelActions,
|
|
String? currentPubkey = _currentPubkey,
|
|
}) => ProviderScope(
|
|
overrides: [
|
|
currentPubkeyProvider.overrideWith((ref) => currentPubkey),
|
|
channelMembersProvider(channel.id).overrideWith((ref) => loadMembers()),
|
|
agentOwnersProvider.overrideWithValue(agentOwners),
|
|
if (createChannelActions != null)
|
|
channelActionsProvider.overrideWith(createChannelActions),
|
|
],
|
|
child: MaterialApp(
|
|
theme: AppTheme.light(),
|
|
home: Scaffold(
|
|
body: ChannelActionsSheet(channel: channel, isUnread: isUnread),
|
|
),
|
|
),
|
|
);
|
|
|
|
Widget _modalApp({
|
|
required Channel channel,
|
|
required Future<List<ChannelMember>> Function() loadMembers,
|
|
required ChannelActions Function(Ref ref) createChannelActions,
|
|
String? canvasContent,
|
|
Future<ChannelCanvas>? pendingCanvas,
|
|
}) => ProviderScope(
|
|
overrides: [
|
|
currentPubkeyProvider.overrideWith((ref) => _currentPubkey),
|
|
channelMembersProvider(channel.id).overrideWith((ref) => loadMembers()),
|
|
channelSectionsProvider.overrideWith(
|
|
() => _FakeChannelSectionsNotifier(const ChannelSectionStore()),
|
|
),
|
|
agentOwnersProvider.overrideWithValue(
|
|
const AsyncValue.data(<String, String>{}),
|
|
),
|
|
channelCanvasProvider(channel.id).overrideWith(
|
|
(ref) =>
|
|
pendingCanvas ??
|
|
ChannelCanvas(
|
|
content: canvasContent,
|
|
updatedAt: null,
|
|
authorPubkey: null,
|
|
),
|
|
),
|
|
channelActionsProvider.overrideWith(createChannelActions),
|
|
],
|
|
child: MaterialApp(
|
|
theme: AppTheme.light(),
|
|
home: Builder(
|
|
builder: (context) => Scaffold(
|
|
body: TextButton(
|
|
onPressed: () => showChannelActionsSheet(
|
|
context: context,
|
|
channel: channel,
|
|
isUnread: false,
|
|
),
|
|
child: const Text('Open actions'),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
|
|
void main() {
|
|
testWidgets('owner sees the complete regular-channel action set', (
|
|
tester,
|
|
) async {
|
|
await tester.pumpWidget(
|
|
_app(
|
|
channel: _channel(),
|
|
loadMembers: () async => [
|
|
ChannelMember(
|
|
pubkey: _currentPubkey,
|
|
role: 'owner',
|
|
joinedAt: DateTime(2025),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
|
|
for (final label in [
|
|
'Star',
|
|
'Mark Unread',
|
|
'Move to section…',
|
|
'Mute channel',
|
|
'Manage channel',
|
|
'Copy channel name',
|
|
'Copy channel ID',
|
|
'Leave channel',
|
|
'Archive channel',
|
|
'Delete channel',
|
|
]) {
|
|
expect(find.text(label), findsOneWidget, reason: label);
|
|
}
|
|
|
|
final moveTop = tester.getTopLeft(find.text('Move to section…')).dy;
|
|
final muteTop = tester.getTopLeft(find.text('Mute channel')).dy;
|
|
final manageTop = tester.getTopLeft(find.text('Manage channel')).dy;
|
|
final copyNameTop = tester.getTopLeft(find.text('Copy channel name')).dy;
|
|
final copyIdTop = tester.getTopLeft(find.text('Copy channel ID')).dy;
|
|
expect(moveTop, lessThan(muteTop));
|
|
expect(muteTop, lessThan(manageTop));
|
|
expect(manageTop, lessThan(copyNameTop));
|
|
expect(copyNameTop, lessThan(copyIdTop));
|
|
});
|
|
|
|
testWidgets('unread channel uses the Mark Read label', (tester) async {
|
|
await tester.pumpWidget(
|
|
_app(
|
|
channel: _channel(),
|
|
isUnread: true,
|
|
loadMembers: () async => const [],
|
|
),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(find.text('Mark Read'), findsOneWidget);
|
|
expect(find.text('Mark Unread'), findsNothing);
|
|
});
|
|
|
|
testWidgets('admin can archive but cannot delete', (tester) async {
|
|
await tester.pumpWidget(
|
|
_app(
|
|
channel: _channel(),
|
|
loadMembers: () async => [
|
|
ChannelMember(
|
|
pubkey: _currentPubkey,
|
|
role: 'admin',
|
|
joinedAt: DateTime(2025),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(find.text('Archive channel'), findsOneWidget);
|
|
expect(find.text('Delete channel'), findsNothing);
|
|
});
|
|
|
|
testWidgets('verified owner agent grants archive and delete', (tester) async {
|
|
const agentPubkey = 'agent';
|
|
await tester.pumpWidget(
|
|
_app(
|
|
channel: _channel(),
|
|
agentOwners: const AsyncValue.data({agentPubkey: _currentPubkey}),
|
|
loadMembers: () async => [
|
|
ChannelMember(
|
|
pubkey: agentPubkey,
|
|
role: 'owner',
|
|
joinedAt: DateTime(2025),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(find.text('Archive channel'), findsOneWidget);
|
|
expect(find.text('Delete channel'), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('unresolved identity grants no lifecycle actions', (
|
|
tester,
|
|
) async {
|
|
await tester.pumpWidget(
|
|
_app(
|
|
channel: _channel(),
|
|
currentPubkey: null,
|
|
loadMembers: () async => [
|
|
ChannelMember(
|
|
pubkey: 'ordinary-owner',
|
|
role: 'owner',
|
|
joinedAt: DateTime(2025),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(find.text('Archive channel'), findsNothing);
|
|
expect(find.text('Delete channel'), findsNothing);
|
|
});
|
|
|
|
testWidgets('archived owner can unarchive but cannot delete', (tester) async {
|
|
late _FakeChannelActions actions;
|
|
await tester.pumpWidget(
|
|
_app(
|
|
channel: _channel(isArchived: true),
|
|
loadMembers: () async => [
|
|
ChannelMember(
|
|
pubkey: _currentPubkey,
|
|
role: 'owner',
|
|
joinedAt: DateTime(2025),
|
|
),
|
|
],
|
|
createChannelActions: (ref) => actions = _FakeChannelActions(ref),
|
|
),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(find.text('Archive channel'), findsNothing);
|
|
expect(find.text('Unarchive channel'), findsOneWidget);
|
|
expect(find.text('Delete channel'), findsNothing);
|
|
|
|
await tester.tap(find.text('Unarchive channel'));
|
|
await tester.pumpAndSettle();
|
|
expect(find.text('Unarchive #general?'), findsOneWidget);
|
|
await tester.tap(find.widgetWithText(FilledButton, 'Unarchive'));
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(actions.unarchivedChannelId, 'channel-id');
|
|
});
|
|
|
|
testWidgets('owned non-owner agent grants no lifecycle actions', (
|
|
tester,
|
|
) async {
|
|
const agentPubkey = 'agent';
|
|
await tester.pumpWidget(
|
|
_app(
|
|
channel: _channel(),
|
|
agentOwners: const AsyncValue.data({agentPubkey: _currentPubkey}),
|
|
loadMembers: () async => [
|
|
ChannelMember(
|
|
pubkey: agentPubkey,
|
|
role: 'bot',
|
|
joinedAt: DateTime(2025),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(find.text('Archive channel'), findsNothing);
|
|
expect(find.text('Delete channel'), findsNothing);
|
|
});
|
|
|
|
testWidgets('agent ownership loading keeps lifecycle actions pending', (
|
|
tester,
|
|
) async {
|
|
await tester.pumpWidget(
|
|
_app(
|
|
channel: _channel(),
|
|
agentOwners: const AsyncValue.loading(),
|
|
loadMembers: () async => const [],
|
|
),
|
|
);
|
|
await tester.pump();
|
|
|
|
expect(find.text('Loading channel actions…'), findsOneWidget);
|
|
expect(find.text('Archive channel'), findsNothing);
|
|
expect(find.text('Delete channel'), findsNothing);
|
|
});
|
|
|
|
testWidgets('member sees neither owner action', (tester) async {
|
|
await tester.pumpWidget(
|
|
_app(
|
|
channel: _channel(),
|
|
loadMembers: () async => [
|
|
ChannelMember(
|
|
pubkey: _currentPubkey,
|
|
role: 'member',
|
|
joinedAt: DateTime(2025),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(find.text('Archive channel'), findsNothing);
|
|
expect(find.text('Delete channel'), findsNothing);
|
|
expect(find.text('Move to section…'), findsOneWidget);
|
|
expect(find.text('Leave channel'), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('shows loading and unavailable capability states', (
|
|
tester,
|
|
) async {
|
|
final pending = Completer<List<ChannelMember>>();
|
|
await tester.pumpWidget(
|
|
_app(channel: _channel(), loadMembers: () => pending.future),
|
|
);
|
|
await tester.pump();
|
|
expect(find.text('Loading channel actions…'), findsOneWidget);
|
|
|
|
pending.completeError(Exception('relay unavailable'));
|
|
await tester.pumpAndSettle();
|
|
expect(find.text('Channel actions unavailable'), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('Manage contains editing and canvas, not mute or leave', (
|
|
tester,
|
|
) async {
|
|
await tester.pumpWidget(
|
|
_modalApp(
|
|
channel: _channel(),
|
|
loadMembers: () async => const [],
|
|
createChannelActions: (ref) => _FakeChannelActions(ref),
|
|
),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
await tester.tap(find.text('Open actions'));
|
|
await tester.pumpAndSettle();
|
|
|
|
await tester.tap(find.text('Manage channel'));
|
|
await tester.pumpAndSettle();
|
|
|
|
final manageSheet = find.byType(ManageChannelSheet);
|
|
expect(manageSheet, findsOneWidget);
|
|
expect(
|
|
find.descendant(
|
|
of: manageSheet,
|
|
matching: find.byKey(const ValueKey('manage-channel-name')),
|
|
),
|
|
findsOneWidget,
|
|
);
|
|
expect(
|
|
find.descendant(of: manageSheet, matching: find.text('Add Canvas')),
|
|
findsOneWidget,
|
|
);
|
|
expect(
|
|
find.descendant(of: manageSheet, matching: find.text('Mute channel')),
|
|
findsNothing,
|
|
);
|
|
expect(
|
|
find.descendant(of: manageSheet, matching: find.text('Leave channel')),
|
|
findsNothing,
|
|
);
|
|
expect(
|
|
find.descendant(of: manageSheet, matching: find.text('Topic')),
|
|
findsNothing,
|
|
);
|
|
expect(
|
|
find.descendant(of: manageSheet, matching: find.text('Purpose')),
|
|
findsNothing,
|
|
);
|
|
});
|
|
|
|
testWidgets('Manage refreshes metadata after saving from actions', (
|
|
tester,
|
|
) async {
|
|
await tester.pumpWidget(
|
|
_modalApp(
|
|
channel: _channel(),
|
|
loadMembers: () async => [
|
|
ChannelMember(
|
|
pubkey: _currentPubkey,
|
|
role: 'owner',
|
|
joinedAt: DateTime(2025),
|
|
),
|
|
],
|
|
createChannelActions: (ref) => _FakeChannelActions(
|
|
ref,
|
|
onUpdateChannel: (channelId, name, description) async {},
|
|
),
|
|
),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
await tester.tap(find.text('Open actions'));
|
|
await tester.pumpAndSettle();
|
|
await tester.tap(find.text('Manage channel'));
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(
|
|
tester.getSize(find.byType(BottomSheet).last).height,
|
|
greaterThan(480),
|
|
);
|
|
await tester.enterText(
|
|
find.byKey(const ValueKey('manage-channel-name')),
|
|
'renamed',
|
|
);
|
|
await tester.pump();
|
|
await tester.tap(find.byKey(const ValueKey('manage-channel-save-details')));
|
|
await tester.pumpAndSettle();
|
|
await tester.tap(find.text('Manage channel'));
|
|
await tester.pumpAndSettle();
|
|
final nameField = tester.widget<TextField>(
|
|
find.byKey(const ValueKey('manage-channel-name')),
|
|
);
|
|
expect(nameField.controller?.text, 'renamed');
|
|
});
|
|
|
|
testWidgets(
|
|
'native channel editor shares the profile form and retains failed drafts',
|
|
(tester) async {
|
|
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
|
|
addTearDown(() => debugDefaultTargetPlatformOverride = null);
|
|
const bridge = MethodChannel('buzz/profile_text_editor');
|
|
final presentations = <Map<Object?, Object?>>[];
|
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
|
.setMockMethodCallHandler(bridge, (call) async {
|
|
presentations.add(
|
|
Map<Object?, Object?>.from(call.arguments as Map),
|
|
);
|
|
// Cancel instead of looping if a failed save keeps re-presenting.
|
|
if (call.method != 'presentChannel' || presentations.length > 2) {
|
|
return {'action': 'cancel'};
|
|
}
|
|
return {
|
|
'action': 'save',
|
|
'name': 'renamed',
|
|
'description': 'Updated description',
|
|
};
|
|
});
|
|
addTearDown(
|
|
() => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
|
.setMockMethodCallHandler(bridge, null),
|
|
);
|
|
final updates = <(String?, String?)>[];
|
|
await tester.pumpWidget(
|
|
_modalApp(
|
|
channel: _channel(),
|
|
loadMembers: () async => [
|
|
ChannelMember(
|
|
pubkey: _currentPubkey,
|
|
role: 'owner',
|
|
joinedAt: DateTime(2025),
|
|
),
|
|
],
|
|
// Record rather than assert here: the sheet catches update errors.
|
|
createChannelActions: (ref) => _FakeChannelActions(
|
|
ref,
|
|
onUpdateChannel: (_, name, description) async {
|
|
updates.add((name, description));
|
|
if (updates.length == 1) throw Exception('offline');
|
|
},
|
|
),
|
|
),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
await tester.tap(find.text('Open actions'));
|
|
await tester.pumpAndSettle();
|
|
await tester.tap(find.text('Manage channel'));
|
|
await tester.pumpAndSettle();
|
|
expect(updates, [
|
|
('renamed', 'Updated description'),
|
|
('renamed', 'Updated description'),
|
|
]);
|
|
expect(presentations, hasLength(2));
|
|
expect(presentations.first['title'], 'Edit channel');
|
|
expect(presentations.first['multiline'], false);
|
|
expect(presentations.last['initialValue'], 'renamed');
|
|
expect(presentations.last['allowUnchangedSubmission'], true);
|
|
expect(presentations.last['description'], 'Updated description');
|
|
expect(find.byType(ManageChannelSheet), findsNothing);
|
|
debugDefaultTargetPlatformOverride = null;
|
|
},
|
|
);
|
|
|
|
testWidgets('native metadata editor opens before a cold canvas finishes', (
|
|
tester,
|
|
) async {
|
|
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
|
|
addTearDown(() => debugDefaultTargetPlatformOverride = null);
|
|
final canvas = Completer<ChannelCanvas>();
|
|
const bridge = MethodChannel('buzz/profile_text_editor');
|
|
final opened = <(bool, Object?)>[];
|
|
var saved = false;
|
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
|
.setMockMethodCallHandler(bridge, (call) async {
|
|
opened.add((
|
|
canvas.isCompleted,
|
|
(call.arguments as Map)['canvasLoaded'],
|
|
));
|
|
if (opened.length > 1) return null;
|
|
return {
|
|
'action': 'save',
|
|
'name': 'renamed',
|
|
'description': 'New description',
|
|
};
|
|
});
|
|
addTearDown(
|
|
() => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
|
.setMockMethodCallHandler(bridge, null),
|
|
);
|
|
await tester.pumpWidget(
|
|
_modalApp(
|
|
channel: _channel(),
|
|
pendingCanvas: canvas.future,
|
|
loadMembers: () async => [
|
|
ChannelMember(
|
|
pubkey: _currentPubkey,
|
|
role: 'owner',
|
|
joinedAt: DateTime(2025),
|
|
),
|
|
],
|
|
createChannelActions: (ref) => _FakeChannelActions(
|
|
ref,
|
|
onUpdateChannel: (_, name, description) async {
|
|
saved = true;
|
|
},
|
|
),
|
|
),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
await tester.tap(find.text('Open actions'));
|
|
await tester.pumpAndSettle();
|
|
await tester.tap(find.text('Manage channel'));
|
|
await tester.pump();
|
|
// Presented once, before the canvas resolved, and marked not loaded.
|
|
expect(opened, [(false, false)]);
|
|
expect(saved, isTrue);
|
|
canvas.complete(
|
|
const ChannelCanvas(content: null, updatedAt: null, authorPubkey: null),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
debugDefaultTargetPlatformOverride = null;
|
|
});
|
|
|
|
testWidgets('native canvas round trip retains both channel drafts', (
|
|
tester,
|
|
) async {
|
|
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
|
|
addTearDown(() => debugDefaultTargetPlatformOverride = null);
|
|
const bridge = MethodChannel('buzz/profile_text_editor');
|
|
final canvasPresentations = <Map<Object?, Object?>>[];
|
|
final channelPresentations = <Map<Object?, Object?>>[];
|
|
final updates = <(String?, String?)>[];
|
|
final canvases = <String>[];
|
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
|
.setMockMethodCallHandler(bridge, (call) async {
|
|
final args = Map<Object?, Object?>.from(call.arguments as Map);
|
|
if (call.method == 'present') {
|
|
canvasPresentations.add(args);
|
|
return 'New canvas';
|
|
}
|
|
channelPresentations.add(args);
|
|
// Cancel instead of looping if a failed save keeps re-presenting.
|
|
if (channelPresentations.length > 2) return {'action': 'cancel'};
|
|
return {
|
|
'action': channelPresentations.length == 1 ? 'canvas' : 'save',
|
|
'name': 'Draft name',
|
|
'description': 'Draft description',
|
|
};
|
|
});
|
|
addTearDown(
|
|
() => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
|
.setMockMethodCallHandler(bridge, null),
|
|
);
|
|
await tester.pumpWidget(
|
|
_modalApp(
|
|
channel: _channel(),
|
|
loadMembers: () async => [
|
|
ChannelMember(
|
|
pubkey: _currentPubkey,
|
|
role: 'owner',
|
|
joinedAt: DateTime(2025),
|
|
),
|
|
],
|
|
createChannelActions: (ref) => _FakeChannelActions(
|
|
ref,
|
|
// Record rather than assert here: the sheet catches save errors.
|
|
onUpdateChannel: (_, name, description) async =>
|
|
updates.add((name, description)),
|
|
onSetCanvas: (content) async => canvases.add(content),
|
|
),
|
|
),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
await tester.tap(find.text('Open actions'));
|
|
await tester.pumpAndSettle();
|
|
await tester.tap(find.text('Manage channel'));
|
|
await tester.pumpAndSettle();
|
|
expect(canvasPresentations, [containsPair('title', 'Canvas')]);
|
|
expect(canvasPresentations.single['multiline'], true);
|
|
expect(channelPresentations, hasLength(2));
|
|
expect(channelPresentations.last, {
|
|
...channelPresentations.last,
|
|
'canvasContent': 'New canvas',
|
|
'canvasLoaded': true,
|
|
'initialValue': 'Draft name',
|
|
'description': 'Draft description',
|
|
'originalName': 'general',
|
|
'originalDescription': '',
|
|
});
|
|
expect(updates, [('Draft name', 'Draft description')]);
|
|
expect(canvases, ['New canvas']);
|
|
expect(find.byType(ManageChannelSheet), findsNothing);
|
|
debugDefaultTargetPlatformOverride = null;
|
|
});
|
|
|
|
testWidgets(
|
|
'canvas preview opens its content and removal retries without losing drafts',
|
|
(tester) async {
|
|
final removals = <String>[];
|
|
await tester.pumpWidget(
|
|
_modalApp(
|
|
channel: _channel(),
|
|
canvasContent: 'Shared team notes',
|
|
loadMembers: () async => [
|
|
ChannelMember(
|
|
pubkey: _currentPubkey,
|
|
role: 'owner',
|
|
joinedAt: DateTime(2025),
|
|
),
|
|
],
|
|
createChannelActions: (ref) => _FakeChannelActions(
|
|
ref,
|
|
onSetCanvas: (content) async {
|
|
removals.add(content);
|
|
if (removals.length == 1) throw Exception('offline');
|
|
},
|
|
),
|
|
),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
await tester.tap(find.text('Open actions'));
|
|
await tester.pumpAndSettle();
|
|
await tester.tap(find.text('Manage channel'));
|
|
await tester.pumpAndSettle();
|
|
expect(find.text('Shared team notes'), findsOneWidget);
|
|
expect(find.text('Add Canvas'), findsNothing);
|
|
expect(find.text('Edit canvas'), findsNothing);
|
|
await tester.enterText(
|
|
find.byKey(const ValueKey('manage-channel-name')),
|
|
'Draft name',
|
|
);
|
|
await tester.tap(find.byKey(const ValueKey('manage-channel-canvas')));
|
|
await tester.pumpAndSettle();
|
|
expect(
|
|
tester
|
|
.widget<TextField>(find.byKey(const ValueKey('text-editor-input')))
|
|
.controller!
|
|
.text,
|
|
'Shared team notes',
|
|
);
|
|
Navigator.of(
|
|
tester.element(find.byKey(const ValueKey('text-editor-input'))),
|
|
).pop();
|
|
await tester.pumpAndSettle();
|
|
for (var attempt = 0; attempt < 2; attempt++) {
|
|
await tester.ensureVisible(find.text('Remove Canvas'));
|
|
await tester.tap(find.text('Remove Canvas'));
|
|
await tester.pumpAndSettle();
|
|
expect(removals, List.filled(attempt, ''));
|
|
await tester.tap(find.text('Remove'));
|
|
await tester.pumpAndSettle();
|
|
if (attempt == 0) {
|
|
expect(find.text('Shared team notes'), findsOneWidget);
|
|
expect(
|
|
find.text("Couldn't remove the canvas. Try again."),
|
|
findsOneWidget,
|
|
);
|
|
}
|
|
}
|
|
expect(find.text('Add Canvas'), findsOneWidget);
|
|
expect(find.text('Remove Canvas'), findsNothing);
|
|
expect(
|
|
tester
|
|
.widget<TextField>(
|
|
find.byKey(const ValueKey('manage-channel-name')),
|
|
)
|
|
.controller!
|
|
.text,
|
|
'Draft name',
|
|
);
|
|
},
|
|
);
|
|
|
|
testWidgets(
|
|
'native removal retains canvas on failure and clears after retry',
|
|
(tester) async {
|
|
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
|
|
addTearDown(() => debugDefaultTargetPlatformOverride = null);
|
|
const bridge = MethodChannel('buzz/profile_text_editor');
|
|
final presentations = <(String, Map<Object?, Object?>)>[];
|
|
final attempts = <String>[];
|
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
|
.setMockMethodCallHandler(bridge, (call) async {
|
|
presentations.add((
|
|
call.method,
|
|
Map<Object?, Object?>.from(call.arguments as Map),
|
|
));
|
|
if (presentations.length >= 3) return null;
|
|
return {
|
|
'action': 'removeCanvas',
|
|
'name': 'Draft name',
|
|
'description': 'Draft description',
|
|
};
|
|
});
|
|
addTearDown(
|
|
() => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
|
.setMockMethodCallHandler(bridge, null),
|
|
);
|
|
await tester.pumpWidget(
|
|
_modalApp(
|
|
channel: _channel(),
|
|
canvasContent: 'Existing canvas',
|
|
loadMembers: () async => [],
|
|
createChannelActions: (ref) => _FakeChannelActions(
|
|
ref,
|
|
onSetCanvas: (content) async {
|
|
attempts.add(content);
|
|
if (attempts.length == 1) throw Exception('offline');
|
|
},
|
|
),
|
|
),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
await tester.tap(find.text('Open actions'));
|
|
await tester.pumpAndSettle();
|
|
await tester.tap(find.text('Manage channel'));
|
|
await tester.pumpAndSettle();
|
|
expect(presentations.map((p) => p.$1), List.filled(3, 'presentChannel'));
|
|
expect(presentations.map((p) => p.$2['canvasLoaded']), [
|
|
true,
|
|
true,
|
|
true,
|
|
]);
|
|
expect(presentations.map((p) => p.$2['canvasContent']), [
|
|
'Existing canvas',
|
|
'Existing canvas',
|
|
'',
|
|
]);
|
|
for (final (_, args) in presentations.skip(1)) {
|
|
expect(args['initialValue'], 'Draft name');
|
|
expect(args['description'], 'Draft description');
|
|
}
|
|
expect(attempts, ['', '']);
|
|
debugDefaultTargetPlatformOverride = null;
|
|
},
|
|
);
|
|
|
|
testWidgets('non-member cannot edit canvas from Manage channel', (
|
|
tester,
|
|
) async {
|
|
await tester.pumpWidget(
|
|
_modalApp(
|
|
channel: _channel(isMember: false),
|
|
loadMembers: () async => const [],
|
|
createChannelActions: (ref) => _FakeChannelActions(ref),
|
|
),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
await tester.tap(find.text('Open actions'));
|
|
await tester.pumpAndSettle();
|
|
await tester.tap(find.text('Manage channel'));
|
|
await tester.pumpAndSettle();
|
|
|
|
final editCanvas = find.byKey(const ValueKey('manage-channel-canvas'));
|
|
expect(editCanvas, findsOneWidget);
|
|
expect(tester.widget<FilledButton>(editCanvas).onPressed, isNull);
|
|
});
|
|
|
|
testWidgets('regular members can move channels but cannot edit metadata', (
|
|
tester,
|
|
) async {
|
|
await tester.pumpWidget(
|
|
_modalApp(
|
|
channel: _channel(),
|
|
loadMembers: () async => [
|
|
ChannelMember(
|
|
pubkey: _currentPubkey,
|
|
role: 'member',
|
|
joinedAt: DateTime(2025),
|
|
),
|
|
],
|
|
createChannelActions: (ref) => _FakeChannelActions(ref),
|
|
),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
await tester.tap(find.text('Open actions'));
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(find.text('Move to section…'), findsOneWidget);
|
|
await tester.tap(find.text('Manage channel'));
|
|
await tester.pumpAndSettle();
|
|
|
|
final nameField = find.byKey(const ValueKey('manage-channel-name'));
|
|
final descriptionField = find.byKey(
|
|
const ValueKey('manage-channel-description'),
|
|
);
|
|
expect(tester.widget<TextField>(nameField).enabled, isFalse);
|
|
expect(tester.widget<TextField>(descriptionField).enabled, isFalse);
|
|
expect(
|
|
tester
|
|
.widget<FilledButton>(
|
|
find.byKey(const ValueKey('manage-channel-canvas')),
|
|
)
|
|
.onPressed,
|
|
isNotNull,
|
|
);
|
|
});
|
|
|
|
testWidgets('DM omits quick actions, then shows mute and copy rows', (
|
|
tester,
|
|
) async {
|
|
await tester.pumpWidget(
|
|
_app(
|
|
channel: _channel(type: 'dm'),
|
|
loadMembers: () async => const [],
|
|
),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
|
|
for (final label in [
|
|
'Mute channel',
|
|
'Copy channel name',
|
|
'Copy channel ID',
|
|
]) {
|
|
expect(find.text(label), findsOneWidget, reason: label);
|
|
}
|
|
for (final label in [
|
|
'Star',
|
|
'Unstar',
|
|
'Mark Unread',
|
|
'Mark Read',
|
|
'Move to section…',
|
|
'Manage channel',
|
|
'Leave channel',
|
|
'Archive channel',
|
|
'Delete channel',
|
|
]) {
|
|
expect(find.text(label), findsNothing, reason: label);
|
|
}
|
|
|
|
final muteTop = tester.getTopLeft(find.text('Mute channel')).dy;
|
|
final copyNameTop = tester.getTopLeft(find.text('Copy channel name')).dy;
|
|
final copyIdTop = tester.getTopLeft(find.text('Copy channel ID')).dy;
|
|
expect(muteTop, lessThan(copyNameTop));
|
|
expect(copyNameTop, lessThan(copyIdTop));
|
|
});
|
|
}
|
|
|
|
class _FakeChannelSectionsNotifier extends ChannelSectionsNotifier {
|
|
_FakeChannelSectionsNotifier(this._store);
|
|
|
|
final ChannelSectionStore _store;
|
|
|
|
@override
|
|
ChannelSectionsState build() =>
|
|
ChannelSectionsState(isReady: true, store: _store, version: 1);
|
|
}
|
|
|
|
class _FakeChannelActions extends ChannelActions {
|
|
final Future<void> Function(
|
|
String channelId,
|
|
String? name,
|
|
String? description,
|
|
)?
|
|
onUpdateChannel;
|
|
|
|
final Future<void> Function(String)? onSetCanvas;
|
|
|
|
_FakeChannelActions(Ref ref, {this.onUpdateChannel, this.onSetCanvas})
|
|
: super(
|
|
ref: ref,
|
|
session: ref.read(relaySessionProvider.notifier),
|
|
signedEventRelay: SignedEventRelay(
|
|
session: ref.read(relaySessionProvider.notifier),
|
|
nsec: null,
|
|
),
|
|
currentPubkey: _currentPubkey,
|
|
);
|
|
|
|
@override
|
|
Future<void> updateChannel({
|
|
required String channelId,
|
|
String? name,
|
|
String? description,
|
|
}) async {
|
|
await onUpdateChannel?.call(channelId, name, description);
|
|
}
|
|
|
|
@override
|
|
Future<void> setCanvas({
|
|
required String channelId,
|
|
required String content,
|
|
}) async {
|
|
await onSetCanvas?.call(content);
|
|
}
|
|
|
|
String? unarchivedChannelId;
|
|
|
|
@override
|
|
Future<void> leaveChannel(String channelId) async {}
|
|
|
|
@override
|
|
Future<void> unarchiveChannel(String channelId) async {
|
|
unarchivedChannelId = channelId;
|
|
}
|
|
}
|