Files
buzz/mobile/test/features/channels/channel_actions_sheet_test.dart
Wes edbfbf08db test(mobile): journey-first testing guidance and suite cleanup (#8397)
## 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>
2026-10-19 17:19:07 -06:00

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;
}
}