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

@ -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 {