halkyon/packages/halkyon_app/test/assistant/bloc/assistant_cubits_test.dart
Tizian 「ティツィアーン」 cc87120bc8
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>
2026-10-26 16:21:53 +01:00

338 lines
9.8 KiB
Dart

import 'package:clock/clock.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_app/testing.dart';
import 'package:halkyon_llm/halkyon_llm.dart';
// The person's assistants in an app's settings, the operator granting them
// to several people at once with one budget each (D58), and the use by
// month, the oldest on the left (Halkyon Learn's ADR 0021, step 2, D57).
const _claude = OfferedAssistant(
id: 'claude',
name: 'Claude',
protocol: 'anthropic',
host: 'api.anthropic.com',
model: 'claude-sonnet-5-5',
);
const _own = OwnAssistant(
id: 'own-1',
name: 'My GPT',
protocol: LlmProtocol.openai,
model: 'gpt-5',
apiKey: 'sk-own',
);
const _setUp = AssistantsSetUp(
assistants: [
SetUpAssistant(
id: 'claude',
name: 'Claude',
protocol: 'anthropic',
host: 'api.anthropic.com',
model: 'claude-sonnet-5-5',
priceInput: 3,
priceOutput: 15,
),
SetUpAssistant(
id: 'opus',
name: 'Opus',
protocol: 'anthropic',
host: 'api.anthropic.com',
model: 'claude-opus-5-5',
),
],
);
void main() {
group('AssistantsCubit', () {
late FakeAssistantGateway gateway;
late AssistantRepository repository;
late MemoryOwnAssistantStore store;
late AssistantsCubit cubit;
setUp(() {
gateway = FakeAssistantGateway()
..offer = const AssistantsOffer(assistants: [_claude], ownKeys: true);
repository = AssistantRepository(gateway, llm: FakeLlmClient());
store = MemoryOwnAssistantStore();
cubit = AssistantsCubit(repository, ownAssistants: store);
addTearDown(() async {
await cubit.close();
await repository.dispose();
});
});
test('reads what is offered, and the own assistants as they '
'change', () async {
await cubit.load();
expect(cubit.state.loading, isFalse);
expect(cubit.state.offer.assistants, [_claude]);
expect(cubit.state.showsAnything, isTrue);
expect(await cubit.saveOwn(_own), OwnAssistantSaved.kept);
await pumpEventQueue();
expect(cubit.state.own, [_own]);
await cubit.removeOwn('own-1');
await pumpEventQueue();
expect(cubit.state.own, isEmpty);
});
test('shows nothing where nothing is offered, own keys are not '
'allowed, and none are kept', () async {
gateway.offer = AssistantsOffer.none;
await cubit.load();
expect(cubit.state.showsAnything, isFalse);
});
test("a key isn't kept where the settings wouldn't be sealed", () async {
store.unsealed = true;
expect(await cubit.saveOwn(_own), OwnAssistantSaved.refused);
expect(store.assistants, isEmpty);
});
test('agreeing reads the offer anew', () async {
await cubit.load();
await cubit.agree('claude');
expect(cubit.state.offer.assistants.single.agreed, isTrue);
expect(cubit.state.agreeing, isNull);
expect(cubit.state.problem, isNull);
});
test('a refused agreement is the problem, the offer as it was', () async {
await cubit.load();
gateway.failing = const AssistantFailed(AssistantProblem.notAllowed);
await cubit.agree('claude');
expect(
cubit.state.problem,
const AssistantFailed(AssistantProblem.notAllowed),
);
expect(cubit.state.agreeing, isNull);
});
test("an offer that can't be read offers nothing", () async {
gateway.failing = const AssistantFailed(AssistantProblem.failed);
await cubit.load();
expect(
cubit.state.unreadable,
const AssistantFailed(AssistantProblem.failed),
);
expect(cubit.state.offer, AssistantsOffer.none);
});
test('an app keeping no own assistants keeps none', () async {
final without = AssistantsCubit(repository);
addTearDown(without.close);
expect(await without.saveOwn(_own), OwnAssistantSaved.failed);
});
});
group('AssistantGrantCubit', () {
late FakeAssistantOperatorGateway gateway;
late FakeInstanceOperatorGateway accounts;
late AssistantOperatorRepository repository;
final anna = OperatedAccount(
id: 'anna',
handle: 'anna',
since: DateTime(2026),
);
final ben = OperatedAccount(
id: 'ben',
handle: 'ben',
since: DateTime(2026),
);
const budget = AssistantBudget(dailyCostMicros: 1000000, monthlyTokens: 9);
AssistantGrantCubit cubitOf({
List<OperatedAccount> accounts = const [],
AssistantGrantee? grantee,
}) {
final cubit = AssistantGrantCubit(
repository,
setUp: _setUp,
accounts: accounts,
grantee: grantee,
);
addTearDown(cubit.close);
return cubit;
}
setUp(() {
gateway = FakeAssistantOperatorGateway()
..set = _setUp
..accounts.addAll({'anna': 'anna', 'ben': 'ben'});
accounts = FakeInstanceOperatorGateway()
..operates = true
..add(anna)
..add(ben);
repository = AssistantOperatorRepository(gateway, accounts);
addTearDown(repository.dispose);
});
test('grants each person each assistant chosen, all with one '
'budget', () async {
final cubit = cubitOf(accounts: [anna])
..add(ben)
..chooseAll(const ['claude', 'opus'], on: true);
expect(cubit.state.canSave, isTrue);
expect(await cubit.save(budget), isTrue);
expect(gateway.calls.last, 'grant anna ben claude opus');
expect(gateway.people['ben']!.budget, budget);
expect(cubit.state.busy, isFalse);
});
test('a person is added once, and taken off again', () {
final cubit = cubitOf()
..add(anna)
..add(anna)
..add(ben)
..remove('anna');
expect(cubit.state.accounts, [ben]);
});
test("names the people whose budget a grant replaces, as they're "
'added', () async {
await repository.grant(
accounts: ['ben'],
assistants: ['claude'],
budget: budget,
);
final cubit = cubitOf(accounts: [anna]);
await pumpEventQueue();
expect(cubit.state.replaced, isEmpty);
cubit.add(ben);
await pumpEventQueue();
expect([for (final each in cubit.state.replaced) each.account], ['ben']);
cubit.remove('ben');
await pumpEventQueue();
expect(cubit.state.replaced, isEmpty);
});
test('chooses assistants one by one, or a whole provider', () {
final cubit = cubitOf()..choose('claude', on: true);
expect(cubit.state.chosen, {'claude'});
cubit.chooseAll(const ['claude', 'opus'], on: true);
expect(cubit.state.chosen, {'claude', 'opus'});
cubit.chooseAll(const ['claude', 'opus'], on: false);
expect(cubit.state.chosen, isEmpty);
});
test('saves nothing without a person and an assistant', () async {
final nobody = cubitOf()..choose('claude', on: true);
final nothing = cubitOf(accounts: [anna]);
for (final cubit in [nobody, nothing]) {
expect(cubit.state.canSave, isFalse);
expect(await cubit.save(budget), isFalse);
}
expect(gateway.calls.where((call) => call.startsWith('grant ')), isEmpty);
});
test("the instance's refusal is the sheet's, and nothing is "
'granted', () async {
final cubit = cubitOf(accounts: [anna])..choose('claude', on: true);
expect(
await cubit.save(const AssistantBudget(dailyTokens: -1)),
isFalse,
);
expect(
cubit.state.refused,
const OperatorFailed(OperatorProblem.invalidLimit),
);
expect(cubit.state.busy, isFalse);
expect(gateway.people, isEmpty);
});
test("changing starts from the person's assistants still set, and "
'gives them exactly those chosen', () async {
final grantee = AssistantGrantee(
account: 'anna',
handle: 'anna',
grants: [
AssistantGrant(assistant: 'claude', grantedAt: DateTime(2026)),
AssistantGrant(
assistant: 'gone',
assistantSet: false,
grantedAt: DateTime(2026),
),
],
budget: budget,
);
final cubit = cubitOf(grantee: grantee);
expect(cubit.state.chosen, {'claude'});
cubit
..choose('claude', on: false)
..choose('opus', on: true);
expect(await cubit.save(AssistantBudget.none), isTrue);
expect(gateway.calls.last, 'change anna opus');
expect(gateway.people['anna']!.budget, AssistantBudget.none);
});
});
group('AssistantUseCubit', () {
test('offers this UTC month and the two before, the oldest first, and '
'reads the one shown', () async {
final asked = <DateTime>[];
final use = AssistantUse(
assistant: 'claude',
month: DateTime.utc(2026),
requests: 2,
);
final cubit = withClock(
Clock.fixed(DateTime.utc(2026, 1, 31, 23, 30)),
() => AssistantUseCubit((month) async {
asked.add(month);
return [use];
}),
);
addTearDown(cubit.close);
expect(cubit.state.months, [
DateTime.utc(2025, 11),
DateTime.utc(2025, 12),
DateTime.utc(2026),
]);
expect(cubit.state.month, DateTime.utc(2026));
await cubit.show();
expect(cubit.state.use, [use]);
expect(cubit.state.loading, isFalse);
await cubit.show(DateTime.utc(2025, 11));
expect(cubit.state.month, DateTime.utc(2025, 11));
expect(asked, [DateTime.utc(2026), DateTime.utc(2025, 11)]);
});
test("says when the use couldn't be read", () async {
final cubit = AssistantUseCubit(
(month) async => throw const AssistantFailed(AssistantProblem.failed),
);
addTearDown(cubit.close);
await cubit.show();
expect(cubit.state.failed, isTrue);
expect(cubit.state.use, isEmpty);
});
});
}