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
6 changed files with 5348 additions and 2709 deletions
|
|
@ -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]);
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -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 '
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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