chore: Halkyon Learn moves into apps/learn
Its server, client, app and curator, and the tools that write its assets (maps, Anki fixtures, icons), as they are; paths are fixed in the commits that follow. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
11abd9afce
commit
649499be23
2 changed files with 0 additions and 0 deletions
|
|
@ -0,0 +1,591 @@
|
|||
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 at the lower left, Create studyset "
|
||||
'at the lower right, 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.dx, lessThan(tester.view.physicalSize.width / 4));
|
||||
expect(create.dx, greaterThan(tester.view.physicalSize.width * 3 / 4));
|
||||
expect(button.dy, closeTo(create.dy, 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'));
|
||||
// At the lower left, above the studyset's bar.
|
||||
final center = tester.getCenter(find.byTooltip('Ask or search'));
|
||||
expect(center.dx, lessThan(100));
|
||||
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 button stands at the lower left, above '
|
||||
'Check, covering nothing that answers the card', (tester) async {
|
||||
final repository = CardRepository(cards);
|
||||
addTearDown(repository.dispose);
|
||||
await repository.create(
|
||||
'japanese',
|
||||
CardContent(
|
||||
type: CardType.multipleChoice,
|
||||
front: paragraph('Capital of Japan?'),
|
||||
back: RichContent.empty(),
|
||||
answers: [
|
||||
CardAnswer(content: paragraph('Tokyo'), correct: true),
|
||||
CardAnswer(content: paragraph('Kyoto')),
|
||||
],
|
||||
),
|
||||
);
|
||||
await showApp(tester, location: RoutePaths.study('japanese'));
|
||||
|
||||
final button = tester.getRect(find.byTooltip('Ask or search'));
|
||||
final check = tester.getRect(find.widgetWithText(FilledButton, 'Check'));
|
||||
expect(button.center.dx, lessThan(tester.view.physicalSize.width / 4));
|
||||
expect(button.bottom, lessThan(check.top));
|
||||
});
|
||||
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,167 @@
|
|||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.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/model/connected_assistant.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/assistant/widget/connect_claude_screen.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/settings/widget/settings_screen.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
import '../../support/fake_assistant_gateway.dart';
|
||||
import '../../support/pump_app.dart';
|
||||
import '../../support/test_dependencies.dart';
|
||||
|
||||
void main() {
|
||||
late FakeAssistantGateway gateway;
|
||||
|
||||
setUp(() {
|
||||
gateway = FakeAssistantGateway(
|
||||
initially: [
|
||||
ConnectedAssistant(
|
||||
id: 1,
|
||||
name: 'Claude',
|
||||
host: 'claude.ai',
|
||||
connectedAt: DateTime.utc(2026, 10, 9),
|
||||
lastUsedAt: DateTime.utc(2026, 10, 10),
|
||||
),
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
Future<void> showApp(
|
||||
WidgetTester tester, {
|
||||
String location = RoutePaths.connectAssistant,
|
||||
bool on = true,
|
||||
}) async {
|
||||
tester.view.physicalSize = const Size(400, 1000);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
await pumpApp(
|
||||
tester,
|
||||
testDependencies(
|
||||
assistantGateway: gateway,
|
||||
deviceSettings: DeviceSettings(assistant: on),
|
||||
),
|
||||
location: location,
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
/// Brings [finder] in Settings into view.
|
||||
Future<void> scrollTo(WidgetTester tester, Finder finder) async {
|
||||
await tester.scrollUntilVisible(
|
||||
finder,
|
||||
200,
|
||||
scrollable: find
|
||||
.descendant(
|
||||
of: find.byType(SettingsScreen),
|
||||
matching: find.byType(Scrollable),
|
||||
)
|
||||
.first,
|
||||
);
|
||||
await tester.ensureVisible(finder);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
testWidgets('Settings offers to connect Claude only while asking Claude is '
|
||||
'on', (tester) async {
|
||||
await showApp(tester, location: RoutePaths.settings, on: false);
|
||||
await scrollTo(tester, find.text('Ask an AI assistant or search the web'));
|
||||
expect(find.text('Connect Claude'), findsNothing);
|
||||
|
||||
await tester.tap(find.text('Ask an AI assistant or search the web'));
|
||||
await tester.pumpAndSettle();
|
||||
await scrollTo(tester, find.text('Connect Claude'));
|
||||
await tester.tap(find.text('Connect Claude'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(ConnectClaudeScreen), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets("shows the connector's address to add in Claude, to copy", (
|
||||
tester,
|
||||
) async {
|
||||
final copied = <Object?>[];
|
||||
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
|
||||
SystemChannels.platform,
|
||||
(call) async {
|
||||
if (call.method == 'Clipboard.setData') {
|
||||
copied.add((call.arguments as Map)['text']);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
);
|
||||
addTearDown(
|
||||
() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
|
||||
SystemChannels.platform,
|
||||
null,
|
||||
),
|
||||
);
|
||||
await showApp(tester);
|
||||
|
||||
expect(find.text('https://learn.example.org/mcp'), findsOneWidget);
|
||||
expect(find.textContaining('learn.example.org through'), findsOneWidget);
|
||||
await tester.tap(find.byTooltip('Copy'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(copied, ['https://learn.example.org/mcp']);
|
||||
expect(find.text('Copied'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('shows a code on asking, and a new one replaces it', (
|
||||
tester,
|
||||
) async {
|
||||
await showApp(tester);
|
||||
|
||||
await tester.tap(find.text('Show a code'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('K7QX-M2P0'), findsOneWidget);
|
||||
expect(find.textContaining('Valid until'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('New code'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('K7QX-M2P0'), findsNothing);
|
||||
expect(find.text('K7QX-M2P1'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('lists what is connected; disconnecting asks first', (
|
||||
tester,
|
||||
) async {
|
||||
await showApp(tester);
|
||||
expect(find.text('Claude'), findsOneWidget);
|
||||
expect(find.textContaining('claude.ai'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.widgetWithText(TextButton, 'Disconnect'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Disconnect Claude?'), findsOneWidget);
|
||||
await tester.tap(find.widgetWithText(FilledButton, 'Disconnect'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(gateway.connected, isEmpty);
|
||||
expect(find.text('Nothing connected yet.'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('an instance without a connector says so, and shows no steps', (
|
||||
tester,
|
||||
) async {
|
||||
gateway = FakeAssistantGateway(url: null);
|
||||
await showApp(tester);
|
||||
|
||||
expect(find.textContaining("doesn't offer a connector"), findsOneWidget);
|
||||
expect(find.text('Show a code'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('an instance out of reach says so, and asks again on Try '
|
||||
'again', (tester) async {
|
||||
gateway.unreachable = true;
|
||||
await showApp(tester);
|
||||
expect(find.textContaining("can't be reached"), findsOneWidget);
|
||||
|
||||
gateway.unreachable = false;
|
||||
await tester.tap(find.text('Try again'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.textContaining("can't be reached"), findsNothing);
|
||||
expect(find.text('https://learn.example.org/mcp'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue