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 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 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( 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); }); }