halkyon/halkyon_learn_flutter/test/feature/assistant/assistant_field_test.dart
Tizian 「ティツィアーン」 8dde0fa42a
feat(assistant): a question opens in the AI assistant chosen, Proton's Lumo by default
Questions asked from the app went to Claude alone. Settings › Assistant
now chooses where they open (decided 2026-10-14): Lumo, the default, by
its guest chat with the question in the address's fragment (Lumo's
`prefill`, which needs no account and reaches no server; the address for
one's own account loses the question when nobody is signed in), ChatGPT,
Claude, Mistral, Perplexity, Duck.ai, Grok, Kagi Assistant, HuggingChat,
or an address of one's own with %s. Each link fills the question in for
the person to send (Perplexity answers at once, Grok asks first); Gemini,
which has no such link, and Copilot, whose stopped working, are left out.
The field names the assistant ("Ask Lumo…"). A device that sent questions
to Claude goes on asking an assistant, now Lumo. Connecting Claude to read
studysets stays as it was.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-14 14:35:35 +02:00

564 lines
19 KiB
Dart

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<Uri> handedOff;
late List<Uri> searched;
late List<String> 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<void> 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<void> 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<void> 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<TextField>(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;
});
}