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): what is granted and left, 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, unit: AssistantLimitUnit.money, dailyLimit: 1000000, usedToday: 8100, ); setUp(() { dependencies = TestAppDependencies(); gateway = dependencies.assistantGateway ..offer = const AssistantsOffer( assistants: [claude], ownKeys: true, currency: 'EUR', ); 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 its limits', ( tester, ) async { await show(tester); expect(find.text('Assistants'), findsOneWidget); expect(find.text('Claude'), findsOneWidget); expect( find.text( 'claude-sonnet-5-5 at api.anthropic.com\n€0.99 of €1.00 left today\n' 'Read and agree', ), findsOneWidget, ); expect(find.text('Your use'), findsOneWidget); }); testWidgets('a limit in tokens names them once', (tester) async { gateway.offer = const AssistantsOffer( assistants: [ OfferedAssistant( id: 'local', name: 'Local', protocol: 'openai', host: 'models.example.org', model: 'qwen', monthlyLimit: 2000000, usedThisMonth: 350000, ), ], ); await show(tester); expect( find.text( 'qwen at models.example.org\n1,650,000 of 2,000,000 tokens left this ' 'month\nRead and agree', ), findsOneWidget, ); }); 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); }); }