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 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 = []; 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); }); }); }