import 'package:flutter/foundation.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart'; import 'package:halkyon_learn_flutter/src/core/device_settings/model/device_settings.dart'; import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart'; import 'package:halkyon_learn_flutter/src/feature/assistant/data/assistant_hand_off.dart'; import 'package:halkyon_learn_flutter/src/feature/assistant/data/web_search.dart'; import 'package:halkyon_learn_flutter/src/feature/assistant/model/ai_assistant.dart'; import 'package:halkyon_learn_flutter/src/feature/assistant/model/assistant_topic.dart'; import 'package:halkyon_learn_flutter/src/feature/assistant/model/search_engine.dart'; import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart'; import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart'; import 'package:halkyon_learn_flutter/src/feature/settings/widget/settings_screen.dart'; import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_payload.dart'; import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart'; import 'package:material_ui/material_ui.dart'; import '../../support/fake_card_gateway.dart'; import '../../support/fake_studyset_gateway.dart'; import '../../support/memory_device_settings_store.dart'; import '../../support/pump_app.dart'; import '../../support/test_dependencies.dart'; /// Rich content with one paragraph of [text]. RichContent paragraph(String text) => RichContent({ 'document': { 'type': 'page', 'children': [ { 'type': 'paragraph', 'data': { 'delta': [ {'insert': text}, ], }, }, ], }, }); void main() { late List handedOff; late List searched; late List onDevice; late bool opens; late bool deviceAnswers; late FakeStudysetGateway studysets; late FakeCardGateway cards; late MemoryDeviceSettingsStore store; setUp(() { handedOff = []; searched = []; onDevice = []; opens = true; deviceAnswers = true; cards = FakeCardGateway(); studysets = FakeStudysetGateway() ..receiveRemoteChange( 'japanese', encodeStudysetPayload( StudysetContent(title: 'Japanese', description: RichContent.empty()), ), ); }); /// Shows the app at [location] on a phone, asking turned [on] or off, /// with questions going to [target], the assistant [assistant] (Lumo /// without). Future showApp( WidgetTester tester, { bool on = true, String location = RoutePaths.home, AssistantTarget target = AssistantTarget.assistant, String? assistant, String? engine, String? customUrl, }) async { tester.view.physicalSize = const Size(400, 900); tester.view.devicePixelRatio = 1; addTearDown(tester.view.reset); final initial = DeviceSettings( assistant: on, assistantTarget: target, aiAssistant: assistant, searchEngine: engine, customSearchUrl: customUrl, ); store = MemoryDeviceSettingsStore(initial); await pumpApp( tester, testDependencies( studysetGateway: studysets, cardGateway: cards, assistantHandOff: AssistantHandOff( open: (url) async { handedOff.add(url); return opens; }, ), webSearch: WebSearch( open: (url) async { searched.add(url); return opens; }, deviceSearch: (query) async { onDevice.add(query); return deviceAnswers; }, ), deviceSettings: initial, deviceSettingsStore: store, ), location: location, ); await tester.pump(const Duration(seconds: 1)); await tester.pumpAndSettle(); } /// What was last handed to Lumo, the default: the fragment's `prefill`. String askedLast() => Uri.splitQueryString(handedOff.last.fragment)['prefill']!; /// The one question handed to Lumo. String asked() { expect(handedOff, hasLength(1)); return askedLast(); } /// Types [question] into the field and sends it. Future send(WidgetTester tester, String question, String send) async { await tester.enterText(find.byType(TextField).last, question); await tester.pump(); await tester.tap(find.byTooltip(send)); await tester.pumpAndSettle(); } /// Unfolds the floating button. Future unfold(WidgetTester tester) async { await tester.tap(find.byTooltip('Ask or search')); await tester.pumpAndSettle(); } group('Handing a question to an assistant', () { test('Lumo, the default, opens its guest chat with the question in the ' "address's fragment, which reaches no server", () { final url = AssistantHandOff.urlFor( 'Was heißt ひ? & mehr #1', AiAssistant.byName(null), ); expect(url.host, 'lumo.proton.me'); expect(url.path, '/guest'); expect(url.query, isEmpty); expect( Uri.splitQueryString(url.fragment)['prefill'], 'Was heißt ひ? & mehr #1', ); }); test('each assistant by the address that fills the question in', () { Uri at(AiAssistant assistant) => AssistantHandOff.urlFor('Hi there', assistant); expect( at(AiAssistant.chatGpt).toString(), 'https://chatgpt.com/?prompt=Hi%20there', ); expect(at(AiAssistant.claude).queryParameters['q'], 'Hi there'); expect(at(AiAssistant.claude).path, '/new'); expect(at(AiAssistant.kagi).queryParameters['qvalue'], 'Hi there'); expect(at(AiAssistant.huggingChat).queryParameters['prompt'], 'Hi there'); expect(at(AiAssistant.duckAi).host, 'duck.ai'); expect( AssistantHandOff.urlFor( 'Hi', AiAssistant.custom, customUrl: 'https://chat.example.org/?ask=%s', ).toString(), 'https://chat.example.org/?ask=Hi', ); // An address that won't do opens Lumo. expect( AssistantHandOff.urlFor( 'Hi', AiAssistant.custom, customUrl: 'chat.example.org', ).host, 'lumo.proton.me', ); }); test('names a custom assistant by its site', () { expect( AiAssistant.custom.titleWith('https://www.chat.example.org/?q=%s'), 'chat.example.org', ); expect(AiAssistant.lumo.titleWith(null), 'Lumo'); expect(AiAssistant.byName('nonsense'), AiAssistant.lumo); }); test('cuts a long question short where the assistant would', () { final prompt = AssistantHandOff.clip('a' * 20000, 12000); expect(prompt.length, 12000); expect(prompt, endsWith('…')); expect( Uri.splitQueryString( AssistantHandOff.urlFor('a' * 20000, AiAssistant.lumo).fragment, )['prefill']!.length, AiAssistant.lumo.maxPromptLength, ); }); }); group('Searching the web', () { test("puts the question into the engine's address", () { expect( WebSearch.urlFor('は vs が', SearchEngine.brave).toString(), 'https://search.brave.com/search?q=%E3%81%AF+vs+%E3%81%8C', ); expect(WebSearch.urlFor('x', SearchEngine.ecosia).host, 'www.ecosia.org'); }); test("a custom address takes the question where %s is; one that won't " 'do falls back to Brave Search', () { expect( WebSearch.urlFor( 'kanji', SearchEngine.custom, customUrl: 'https://search.example.org/?s=%s&lang=de', ).toString(), 'https://search.example.org/?s=kanji&lang=de', ); expect( WebSearch.urlFor( 'kanji', SearchEngine.custom, customUrl: 'ftp://nowhere', ).host, 'search.brave.com', ); expect(SearchEngine.validTemplate('https://a.example/?q=%s'), isTrue); expect(SearchEngine.validTemplate('https://a.example/'), isFalse); expect(SearchEngine.validTemplate('javascript:%s'), isFalse); }); test("the device's own search exists only on Android", () { debugDefaultTargetPlatformOverride = TargetPlatform.android; expect(SearchEngine.platformDefault, SearchEngine.device); expect(SearchEngine.byName('device'), SearchEngine.device); debugDefaultTargetPlatformOverride = TargetPlatform.linux; expect(SearchEngine.platformDefault, SearchEngine.brave); expect(SearchEngine.byName('device'), SearchEngine.brave); debugDefaultTargetPlatformOverride = null; }); }); group('What goes along', () { final card = CardContent( type: CardType.vocabulary, front: paragraph('火'), back: RichContent.empty(), reading: 'ひ', meanings: const ['fire', 'flame'], hints: [paragraph('火曜日は月曜日の次です。')], explanation: paragraph('A kanji of the five elements.'), ); final parts = AssistantTopicCard( studysetTitle: 'Japanese', card: card, ).parts; test('a card offers its front, its back, each hint, its explanation and ' "the studyset's name", () { expect( [for (final part in parts) part.kind], [ AssistantPartKind.front, AssistantPartKind.back, AssistantPartKind.hint, AssistantPartKind.explanation, AssistantPartKind.studyset, ], ); expect(parts[1].text, 'ひ\nfire; flame'); expect(parts[2].number, 1); }); test('a long part is cut short', () { const part = AssistantPart(kind: AssistantPartKind.hint, text: ''); final long = AssistantPart( kind: AssistantPartKind.hint, text: 'x' * 2000, ); expect(long.sent.length, AssistantPart.maxLength); expect(part.sent, ''); }); test('a web search takes the question and the text of each part chosen, ' "never the studyset's name", () { expect( searchQuery('Why?', const []), 'Why?', ); expect(searchQuery('Why?', [parts[2], parts[4]]), 'Why? 火曜日は月曜日の次です。'); }); }); group('Off unless turned on', () { testWidgets('no field on Home', (tester) async { await showApp(tester, on: false); expect(find.byType(TextField), findsNothing); }); testWidgets('no button in the Library, Create studyset as before', ( tester, ) async { await showApp(tester, on: false, location: RoutePaths.library); expect(find.byTooltip('Ask or search'), findsNothing); expect(find.byTooltip('Create studyset'), findsOneWidget); }); testWidgets('Settings turns it on, kept for the next start, and chooses ' 'the search engine', (tester) async { await showApp(tester, on: false, location: RoutePaths.settings); final settings = find .descendant( of: find.byType(SettingsScreen), matching: find.byType(Scrollable), ) .first; await tester.scrollUntilVisible( find.text('Ask an AI assistant or search the web'), 200, scrollable: settings, ); await tester.ensureVisible( find.text('Ask an AI assistant or search the web'), ); await tester.pumpAndSettle(); await tester.tap(find.text('Ask an AI assistant or search the web')); await tester.pumpAndSettle(); expect((await store.read()).assistant, isTrue); await tester.scrollUntilVisible( find.text('Search engine'), 200, scrollable: settings, ); await tester.ensureVisible(find.text('Search engine')); await tester.pumpAndSettle(); await tester.tap(find.text('Search engine')); await tester.pumpAndSettle(); await tester.tap(find.text('Ecosia')); await tester.pumpAndSettle(); expect((await store.read()).searchEngine, 'ecosia'); expect(find.text('Ecosia'), findsOneWidget); // Lumo unless another is chosen. await tester.scrollUntilVisible( find.text('AI assistant'), 200, scrollable: settings, ); await tester.ensureVisible(find.text('AI assistant')); await tester.pumpAndSettle(); expect(find.text('Lumo'), findsOneWidget); await tester.tap(find.text('AI assistant')); await tester.pumpAndSettle(); expect(find.text('Proton'), findsOneWidget); await tester.tap(find.text('ChatGPT')); await tester.pumpAndSettle(); expect((await store.read()).aiAssistant, 'chatGpt'); expect(find.text('ChatGPT'), findsOneWidget); }); testWidgets('a question goes to the assistant chosen, named in the ' 'field', (tester) async { await showApp(tester, assistant: 'chatGpt'); expect(find.text('Ask ChatGPT…'), findsOneWidget); await send(tester, 'Explain pitch accent', 'Ask in ChatGPT'); expect(handedOff.single.host, 'chatgpt.com'); expect( handedOff.single.queryParameters['prompt'], 'Explain pitch accent', ); }); }); testWidgets("Home's field hands the question alone to Lumo, and empties", ( tester, ) async { await showApp(tester); await send(tester, 'What should I repeat today?', 'Ask in Lumo'); expect(asked(), 'What should I repeat today?'); expect( tester.widget(find.byType(TextField)).controller!.text, isEmpty, ); }); testWidgets('the switch at its start sends to a web search instead, and ' 'stays so', (tester) async { // A desktop: a web search opens Brave Search's results. debugDefaultTargetPlatformOverride = TargetPlatform.linux; await showApp(tester); await tester.tap(find.byTooltip('Search the web instead')); await tester.pumpAndSettle(); expect(find.text('Search the web…'), findsOneWidget); await send(tester, 'は vs が', 'Search'); expect(handedOff, isEmpty); expect(searched.single.host, 'search.brave.com'); expect(searched.single.queryParameters['q'], 'は vs が'); expect((await store.read()).assistantTarget, AssistantTarget.web); debugDefaultTargetPlatformOverride = null; }); testWidgets("on Android, the device's own search takes it; Brave Search " 'where nothing on the device answers', (tester) async { debugDefaultTargetPlatformOverride = TargetPlatform.android; await showApp(tester, target: AssistantTarget.web); await send(tester, 'particle は', 'Search'); expect(onDevice, ['particle は']); expect(searched, isEmpty); deviceAnswers = false; await send(tester, 'particle が', 'Search'); expect(searched.single.host, 'search.brave.com'); debugDefaultTargetPlatformOverride = null; }); testWidgets('a custom engine searches at its own address', (tester) async { await showApp( tester, target: AssistantTarget.web, engine: 'custom', customUrl: 'https://search.example.org/?s=%s', ); await send(tester, 'kanji', 'Search'); expect(searched.single.toString(), 'https://search.example.org/?s=kanji'); }); testWidgets('nothing to send while the field is blank and nothing chosen', ( tester, ) async { await showApp(tester); await tester.enterText(find.byType(TextField), ' '); await tester.pump(); await tester.tap(find.byTooltip('Ask in Lumo')); await tester.pump(); expect(handedOff, isEmpty); }); testWidgets("says so when Lumo can't be opened", (tester) async { opens = false; await showApp(tester); await send(tester, 'Hello?', 'Ask in Lumo'); expect(find.text("Lumo couldn't be opened."), findsOneWidget); }); testWidgets("the Library's button sits above Create studyset, unfolds into " 'the field, and folds back once sent', (tester) async { await showApp(tester, location: RoutePaths.library); final button = tester.getCenter(find.byTooltip('Ask or search')); final create = tester.getCenter(find.byTooltip('Create studyset')); expect(button.dy, lessThan(create.dy)); expect(button.dx, closeTo(create.dx, 8)); await unfold(tester); expect(find.byTooltip('Create studyset'), findsNothing); await send(tester, 'Which studyset needs me most?', 'Ask in Lumo'); expect(asked(), 'Which studyset needs me most?'); expect(find.byTooltip('Ask or search'), findsOneWidget); expect(find.byTooltip('Create studyset'), findsOneWidget); }); testWidgets("a studyset's name goes along only when chosen, and never " 'into a web search', (tester) async { await showApp(tester, location: RoutePaths.studyset('japanese')); final center = tester.getCenter(find.byTooltip('Ask or search')); expect(center.dx, greaterThan(300)); expect( center.dy, lessThan(tester.getCenter(find.byTooltip('Add material')).dy), ); await unfold(tester); await tester.tap(find.text('Studyset: Japanese')); await tester.pump(); await send(tester, 'Quiz me on it', 'Ask in Lumo'); expect(asked(), startsWith('Quiz me on it\n\n')); expect(asked(), contains('“Japanese”')); expect(asked(), contains('learn.example.org')); await unfold(tester); await tester.tap(find.byTooltip('Search the web instead')); await tester.pumpAndSettle(); expect(find.text('Studyset: Japanese'), findsNothing); }); testWidgets("in a session, the card's parts go along only as chosen, and " 'a chosen hint alone is enough to search', (tester) async { // A desktop: a web search opens Brave Search's results. debugDefaultTargetPlatformOverride = TargetPlatform.linux; final repository = CardRepository(cards); addTearDown(repository.dispose); await repository.create( 'japanese', CardContent( front: paragraph('ひ'), back: paragraph('hi'), hints: [paragraph('ひらがな is the cursive script.')], ), ); await showApp(tester, location: RoutePaths.study('japanese')); await unfold(tester); await send(tester, 'How do I remember this?', 'Ask in Lumo'); expect(asked(), 'How do I remember this?'); await unfold(tester); await tester.tap(find.text('Front: ひ')); await tester.tap(find.text('Back: hi')); await tester.pump(); await send(tester, 'And this?', 'Ask in Lumo'); expect(askedLast(), 'And this?\n\nFront: ひ\nBack: hi'); await unfold(tester); await tester.tap(find.byTooltip('Search the web instead')); await tester.pumpAndSettle(); // The chips scroll sideways. await tester.ensureVisible(find.textContaining('Hint 1: ひらがな')); await tester.pumpAndSettle(); await tester.tap(find.textContaining('Hint 1: ひらがな')); await tester.pump(); await tester.tap(find.byTooltip('Search')); await tester.pumpAndSettle(); expect( searched.single.queryParameters['q'], 'ひらがな is the cursive script.', ); debugDefaultTargetPlatformOverride = null; }); }