halkyon/packages/halkyon_app/test/assistant/widget/assistants_card_test.dart
Tizian 「ティツィアーン」 830aea10fe
feat(learn,app): explanations wait behind a question mark beside their title
What explains a setting or a section, rather than what is the person's
own, moves behind halkyon_ui's HelpButton, beside its title through the
new ExplainedTitle (the developer's review of 3 Nov 2026): in Learn's
settings (citations, the Pomodoro timer, grammar, the web search,
Jellyfin, the device's dictionaries, counting one's learning), the study
settings, export's progress, the Jellyfin tags and the study button's
back first; in halkyon_app, setting up a password, adding SimpleLogin,
own keys not allowed, the profile page's link back and the operator's
grant sheet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-11-04 09:32:05 +01:00

429 lines
13 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('one agreement holds for every assistant of its provider, '
'which the sheet says (D66)', (tester) async {
gateway.offer = const AssistantsOffer(
assistants: [
claude,
OfferedAssistant(
id: 'opus',
name: 'Opus',
protocol: 'anthropic',
host: 'api.anthropic.com',
model: 'claude-opus-5-5',
),
OfferedAssistant(
id: 'gemini',
name: 'Gemini',
protocol: 'openai',
host: 'generativelanguage.googleapis.com',
model: 'gemini-3-pro',
),
],
);
await show(tester);
expect(find.textContaining('Read and agree'), findsNWidgets(3));
await tap(tester, find.text('Claude'));
expect(
find.text('It holds for every assistant at api.anthropic.com.'),
findsOneWidget,
);
await tap(tester, find.widgetWithText(FilledButton, 'Agree'));
expect(find.textContaining('Read and agree'), findsOneWidget);
expect(gateway.calls.where((call) => call.startsWith('agree')), [
'agree claude',
]);
});
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);
const why =
"Whoever runs test.example.org doesn't allow own keys here, so the "
"app doesn't ask these.";
expect(find.text(why), findsNothing, reason: 'behind its question mark');
await tester.tap(find.byType(HelpButton));
await tester.pumpAndSettle();
expect(find.text(why), findsOneWidget);
await tester.tap(find.text('OK'));
await tester.pumpAndSettle();
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);
});
}