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
2 changed files with 5348 additions and 2709 deletions

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