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>
390 lines
12 KiB
Dart
390 lines
12 KiB
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';
|
|
import 'package:halkyon_ui/halkyon_ui.dart';
|
|
import 'package:material_ui/material_ui.dart';
|
|
|
|
/// An app's settings' Assistants card (Halkyon Learn's ADR 0021, points 6,
|
|
/// 9 and 10, D58, D60): what is granted, by provider, and left of the
|
|
/// budget for them all, agreeing to what goes where once, the person's
|
|
/// use, and assistants with their own key where allowed.
|
|
void main() {
|
|
late TestAppDependencies dependencies;
|
|
late FakeAssistantGateway gateway;
|
|
late MemoryOwnAssistantStore store;
|
|
|
|
const claude = OfferedAssistant(
|
|
id: 'claude',
|
|
name: 'Claude',
|
|
protocol: 'anthropic',
|
|
host: 'api.anthropic.com',
|
|
model: 'claude-sonnet-5-5',
|
|
images: true,
|
|
);
|
|
|
|
setUp(() {
|
|
dependencies = TestAppDependencies();
|
|
gateway = dependencies.assistantGateway
|
|
..offer = const AssistantsOffer(
|
|
assistants: [claude],
|
|
ownKeys: true,
|
|
currency: 'EUR',
|
|
budget: AssistantBudget(dailyCostMicros: 1000000),
|
|
usedToday: BudgetUse(costMicros: 8100, tokens: 900),
|
|
usedThisMonth: BudgetUse(costMicros: 8100, tokens: 900),
|
|
);
|
|
store = MemoryOwnAssistantStore();
|
|
});
|
|
|
|
Future<void> show(WidgetTester tester, {bool keepsOwn = true}) async {
|
|
tester.view.physicalSize = const Size(412, 1600);
|
|
tester.view.devicePixelRatio = 1;
|
|
addTearDown(tester.view.reset);
|
|
await tester.pumpWidget(
|
|
testApp(
|
|
Scaffold(
|
|
body: SingleChildScrollView(
|
|
child: AssistantsCard(ownAssistants: keepsOwn ? store : null),
|
|
),
|
|
),
|
|
dependencies: dependencies,
|
|
),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
}
|
|
|
|
Finder inSheet(Finder finder) =>
|
|
find.descendant(of: find.byType(SheetScaffold), matching: finder);
|
|
|
|
Future<void> tap(WidgetTester tester, Finder finder) async {
|
|
await tester.ensureVisible(finder);
|
|
await tester.tap(finder);
|
|
await tester.pumpAndSettle();
|
|
}
|
|
|
|
testWidgets('shows nothing where nothing is offered and own keys are not '
|
|
'allowed', (tester) async {
|
|
gateway.offer = AssistantsOffer.none;
|
|
await show(tester);
|
|
|
|
expect(find.text('Assistants'), findsNothing);
|
|
});
|
|
|
|
testWidgets("shows nothing where the offer can't be read", (tester) async {
|
|
gateway.failing = const AssistantFailed(AssistantProblem.failed);
|
|
await show(tester);
|
|
|
|
expect(find.text('Assistants'), findsNothing);
|
|
});
|
|
|
|
testWidgets('shows who answers, and what is left of the budget for them '
|
|
'all', (tester) async {
|
|
await show(tester);
|
|
|
|
expect(find.text('Assistants'), findsOneWidget);
|
|
expect(find.text('Your budget, for all of them'), findsOneWidget);
|
|
expect(find.text('€0.99 of €1.00 left today'), findsOneWidget);
|
|
expect(find.text('Anthropic'), findsOneWidget);
|
|
expect(find.text('Claude'), findsOneWidget);
|
|
expect(
|
|
find.text('claude-sonnet-5-5 at api.anthropic.com\nRead and agree'),
|
|
findsOneWidget,
|
|
);
|
|
expect(find.text('Your use'), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('a budget in tokens names them once', (tester) async {
|
|
gateway.offer = const AssistantsOffer(
|
|
assistants: [claude],
|
|
budget: AssistantBudget(dailyTokens: 100000, monthlyTokens: 2000000),
|
|
usedToday: BudgetUse(tokens: 350000),
|
|
usedThisMonth: BudgetUse(tokens: 350000),
|
|
);
|
|
await show(tester);
|
|
|
|
expect(
|
|
find.text(
|
|
'0 of 100,000 tokens left today\n'
|
|
'1,650,000 of 2,000,000 tokens left this month',
|
|
),
|
|
findsOneWidget,
|
|
);
|
|
});
|
|
|
|
testWidgets('a budget without limits says so', (tester) async {
|
|
gateway.offer = const AssistantsOffer(assistants: [claude]);
|
|
await show(tester);
|
|
|
|
expect(find.text('No limits'), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('groups the assistants by provider, in the order the operator '
|
|
'set them', (tester) async {
|
|
gateway.offer = const AssistantsOffer(
|
|
assistants: [
|
|
claude,
|
|
OfferedAssistant(
|
|
id: 'gemini',
|
|
name: 'Gemini',
|
|
protocol: 'openai',
|
|
host: 'generativelanguage.googleapis.com',
|
|
model: 'gemini-3-pro',
|
|
),
|
|
OfferedAssistant(
|
|
id: 'opus',
|
|
name: 'Opus',
|
|
protocol: 'anthropic',
|
|
host: 'api.anthropic.com',
|
|
model: 'claude-opus-5-5',
|
|
),
|
|
],
|
|
);
|
|
await show(tester);
|
|
|
|
final labels = [
|
|
for (final text in tester.widgetList<Text>(
|
|
find.descendant(
|
|
of: find.byType(AssistantsCard),
|
|
matching: find.byType(Text),
|
|
),
|
|
))
|
|
if (const {
|
|
'Anthropic',
|
|
'Google',
|
|
'Claude',
|
|
'Opus',
|
|
'Gemini',
|
|
}.contains(text.data))
|
|
text.data,
|
|
];
|
|
expect(labels, ['Anthropic', 'Claude', 'Opus', 'Google', 'Gemini']);
|
|
});
|
|
|
|
testWidgets('says what goes where before agreeing, and agrees only when '
|
|
'asked to (D40)', (tester) async {
|
|
await show(tester);
|
|
|
|
await tap(tester, find.text('Claude'));
|
|
expect(find.text('Before you ask Claude'), findsOneWidget);
|
|
expect(
|
|
find.text(
|
|
'It answers with the model claude-sonnet-5-5 at '
|
|
'api.anthropic.com.',
|
|
),
|
|
findsOneWidget,
|
|
);
|
|
expect(find.text('It reads the images you add.'), findsOneWidget);
|
|
expect(
|
|
find.text(
|
|
'Whoever runs test.example.org sees how much you use it (requests, '
|
|
'tokens, cost), never what you ask.',
|
|
),
|
|
findsOneWidget,
|
|
);
|
|
expect(gateway.calls, isNot(contains('agree claude')));
|
|
|
|
await tap(tester, find.widgetWithText(FilledButton, 'Agree'));
|
|
|
|
expect(gateway.calls, contains('agree claude'));
|
|
expect(find.text('Before you ask Claude'), findsNothing);
|
|
expect(find.textContaining('Read and agree'), findsNothing);
|
|
});
|
|
|
|
testWidgets('once agreed, the sheet says since when and asks nothing', (
|
|
tester,
|
|
) async {
|
|
gateway.offer = AssistantsOffer(
|
|
assistants: [
|
|
OfferedAssistant(
|
|
id: claude.id,
|
|
name: claude.name,
|
|
protocol: claude.protocol,
|
|
host: claude.host,
|
|
model: claude.model,
|
|
agreedAt: DateTime.utc(2026, 10, 25, 12),
|
|
),
|
|
],
|
|
);
|
|
await show(tester);
|
|
|
|
await tap(tester, find.text('Claude'));
|
|
|
|
expect(find.text('You agreed on Oct 25, 2026.'), findsOneWidget);
|
|
expect(find.widgetWithText(FilledButton, 'Agree'), findsNothing);
|
|
expect(find.text('It reads the images you add.'), findsNothing);
|
|
});
|
|
|
|
testWidgets('a refused agreement is said so', (tester) async {
|
|
await show(tester);
|
|
|
|
await tap(tester, find.text('Claude'));
|
|
gateway.failing = const AssistantFailed(AssistantProblem.notGranted);
|
|
await tap(tester, find.widgetWithText(FilledButton, 'Agree'));
|
|
|
|
expect(
|
|
find.text("This assistant isn't granted to you any more."),
|
|
findsOneWidget,
|
|
);
|
|
});
|
|
|
|
testWidgets("the person's use, by UTC month", (tester) async {
|
|
final month = utcMonthOf(DateTime.now());
|
|
gateway.use = [
|
|
AssistantUse(
|
|
assistant: 'claude',
|
|
month: month,
|
|
requests: 1,
|
|
inputTokens: 900,
|
|
outputTokens: 100,
|
|
costMicros: 4500,
|
|
),
|
|
];
|
|
await show(tester);
|
|
|
|
await tap(tester, find.text('Your use'));
|
|
|
|
expect(inSheet(find.text('Claude')), findsOneWidget);
|
|
expect(
|
|
inSheet(find.text('1 request · 1,000 tokens · €0.00')),
|
|
findsOneWidget,
|
|
);
|
|
});
|
|
|
|
testWidgets('adds an assistant with an own key, kept in the settings', (
|
|
tester,
|
|
) async {
|
|
await show(tester);
|
|
|
|
await tap(tester, find.text('Add an assistant with your own key'));
|
|
await tester.enterText(
|
|
inSheet(find.widgetWithText(TextField, 'Name')),
|
|
'My GPT',
|
|
);
|
|
await tap(tester, find.text('OpenAI Responses'));
|
|
await tester.enterText(
|
|
inSheet(find.widgetWithText(TextField, 'Model')),
|
|
'gpt-5',
|
|
);
|
|
await tester.enterText(
|
|
inSheet(find.widgetWithText(TextField, 'Key')),
|
|
'sk-own',
|
|
);
|
|
await tap(tester, inSheet(find.widgetWithText(FilledButton, 'Save')));
|
|
|
|
final own = store.assistants.single;
|
|
expect(own.name, 'My GPT');
|
|
expect(own.protocol, LlmProtocol.openaiResponses);
|
|
expect(own.baseUrl, isNull);
|
|
expect(own.model, 'gpt-5');
|
|
expect(own.apiKey, 'sk-own');
|
|
expect(find.text('My GPT'), findsOneWidget);
|
|
expect(find.text('gpt-5 at api.openai.com'), findsOneWidget);
|
|
expect(find.text('sk-own'), findsNothing, reason: 'never shown');
|
|
});
|
|
|
|
testWidgets("an address that isn't https is refused before anything is "
|
|
'kept', (tester) async {
|
|
await show(tester);
|
|
|
|
await tap(tester, find.text('Add an assistant with your own key'));
|
|
await tester.enterText(
|
|
inSheet(find.widgetWithText(TextField, 'Name')),
|
|
'Remote',
|
|
);
|
|
await tester.enterText(
|
|
inSheet(find.widgetWithText(TextField, 'Address')),
|
|
'http://models.example.org/v1',
|
|
);
|
|
await tester.enterText(
|
|
inSheet(find.widgetWithText(TextField, 'Model')),
|
|
'qwen',
|
|
);
|
|
await tap(tester, inSheet(find.widgetWithText(FilledButton, 'Save')));
|
|
|
|
expect(
|
|
find.text('Only https, or http on your own network'),
|
|
findsOneWidget,
|
|
);
|
|
expect(store.assistants, isEmpty);
|
|
});
|
|
|
|
testWidgets("a key isn't kept where this device can't seal the "
|
|
'settings', (tester) async {
|
|
store.unsealed = true;
|
|
await show(tester);
|
|
|
|
await tap(tester, find.text('Add an assistant with your own key'));
|
|
await tester.enterText(
|
|
inSheet(find.widgetWithText(TextField, 'Name')),
|
|
'Mine',
|
|
);
|
|
await tester.enterText(
|
|
inSheet(find.widgetWithText(TextField, 'Model')),
|
|
'claude-sonnet-5-5',
|
|
);
|
|
await tap(tester, inSheet(find.widgetWithText(FilledButton, 'Save')));
|
|
|
|
expect(
|
|
find.text(
|
|
"The key wasn't kept: this device can't encrypt your settings yet. "
|
|
"Open your account's keys here first.",
|
|
),
|
|
findsOneWidget,
|
|
);
|
|
expect(store.assistants, isEmpty);
|
|
});
|
|
|
|
testWidgets('removing an own assistant asks first', (tester) async {
|
|
await store.save(
|
|
const OwnAssistant(
|
|
id: 'own-1',
|
|
name: 'My GPT',
|
|
protocol: LlmProtocol.openai,
|
|
model: 'gpt-5',
|
|
apiKey: 'sk-own',
|
|
),
|
|
);
|
|
await show(tester);
|
|
|
|
await tap(tester, find.text('My GPT'));
|
|
await tap(tester, find.text('Remove'));
|
|
expect(find.text('Remove My GPT?'), findsOneWidget);
|
|
await tap(tester, find.widgetWithText(FilledButton, 'Remove'));
|
|
|
|
expect(store.assistants, isEmpty);
|
|
expect(find.text('My GPT'), findsNothing);
|
|
});
|
|
|
|
testWidgets("where own keys aren't allowed, those kept are said to go "
|
|
'unasked, and none can be added (D6)', (tester) async {
|
|
gateway.offer = AssistantsOffer.none;
|
|
await store.save(
|
|
const OwnAssistant(
|
|
id: 'own-1',
|
|
name: 'My GPT',
|
|
protocol: LlmProtocol.openai,
|
|
model: 'gpt-5',
|
|
apiKey: 'sk-own',
|
|
),
|
|
);
|
|
await show(tester);
|
|
|
|
expect(
|
|
find.text(
|
|
"Whoever runs test.example.org doesn't allow own keys here, so the "
|
|
"app doesn't ask these.",
|
|
),
|
|
findsOneWidget,
|
|
);
|
|
expect(find.text('Add an assistant with your own key'), findsNothing);
|
|
});
|
|
|
|
testWidgets('an app keeping no own assistants offers none', (tester) async {
|
|
await show(tester, keepsOwn: false);
|
|
|
|
expect(find.text('Claude'), findsOneWidget);
|
|
expect(find.text('With your own key'), findsNothing);
|
|
expect(find.text('Add an assistant with your own key'), findsNothing);
|
|
});
|
|
}
|