feat(app): the operator's overview of screens, grants to several people at once with one budget each, assistants by provider

Halkyon Learn's ADR 0021, D57 to D63, on halkyon_base's budgets and
effort:

- The operator's screen is an overview (Waiting, Operators, Accounts,
  Assistants, each with its count) whose rows open screens of their own,
  read 50 at a time as they scroll (OperatorListCubit, OperatorPage), so
  hundreds of accounts push nothing else down (D62).
- A handle is picked among the accounts there are (AccountSuggestionField):
  appointing an operator and granting take no other (D61).
- AssistantGrantsScreen replaces AssistantGrantsCard: the assistants set
  up by provider, with their efforts and prices; the people granted, found
  by their handle's start; one sheet granting several assistants, a
  provider's at once, to several people, each with one budget in money and
  tokens a day and a month, or changing one person's (D58, D59).
- Assistants are grouped by provider wherever they are listed, each under
  its name with a dot in its colour (ProviderLabel, D60).
- AssistantsCard shows what is left of the person's budget for all their
  assistants together; the months of the figures run oldest first (D57).
- AssistantChoice says the efforts a question may ask at and the ceiling
  on output each asks for (D63); a person's own key gets none and keeps
  4,096 tokens.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-26 16:05:49 +01:00
commit cc87120bc8
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
6 changed files with 5348 additions and 2709 deletions

View file

@ -1,182 +0,0 @@
import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_app/testing.dart';
/// The operator's screen (Halkyon Learn's ADR 0021, D21 to D24): the
/// accounts that wait, who operates, accounts found by handle, and each
/// action one at a time.
void main() {
late FakeInstanceOperatorGateway gateway;
late InstanceOperatorCubit cubit;
final anna = OperatedAccount(
id: 'anna',
handle: 'anna',
since: DateTime.utc(2026, 10),
isOperator: true,
);
final ben = OperatedAccount(
id: 'ben',
handle: 'ben',
since: DateTime.utc(2026, 10, 24),
waitingSince: DateTime.utc(2026, 10, 24, 9),
words: const ['abandon', 'ability', 'able', 'about'],
);
final bea = OperatedAccount(
id: 'bea',
handle: 'bea',
since: DateTime.utc(2026, 10, 2),
);
setUp(() {
gateway = FakeInstanceOperatorGateway()
..operates = true
..add(anna)
..add(ben)
..add(bea);
final operators = InstanceOperatorRepository(gateway);
cubit = InstanceOperatorCubit(operators);
addTearDown(() async {
await cubit.close();
await operators.dispose();
});
});
test('reads who waits and who operates', () async {
expect(cubit.state.loading, isTrue);
await cubit.load();
expect(cubit.state.loading, isFalse);
expect(cubit.state.waiting, [ben]);
expect(cubit.state.operators, [anna]);
});
test(
"can't read them where the person doesn't operate the instance",
() async {
gateway.operates = false;
await cubit.load();
expect(
cubit.state.unreadable,
const OperatorFailed(OperatorProblem.notAllowed),
);
expect(cubit.state.waiting, isEmpty);
},
);
test('approving reads the lists anew: the account no longer waits', () async {
await cubit.load();
await cubit.approve('ben');
expect(cubit.state.waiting, isEmpty);
expect(cubit.state.busy, isNull);
expect(cubit.state.problem, isNull);
expect(gateway.calls, contains('approve ben'));
});
test('one action at a time: another asked meanwhile is ignored', () async {
await cubit.load();
final approving = cubit.approve('ben');
expect(cubit.state.busy, 'ben');
await cubit.suspend('bea');
await approving;
expect(gateway.calls, isNot(contains('suspend bea')));
expect(cubit.state.busy, isNull);
});
test('a refusal is said, nothing changed, and the next action clears '
'it', () async {
await cubit.load();
await cubit.suspend('anna');
expect(
cubit.state.problem,
const OperatorFailed(OperatorProblem.isOperator),
);
expect(cubit.state.operators, [anna]);
await cubit.takeBack('anna');
expect(
cubit.state.problem,
const OperatorFailed(OperatorProblem.lastOperator),
);
await cubit.approve('ben');
expect(cubit.state.problem, isNull);
});
test('finds accounts as a handle is typed, and finds them anew after an '
'action', () async {
await cubit.load();
await cubit.find('be');
expect(cubit.state.found, [bea, ben]);
await cubit.suspend('bea');
expect(cubit.state.query, 'be');
expect(cubit.state.found.first.suspended, isTrue);
expect(cubit.state.waiting.map((each) => each.id), ['ben', 'bea']);
});
test('an older search answering late is dropped', () async {
await cubit.load();
final first = cubit.find('b');
final second = cubit.find('an');
await second;
await first;
expect(cubit.state.query, 'an');
expect(cubit.state.found, [anna]);
});
test('naming an operator by handle names and approves them', () async {
await cubit.load();
final naming = cubit.appoint('@ben');
expect(cubit.state.busy, '@ben');
await naming;
expect(cubit.state.operators.map((each) => each.id), ['anna', 'ben']);
expect(cubit.state.waiting, isEmpty);
});
test('a handle nobody has is refused', () async {
await cubit.load();
await cubit.appoint('cleo');
expect(cubit.state.problem, const OperatorFailed(OperatorProblem.notFound));
});
test('refusing a new account deletes it; a suspended one is never '
'refused, only approved', () async {
await cubit.load();
await cubit.refuse('ben');
expect(gateway.accounts, isNot(contains('ben')));
await cubit.suspend('bea');
await cubit.refuse('bea');
expect(cubit.state.problem, const OperatorFailed(OperatorProblem.notFound));
expect(gateway.accounts, contains('bea'));
});
test('the instance out of reach fails as such', () async {
await cubit.load();
gateway.failing = const OperatorFailed(OperatorProblem.failed);
unawaited(cubit.approve('ben'));
await pumpEventQueue();
expect(cubit.state.problem, const OperatorFailed(OperatorProblem.failed));
expect(
cubit.state.unreadable,
const OperatorFailed(OperatorProblem.failed),
);
// Read again once it answers.
gateway.failing = null;
await cubit.load();
expect(cubit.state.unreadable, isNull);
expect(cubit.state.waiting, [ben]);
});
}

View file

@ -0,0 +1,339 @@
import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_app/testing.dart';
/// The operator's screens (Halkyon Learn's ADR 0021, D21 to D24, D61, D62):
/// the overview's counts, each list a page after another, found by the
/// start of a handle where it can be, each action one at a time with the
/// account it changed in its place; and the handles suggested as they are
/// typed, only of accounts there are.
void main() {
late FakeInstanceOperatorGateway gateway;
late InstanceOperatorRepository operators;
OperatedAccount account(
String handle, {
bool operates = false,
DateTime? waitingSince,
}) => OperatedAccount(
id: 'id-$handle',
handle: handle,
since: DateTime.utc(2026, 10),
isOperator: operates,
waitingSince: waitingSince,
);
setUp(() {
gateway = FakeInstanceOperatorGateway()..operates = true;
operators = InstanceOperatorRepository(gateway);
addTearDown(operators.dispose);
});
group('OperatorListCubit', () {
late OperatorListCubit<OperatedAccount> cubit;
/// [count] accounts waiting, each a minute after the one before.
void waiting(int count) {
for (var each = 0; each < count; each++) {
gateway.add(
account(
'p${'$each'.padLeft(3, '0')}',
waitingSince: DateTime.utc(2026, 10, 1, 0, each),
),
);
}
}
setUp(() {
cubit = OperatorListCubit(
(query, offset) =>
operators.accountsStartingWith(query, offset: offset),
(account) => account.id,
);
addTearDown(cubit.close);
});
test('reads the first page, and says whether more may follow', () async {
waiting(operatorPageSize + 10);
expect(cubit.state.loading, isTrue);
await cubit.load();
expect(cubit.state.loading, isFalse);
expect(cubit.state.items, hasLength(operatorPageSize));
expect(cubit.state.more, isTrue);
expect(gateway.calls, ['accounts 0']);
});
test('reads the next page from where the last ended, then no '
'more', () async {
waiting(operatorPageSize + 10);
await cubit.load();
await cubit.more();
expect(cubit.state.items, hasLength(operatorPageSize + 10));
expect(cubit.state.items.last.handle, 'p059');
expect(cubit.state.more, isFalse);
expect(gateway.calls.last, 'accounts $operatorPageSize');
await cubit.more();
expect(gateway.calls, hasLength(2), reason: 'nothing more to read');
});
test('reads one page at a time, however often asked', () async {
waiting(operatorPageSize * 3);
await cubit.load();
await Future.wait<void>([cubit.more(), cubit.more()]);
expect(cubit.state.items, hasLength(operatorPageSize * 2));
expect(gateway.calls, hasLength(2));
});
test('a page fewer than full is the last', () async {
waiting(3);
await cubit.load();
expect(cubit.state.more, isFalse);
await cubit.more();
expect(gateway.calls, hasLength(1));
});
test('finds by the start of a handle, from its first page, and drops '
'an answer come too late', () async {
gateway
..add(account('anna'))
..add(account('ben'));
await cubit.load();
await Future.wait<void>([cubit.find('a'), cubit.find('b')]);
expect(cubit.state.query, 'b');
expect([for (final each in cubit.state.items) each.handle], ['ben']);
});
test("can't read a list where the person doesn't operate the "
'instance', () async {
gateway.operates = false;
await cubit.load();
expect(
cubit.state.unreadable,
const OperatorFailed(OperatorProblem.notAllowed),
);
expect(cubit.state.items, isEmpty);
});
test('an action puts what it made of the account in its place, or takes '
'it off the list; the next page reads on from there', () async {
waiting(operatorPageSize + 1);
final list = OperatorListCubit<OperatedAccount>(
(query, offset) => operators.waiting(offset: offset),
(account) => account.id,
);
addTearDown(list.close);
await list.load();
final [first, second, ...] = list.state.items;
await list.act(
first.id,
() => operators.approve(first.id),
after: (account, failed) => null,
);
final renamed = account('renamed');
await list.act(
second.id,
Future<void>.value,
after: (account, failed) => renamed,
);
expect(list.state.items, hasLength(operatorPageSize - 1));
expect(list.state.items, isNot(contains(first)));
expect(list.state.items.first, renamed);
expect(list.state.busy, isNull);
await list.more();
expect(
gateway.calls.last,
'waiting ${operatorPageSize - 1}',
reason: 'the instance lists one fewer too',
);
expect(list.state.items, hasLength(operatorPageSize));
expect(list.state.more, isFalse);
});
test('a failed action is the problem, and the account is as after '
'it', () async {
final anna = account('anna');
gateway.add(anna);
await cubit.load();
OperatorFailed? seen;
await cubit.act(
anna.id,
() => operators.suspend('id-nobody'),
after: (account, failed) {
seen = failed;
return account;
},
);
expect(seen, const OperatorFailed(OperatorProblem.notFound));
expect(
cubit.state.problem,
const OperatorFailed(OperatorProblem.notFound),
);
expect(cubit.state.items, [anna]);
});
test('one action at a time', () async {
final anna = account('anna');
gateway.add(anna);
await cubit.load();
final release = Completer<void>();
final first = cubit.act(
anna.id,
() => release.future,
after: (account, failed) => account,
);
expect(cubit.state.busy, anna.id);
var second = false;
await cubit.act(
anna.id,
() async => second = true,
after: (a, f) => a,
);
release.complete();
await first;
expect(second, isFalse);
expect(cubit.state.busy, isNull);
});
});
group('OperatorOverviewCubit', () {
late FakeAssistantOperatorGateway assistants;
late AssistantOperatorRepository assistantOperators;
late OperatorOverviewCubit cubit;
setUp(() {
gateway
..add(account('anna', operates: true))
..add(account('ben', waitingSince: DateTime.utc(2026, 10, 24)))
..add(account('bea'));
assistants = FakeAssistantOperatorGateway()
..set = const AssistantsSetUp(
assistants: [
SetUpAssistant(
id: 'claude',
name: 'Claude',
protocol: 'anthropic',
host: 'api.anthropic.com',
model: 'claude-sonnet-5-5',
),
],
)
..accounts.addAll({'id-bea': 'bea'});
assistantOperators = AssistantOperatorRepository(assistants, gateway);
cubit = OperatorOverviewCubit(operators, assistantOperators);
addTearDown(() async {
await cubit.close();
await assistantOperators.dispose();
});
});
test('counts who waits, operates and has an account, and the '
'assistants set', () async {
await cubit.load();
expect(cubit.state.loading, isFalse);
expect(
cubit.state.counts,
const OperatorCounts(waiting: 1, operators: 1, accounts: 3),
);
expect(cubit.state.setUp.assistants.single.id, 'claude');
expect(cubit.state.setUp.people, 0);
});
test('counts anew after each change made on its screens', () async {
await cubit.load();
await operators.approve('id-ben');
await assistantOperators.grant(
accounts: ['id-bea'],
assistants: ['claude'],
budget: AssistantBudget.none,
);
await pumpEventQueue();
expect(cubit.state.counts.waiting, 0);
expect(cubit.state.setUp.people, 1);
});
test("reads nothing where the person doesn't operate the "
'instance', () async {
gateway.operates = false;
await cubit.load();
expect(
cubit.state.unreadable,
const OperatorFailed(OperatorProblem.notAllowed),
);
});
});
group('AccountSuggestionsCubit', () {
late AccountSuggestionsCubit cubit;
setUp(() {
gateway
..add(account('anna', operates: true))
..add(account('annabel'))
..add(account('ben'));
cubit = AccountSuggestionsCubit(operators.appointable);
addTearDown(cubit.close);
});
test('suggests as typed, and matches only a handle there is', () async {
await cubit.type('ann');
expect(
[for (final each in cubit.state.found) each.handle],
[
'annabel',
],
reason: 'anna operates already',
);
expect(cubit.state.match, isNull, reason: 'only the start of one');
await cubit.type('@Annabel ');
expect(cubit.state.match?.id, 'id-annabel');
await cubit.type('annabelle');
expect(cubit.state.found, isEmpty);
expect(cubit.state.match, isNull);
});
test('drops an answer come too late, and suggests nothing for nothing '
'typed', () async {
await Future.wait<void>([cubit.type('ann'), cubit.type('')]);
expect(cubit.state.typed, '');
expect(cubit.state.found, isEmpty);
});
test('a failure suggests nothing', () async {
gateway.failing = const OperatorFailed(OperatorProblem.failed);
await cubit.type('ann');
expect(cubit.state.found, isEmpty);
expect(cubit.state.match, isNull);
});
});
}

View file

@ -3,7 +3,8 @@ import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_app/testing.dart';
/// What the app's screens ask of the instance's operators' calls (Halkyon
/// Learn's ADR 0021, D21 to D24), and the changes they say were made.
/// Learn's ADR 0021, D21 to D24, D61, D62), and the changes they say were
/// made.
void main() {
late FakeInstanceOperatorGateway gateway;
late InstanceOperatorRepository operators;
@ -32,16 +33,48 @@ void main() {
addTearDown(operators.dispose);
});
test('a handle is found and named with or without its @, and nothing '
'typed asks nothing', () async {
test('a handle is found and named with or without its @; nothing typed '
'lists every account, a page from where the last ended', () async {
expect(
(await operators.accountsStartingWith(' @be ')).map((each) => each.id),
['ben'],
);
expect(await operators.accountsStartingWith(' @ '), isEmpty);
expect(
(await operators.accountsStartingWith(' @ ')).map((each) => each.id),
['anna', 'ben'],
);
await operators.accountsStartingWith('', offset: 50);
await operators.waiting(offset: 50);
await operators.appoint('@ben');
expect(gateway.calls, ['accounts be', 'appoint ben']);
expect(gateway.calls, [
'accounts be 0',
'accounts 0',
'accounts 50',
'waiting 50',
'appoint ben',
]);
});
test('counts who waits, operates and has an account', () async {
expect(
await operators.counts(),
const OperatorCounts(waiting: 1, operators: 1, accounts: 2),
);
});
test('suggests the accounts to name an operator as a handle is typed, '
'not those operating already; nothing typed asks nothing', () async {
expect(
(await operators.appointable('@B')).map((each) => each.id),
['ben'],
reason: 'one that waits is approved with it',
);
expect(await operators.appointable('an'), isEmpty);
gateway.calls.clear();
expect(await operators.appointable(' @ '), isEmpty);
expect(gateway.calls, isEmpty);
});
test('every change says so, for the count to be asked anew; reading '

View file

@ -2,7 +2,11 @@ import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_base_client/halkyon_base_client.dart';
import 'package:halkyon_base_client/halkyon_base_client.dart'
hide OperatorCounts;
import 'package:halkyon_base_client/halkyon_base_client.dart'
as protocol
show OperatorCounts;
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
@ -66,6 +70,12 @@ final class _Instance() {
jsonEncode(waiting),
200,
),
'halkyon_base.instanceOperator/counts' => http.Response(
jsonEncode(
protocol.OperatorCounts(waiting: 1, operators: 1, accounts: 2),
),
200,
),
'halkyon_base.instanceOperator/waiting' ||
'halkyon_base.instanceOperator/accounts' ||
'halkyon_base.instanceOperator/operators' => http.Response(
@ -111,7 +121,7 @@ void main() {
test('reads accounts as the instance knows them, words while they wait, '
'and never more', () async {
final waiting = await gateway.waiting();
final waiting = await gateway.waiting(offset: 0);
expect(waiting, [
OperatedAccount(
@ -130,10 +140,24 @@ void main() {
expect(waiting.last.waits, isTrue);
expect(waiting.first.waits, isFalse);
expect(await gateway.operators(), waiting);
expect(await gateway.accountsStartingWith('an'), waiting);
expect(await gateway.accountsStartingWith('an', offset: 50), waiting);
expect(
jsonDecode(instance.bodies['halkyon_base.instanceOperator/accounts']!),
{'startingWith': 'an'},
{'startingWith': 'an', 'offset': 50},
);
});
test('reads a page of those waiting from where the last ended, and how '
'many there are of each', () async {
await gateway.waiting(offset: 100);
expect(
jsonDecode(instance.bodies['halkyon_base.instanceOperator/waiting']!),
{'offset': 100},
);
expect(
await gateway.counts(),
const OperatorCounts(waiting: 1, operators: 1, accounts: 2),
);
});
@ -186,10 +210,16 @@ void main() {
test("someone who doesn't operate the instance, or no longer, is not "
'allowed: without the scope, or without a session', () async {
instance.refusals['halkyon_base.instanceOperator/waiting'] = 403;
await expectLater(gateway.waiting(), failsWith(OperatorProblem.notAllowed));
await expectLater(
gateway.waiting(offset: 0),
failsWith(OperatorProblem.notAllowed),
);
instance.refusals['halkyon_base.instanceOperator/waiting'] = 401;
await expectLater(gateway.waiting(), failsWith(OperatorProblem.notAllowed));
await expectLater(
gateway.waiting(offset: 0),
failsWith(OperatorProblem.notAllowed),
);
});
test('an instance out of reach or failing fails, and says no more', () async {

View file

@ -81,8 +81,10 @@ void main() {
await tester.tap(find.text('Accounts and operators'));
await tester.pumpAndSettle();
expect(find.text('2 accounts wait for approval'), findsOneWidget);
await tester.tap(find.text('Waiting'));
await tester.pumpAndSettle();
expect(find.text('Waiting'), findsOneWidget);
expect(find.text('@ben'), findsOneWidget);
expect(find.text('@cleo'), findsOneWidget);
});

View file

@ -3,9 +3,11 @@ import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_app/testing.dart';
import 'package:material_ui/material_ui.dart';
/// The operator's screen (Halkyon Learn's ADR 0021, D21 to D24, D33):
/// approving once the words read out were confirmed, refusing, suspending,
/// naming operators and taking the role back.
/// The operator's screens (Halkyon Learn's ADR 0021, D21 to D24, D33, D61,
/// D62): an overview with a row for each list, however long; approving
/// once the words read out were confirmed, refusing, suspending, naming
/// operators by a handle suggested and taking the role back; and every
/// list read a page at a time.
void main() {
late TestAppDependencies dependencies;
late FakeInstanceOperatorGateway gateway;
@ -54,167 +56,317 @@ void main() {
await tester.pumpAndSettle();
}
testWidgets('lists who waits, since when, and who operates', (tester) async {
await show(tester);
expect(find.text('This instance'), findsOneWidget);
expect(find.text('@ben'), findsOneWidget);
expect(find.text('New, waiting since Oct 24, 2026'), findsOneWidget);
expect(find.text('@anna'), findsOneWidget);
expect(find.text('Take back'), findsOneWidget);
expect(find.text('abandon'), findsNothing, reason: 'shown on asking');
});
testWidgets('a waiting account shows its words to read out, and is '
'approved only once asked to (D33)', (tester) async {
await show(tester);
await tap(tester, find.text('@ben'));
for (final word in ben.words!) {
expect(find.text(word), findsOneWidget);
}
expect(
find.text(
'Read them these four words, and approve only once they confirm '
'these are on their screen.',
),
findsOneWidget,
);
await tap(tester, find.text('Cancel'));
expect(gateway.calls, isNot(contains('approve ben')));
await tap(tester, find.text('@ben'));
await tap(tester, find.text('Approve'));
expect(gateway.calls, contains('approve ben'));
expect(find.text('Nobody waits.'), findsOneWidget);
});
testWidgets('refusing a new account asks first, then deletes it', (
tester,
) async {
await show(tester);
await tap(tester, find.text('@ben'));
await tap(tester, find.widgetWithText(TextButton, 'Refuse'));
expect(find.text('Refuse this account?'), findsOneWidget);
expect(
find.text(
'It is deleted with everything it has. They can sign up again.',
),
findsOneWidget,
);
await tap(tester, find.widgetWithText(FilledButton, 'Refuse'));
expect(gateway.accounts, isNot(contains('ben')));
expect(find.text('Nobody waits.'), findsOneWidget);
});
testWidgets('a suspended account is found, suspended after asking, and '
'waits; it can be approved, never refused', (tester) async {
await show(tester);
await tester.enterText(find.byType(TextField), 'bea');
Future<void> back(WidgetTester tester) async {
await tester.pageBack();
await tester.pumpAndSettle();
expect(find.text('Since Oct 2, 2026'), findsOneWidget);
}
await tap(tester, find.byTooltip('Show menu'));
await tap(tester, find.text('Suspend').last);
expect(find.text('Suspend this account?'), findsOneWidget);
await tap(tester, find.widgetWithText(FilledButton, 'Suspend'));
group('the overview', () {
testWidgets('counts who waits, operates and has an account, a row each '
'leading to its list', (tester) async {
await show(tester);
expect(gateway.calls, contains('suspend bea'));
expect(find.text('Suspended since Oct 25, 2026'), findsOneWidget);
expect(find.text('Since Oct 2, 2026 · Suspended'), findsOneWidget);
expect(find.text('This instance'), findsOneWidget);
expect(find.text('1 account waits for approval'), findsOneWidget);
expect(find.text('1 operator'), findsOneWidget);
expect(find.text('3 accounts'), findsOneWidget);
expect(find.text('@ben'), findsNothing, reason: 'on its own screen');
expect(find.text('Assistants'), findsNothing, reason: 'none set up');
});
await tap(tester, find.text('Suspended since Oct 25, 2026'));
expect(find.text('zoo'), findsOneWidget);
expect(find.widgetWithText(TextButton, 'Refuse'), findsNothing);
testWidgets('a row for the assistants where one is set up', (
tester,
) async {
dependencies.assistantOperatorGateway.set = const AssistantsSetUp(
assistants: [
SetUpAssistant(
id: 'claude',
name: 'Claude',
protocol: 'anthropic',
host: 'api.anthropic.com',
model: 'claude-sonnet-5-5',
),
],
);
await show(tester);
expect(find.text('Assistants'), findsOneWidget);
expect(find.text('1 assistant · nobody granted'), findsOneWidget);
});
testWidgets("someone who doesn't operate the instance is shown nothing "
'of its accounts', (tester) async {
gateway.operates = false;
await show(tester);
expect(
find.text("Only the instance's operators can do this."),
findsOneWidget,
);
expect(find.text('Waiting'), findsNothing);
expect(find.text('Try again'), findsOneWidget);
});
testWidgets('an instance out of reach is said so, and read again', (
tester,
) async {
gateway.failing = const OperatorFailed(OperatorProblem.failed);
await show(tester);
expect(find.text("Couldn't reach the server."), findsOneWidget);
gateway.failing = null;
await tap(tester, find.text('Try again'));
expect(find.text('1 account waits for approval'), findsOneWidget);
});
});
testWidgets('an operator is neither suspended nor offered it', (
tester,
) async {
await show(tester);
group('waiting', () {
testWidgets('lists who waits and since when; the words only on '
'asking', (tester) async {
await show(tester);
await tap(tester, find.text('Waiting'));
await tester.enterText(find.byType(TextField), '@an');
await tester.pumpAndSettle();
expect(find.text('@ben'), findsOneWidget);
expect(find.text('New, waiting since Oct 24, 2026'), findsOneWidget);
expect(find.text('abandon'), findsNothing);
});
expect(find.text('Since Oct 1, 2026 · Operator'), findsOneWidget);
expect(find.byTooltip('Show menu'), findsNothing);
testWidgets('a waiting account shows its words to read out, and is '
'approved only once asked to (D33)', (tester) async {
await show(tester);
await tap(tester, find.text('Waiting'));
await tap(tester, find.text('@ben'));
for (final word in ben.words!) {
expect(find.text(word), findsOneWidget);
}
expect(
find.text(
'Read them these four words, and approve only once they confirm '
'these are on their screen.',
),
findsOneWidget,
);
await tap(tester, find.text('Cancel'));
expect(gateway.calls, isNot(contains('approve ben')));
await tap(tester, find.text('@ben'));
await tap(tester, find.text('Approve'));
expect(gateway.calls, contains('approve ben'));
expect(find.text('Nobody waits.'), findsOneWidget);
await back(tester);
expect(find.text('Nobody waits for approval'), findsOneWidget);
});
testWidgets('refusing a new account asks first, then deletes it', (
tester,
) async {
await show(tester);
await tap(tester, find.text('Waiting'));
await tap(tester, find.text('@ben'));
await tap(tester, find.widgetWithText(TextButton, 'Refuse'));
expect(find.text('Refuse this account?'), findsOneWidget);
expect(
find.text(
'It is deleted with everything it has. They can sign up again.',
),
findsOneWidget,
);
await tap(tester, find.widgetWithText(FilledButton, 'Refuse'));
expect(gateway.accounts, isNot(contains('ben')));
expect(find.text('Nobody waits.'), findsOneWidget);
});
testWidgets('a long list is read a page at a time, the next as its end '
'comes near', (tester) async {
for (var each = 0; each < operatorPageSize + 20; each++) {
gateway.add(
OperatedAccount(
id: 'p$each',
handle: 'p${'$each'.padLeft(3, '0')}',
since: DateTime.utc(2026, 10),
waitingSince: DateTime.utc(2026, 10, 25, 0, each),
),
);
}
await show(tester);
await tap(tester, find.text('Waiting'));
expect(gateway.calls.where((call) => call.startsWith('waiting ')), [
'waiting 0',
]);
await tester.drag(find.byType(ListView), const Offset(0, -6000));
await tester.pumpAndSettle();
expect(gateway.calls.where((call) => call.startsWith('waiting ')), [
'waiting 0',
'waiting $operatorPageSize',
]);
await tester.drag(find.byType(ListView), const Offset(0, -6000));
await tester.pumpAndSettle();
expect(find.text('@p069'), findsOneWidget);
});
});
testWidgets('a handle nobody has is said so', (tester) async {
await show(tester);
group('accounts', () {
testWidgets('lists every account by handle, and finds them by its '
'start', (tester) async {
await show(tester);
await tap(tester, find.text('Accounts'));
await tester.enterText(find.byType(TextField), 'zed');
await tester.pumpAndSettle();
for (final handle in ['@anna', '@bea', '@ben']) {
expect(find.text(handle), findsOneWidget);
}
expect(find.text("No account's handle starts like this."), findsOneWidget);
await tester.enterText(find.byType(TextField), '@be');
await tester.pumpAndSettle();
expect(find.text('@anna'), findsNothing);
expect(find.text('@bea'), findsOneWidget);
await tester.enterText(find.byType(TextField), 'zed');
await tester.pumpAndSettle();
expect(
find.text("No account's handle starts like this."),
findsOneWidget,
);
});
testWidgets('an account is suspended after asking, in its place, and '
'then waits', (tester) async {
await show(tester);
await tap(tester, find.text('Accounts'));
await tester.enterText(find.byType(TextField), 'bea');
await tester.pumpAndSettle();
expect(find.text('Since Oct 2, 2026'), findsOneWidget);
await tap(tester, find.byTooltip('Show menu'));
await tap(tester, find.text('Suspend').last);
expect(find.text('Suspend this account?'), findsOneWidget);
await tap(tester, find.widgetWithText(FilledButton, 'Suspend'));
expect(gateway.calls, contains('suspend bea'));
expect(find.text('Since Oct 2, 2026 · Suspended'), findsOneWidget);
await back(tester);
expect(find.text('2 accounts wait for approval'), findsOneWidget);
await tap(tester, find.text('Waiting'));
await tap(tester, find.text('Suspended since Oct 25, 2026'));
expect(find.text('zoo'), findsOneWidget);
expect(find.widgetWithText(TextButton, 'Refuse'), findsNothing);
});
testWidgets('an account is named an operator from its menu', (
tester,
) async {
await show(tester);
await tap(tester, find.text('Accounts'));
await tester.enterText(find.byType(TextField), 'bea');
await tester.pumpAndSettle();
await tap(tester, find.byTooltip('Show menu'));
await tap(tester, find.text('Name as operator'));
expect(gateway.accounts['bea']!.isOperator, isTrue);
expect(find.text('Since Oct 2, 2026 · Operator'), findsOneWidget);
});
testWidgets('an operator is neither suspended nor offered it', (
tester,
) async {
await show(tester);
await tap(tester, find.text('Accounts'));
await tester.enterText(find.byType(TextField), '@an');
await tester.pumpAndSettle();
expect(find.text('Since Oct 1, 2026 · Operator'), findsOneWidget);
expect(find.byTooltip('Show menu'), findsNothing);
});
});
testWidgets('naming an operator by handle, and taking the role back after '
'asking', (tester) async {
await show(tester);
group('operators', () {
testWidgets('names an operator only by a handle suggested as it is typed '
'(D61), then takes the role back after asking', (tester) async {
await show(tester);
await tap(tester, find.text('Operators'));
await tap(tester, find.text('Name an operator'));
expect(
find.text(
'They approve, refuse and suspend accounts here, and name '
'operators, as you do.',
),
findsOneWidget,
);
await tester.enterText(find.byType(TextField).last, 'bea');
await tap(tester, find.widgetWithText(FilledButton, 'Name'));
await tap(tester, find.text('Name an operator'));
expect(
find.text(
'They approve, refuse and suspend accounts here, and name '
'operators, as you do.',
),
findsOneWidget,
);
final name = find.widgetWithText(FilledButton, 'Name');
await tester.enterText(find.byType(TextField).last, 'be');
await tester.pumpAndSettle();
expect(find.text('@bea'), findsOneWidget);
expect(find.text('@ben'), findsOneWidget, reason: 'approved with it');
expect(tester.widget<FilledButton>(name).onPressed, isNull);
expect(gateway.accounts['bea']!.isOperator, isTrue);
expect(find.text('Take back'), findsNWidgets(2));
await tap(tester, find.text('@bea'));
expect(tester.widget<FilledButton>(name).onPressed, isNotNull);
await tap(tester, name);
await tap(tester, find.text('Take back').last);
expect(find.text('Take back the role?'), findsOneWidget);
await tap(tester, find.widgetWithText(FilledButton, 'Take back'));
expect(gateway.calls, contains('appoint bea'));
expect(gateway.accounts['bea']!.isOperator, isTrue);
expect(find.text('Take back'), findsNWidgets(2));
expect(gateway.accounts['bea']!.isOperator, isFalse);
});
await tap(tester, find.text('Take back').last);
expect(find.text('Take back the role?'), findsOneWidget);
await tap(tester, find.widgetWithText(FilledButton, 'Take back'));
testWidgets('the last operator stays, and the screen says why', (
tester,
) async {
await show(tester);
expect(gateway.accounts['bea']!.isOperator, isFalse);
expect(find.text('Take back'), findsOneWidget);
});
await tap(tester, find.text('Take back'));
await tap(tester, find.widgetWithText(FilledButton, 'Take back'));
testWidgets('a handle typed whole is the one picked; one nobody has '
"can't be named", (tester) async {
await show(tester);
await tap(tester, find.text('Operators'));
await tap(tester, find.text('Name an operator'));
final name = find.widgetWithText(FilledButton, 'Name');
expect(
find.text('The instance needs at least one operator.'),
findsOneWidget,
);
expect(gateway.accounts['anna']!.isOperator, isTrue);
});
await tester.enterText(find.byType(TextField).last, 'zed');
await tester.pumpAndSettle();
expect(
find.text("No account's handle starts like this."),
findsOneWidget,
);
expect(tester.widget<FilledButton>(name).onPressed, isNull);
testWidgets("someone who doesn't operate the instance is shown nothing of "
'its accounts', (tester) async {
gateway.operates = false;
await show(tester);
await tester.enterText(find.byType(TextField).last, '@Bea');
await tester.pumpAndSettle();
expect(tester.widget<FilledButton>(name).onPressed, isNotNull);
expect(
find.text("Only the instance's operators can do this."),
findsOneWidget,
);
expect(find.text('@ben'), findsNothing);
expect(find.text('Try again'), findsOneWidget);
});
await tester.enterText(find.byType(TextField).last, 'anna');
await tester.pumpAndSettle();
expect(
tester.widget<FilledButton>(name).onPressed,
isNull,
reason: 'operates already',
);
});
testWidgets('an instance out of reach is said so, and read again', (
tester,
) async {
gateway.failing = const OperatorFailed(OperatorProblem.failed);
await show(tester);
expect(find.text("Couldn't reach the server."), findsOneWidget);
testWidgets('the last operator stays, and the screen says why', (
tester,
) async {
await show(tester);
await tap(tester, find.text('Operators'));
gateway.failing = null;
await tap(tester, find.text('Try again'));
expect(find.text('@ben'), findsOneWidget);
await tap(tester, find.text('Take back'));
await tap(tester, find.widgetWithText(FilledButton, 'Take back'));
expect(
find.text('The instance needs at least one operator.'),
findsOneWidget,
);
expect(gateway.accounts['anna']!.isOperator, isTrue);
expect(find.text('@anna'), findsOneWidget);
});
});
}