295 lines
8.3 KiB
Dart
295 lines
8.3 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's grants and
|
||
|
|
// the use by month (Halkyon Learn's ADR 0021, step 2).
|
||
|
|
|
||
|
|
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,
|
||
|
|
),
|
||
|
|
],
|
||
|
|
);
|
||
|
|
|
||
|
|
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('AssistantGrantsCubit', () {
|
||
|
|
late FakeAssistantOperatorGateway gateway;
|
||
|
|
late FakeInstanceOperatorGateway accounts;
|
||
|
|
late AssistantOperatorRepository repository;
|
||
|
|
late AssistantGrantsCubit cubit;
|
||
|
|
|
||
|
|
setUp(() {
|
||
|
|
gateway = FakeAssistantOperatorGateway()
|
||
|
|
..set = _setUp
|
||
|
|
..accounts.addAll({'anna': 'anna'});
|
||
|
|
accounts = FakeInstanceOperatorGateway()
|
||
|
|
..operates = true
|
||
|
|
..add(
|
||
|
|
OperatedAccount(id: 'anna', handle: 'anna', since: DateTime(2026)),
|
||
|
|
);
|
||
|
|
repository = AssistantOperatorRepository(gateway, accounts);
|
||
|
|
cubit = AssistantGrantsCubit(repository);
|
||
|
|
addTearDown(() async {
|
||
|
|
await cubit.close();
|
||
|
|
await repository.dispose();
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
test('reads the assistants set and the grants', () async {
|
||
|
|
await cubit.load();
|
||
|
|
|
||
|
|
expect(cubit.state.setUp, _setUp);
|
||
|
|
expect(cubit.state.grants, isEmpty);
|
||
|
|
expect(cubit.state.showsAnything, isTrue);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('shows nothing without an assistant set or a grant', () async {
|
||
|
|
gateway.set = const AssistantsSetUp();
|
||
|
|
await cubit.load();
|
||
|
|
|
||
|
|
expect(cubit.state.showsAnything, isFalse);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('granting by handle reads the grants anew', () async {
|
||
|
|
await cubit.load();
|
||
|
|
final failed = await cubit.grant(
|
||
|
|
handle: 'anna',
|
||
|
|
assistant: 'claude',
|
||
|
|
dailyLimit: 1000000,
|
||
|
|
monthlyLimit: null,
|
||
|
|
);
|
||
|
|
|
||
|
|
expect(failed, isNull);
|
||
|
|
expect(cubit.state.grants.single.account, 'anna');
|
||
|
|
expect(cubit.state.busy, isNull);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("a handle nobody has is the sheet's to say, not the "
|
||
|
|
"screen's", () async {
|
||
|
|
await cubit.load();
|
||
|
|
final failed = await cubit.grant(
|
||
|
|
handle: 'bob',
|
||
|
|
assistant: 'claude',
|
||
|
|
dailyLimit: null,
|
||
|
|
monthlyLimit: null,
|
||
|
|
);
|
||
|
|
|
||
|
|
expect(failed, const OperatorFailed(OperatorProblem.notFound));
|
||
|
|
expect(cubit.state.problem, isNull);
|
||
|
|
expect(cubit.state.grants, isEmpty);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('changes the limits, and takes a grant back', () async {
|
||
|
|
await cubit.load();
|
||
|
|
await cubit.grant(
|
||
|
|
handle: 'anna',
|
||
|
|
assistant: 'claude',
|
||
|
|
dailyLimit: 1,
|
||
|
|
monthlyLimit: 2,
|
||
|
|
);
|
||
|
|
|
||
|
|
expect(
|
||
|
|
await cubit.changeLimits(
|
||
|
|
cubit.state.grants.single,
|
||
|
|
dailyLimit: 5,
|
||
|
|
monthlyLimit: null,
|
||
|
|
),
|
||
|
|
isNull,
|
||
|
|
);
|
||
|
|
expect(cubit.state.grants.single.dailyLimit, 5);
|
||
|
|
expect(cubit.state.grants.single.monthlyLimit, isNull);
|
||
|
|
|
||
|
|
await cubit.revoke(cubit.state.grants.single);
|
||
|
|
expect(cubit.state.grants, isEmpty);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("taking back what isn't granted any more is the screen's "
|
||
|
|
'problem', () async {
|
||
|
|
await cubit.load();
|
||
|
|
await cubit.revoke(
|
||
|
|
AssistantGrant(
|
||
|
|
account: 'anna',
|
||
|
|
assistant: 'claude',
|
||
|
|
grantedAt: DateTime.utc(2026, 10, 25),
|
||
|
|
),
|
||
|
|
);
|
||
|
|
|
||
|
|
expect(
|
||
|
|
cubit.state.problem,
|
||
|
|
const OperatorFailed(OperatorProblem.notFound),
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("someone who doesn't operate the instance reads nothing", () async {
|
||
|
|
gateway.failing = const OperatorFailed(OperatorProblem.notAllowed);
|
||
|
|
await cubit.load();
|
||
|
|
|
||
|
|
expect(
|
||
|
|
cubit.state.unreadable,
|
||
|
|
const OperatorFailed(OperatorProblem.notAllowed),
|
||
|
|
);
|
||
|
|
expect(cubit.state.showsAnything, isFalse);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
group('AssistantUseCubit', () {
|
||
|
|
test('offers this UTC month and the two before, 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(2026),
|
||
|
|
DateTime.utc(2025, 12),
|
||
|
|
DateTime.utc(2025, 11),
|
||
|
|
]);
|
||
|
|
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);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
}
|