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:
parent
24ed63585e
commit
cc87120bc8
2 changed files with 5348 additions and 2709 deletions
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue