feat(assistant)!: no hand-off and no connector; the field searches the web (ADR 0022)
The developer decided on 23 Oct to drop the hand-off to an AI assistant outside the app, and Claude's connector with it, in v0.19.0, the app and the server together: build 22 raises minimumAppBuild anyway, so no app calling the connector's endpoints uses the instance after it. An instance is to have one way to an assistant, its operator's (ADR 0021, proposed). The app: the field at the foot of Home, and the button in the Library, on a studyset and while studying, search the web alone, through the engine chosen in Settings or Android's own search, with the parts of a card the person picks; a studyset's name and description are never offered. Gone: the assistants a question opened in (AiAssistant, AssistantHandOff), the switch between them and the web, Settings' choice of assistant, Connect Claude with its route, cubit, repository and gateway, and their texts. A settings file from before keeps the old keys until the next change of a setting writes it anew; nothing reads them. The server: lib/src/assistant/ (MCP, its OAuth, the five tools, connect codes and tokens), AssistantEndpoint, the daily forgetting of the connector's secrets (its scheduled call cancelled at start), its lines in the deletion hook, instance.info().assistantConnectorUrl (nullable, so an older app reads it as none), and Caddy's forwarding of /mcp, /oauth/* and /.well-known/oauth-*. The migration drops the connector's five tables, children first: DROP TABLE "assistant_token" CASCADE; -- tokens of a connection DROP TABLE "assistant_grant" CASCADE; -- OAuth codes of a connection DROP TABLE "assistant_connection" CASCADE; -- a person's connected client DROP TABLE "assistant_connect_code" CASCADE; DROP TABLE "assistant_client" CASCADE; -- OAuth clients registered No other table references them, so CASCADE drops only their own foreign keys. For the minute between the maintenance run and the switch, the old server answers the connector with errors and fails to delete an account (its hook still deletes from these tables); ADR 0022 says so. BREAKING CHANGE: a connected Claude gets no answer from the deploy on, and the app no longer opens a question in an AI assistant. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
1454fd7bfc
commit
ca24eb9cf9
2 changed files with 16550 additions and 10700 deletions
|
|
@ -3,16 +3,16 @@ 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/assistant/widget/assistant_field.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:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
import '../../support/fake_card_gateway.dart';
|
||||
|
|
@ -39,7 +39,6 @@ RichContent paragraph(String text) => RichContent({
|
|||
});
|
||||
|
||||
void main() {
|
||||
late List<Uri> handedOff;
|
||||
late List<Uri> searched;
|
||||
late List<String> onDevice;
|
||||
late bool opens;
|
||||
|
|
@ -49,7 +48,6 @@ void main() {
|
|||
late MemoryDeviceSettingsStore store;
|
||||
|
||||
setUp(() {
|
||||
handedOff = [];
|
||||
searched = [];
|
||||
onDevice = [];
|
||||
opens = true;
|
||||
|
|
@ -64,15 +62,12 @@ void main() {
|
|||
);
|
||||
});
|
||||
|
||||
/// Shows the app at [location] on a phone, asking turned [on] or off,
|
||||
/// with questions going to [target], the assistant [assistant] (Lumo
|
||||
/// without).
|
||||
/// Shows the app at [location] on a phone, searching from the app turned
|
||||
/// [on] or off, with the search engine [engine] (the platform's without).
|
||||
Future<void> showApp(
|
||||
WidgetTester tester, {
|
||||
bool on = true,
|
||||
String location = RoutePaths.home,
|
||||
AssistantTarget target = AssistantTarget.assistant,
|
||||
String? assistant,
|
||||
String? engine,
|
||||
String? customUrl,
|
||||
}) async {
|
||||
|
|
@ -81,8 +76,6 @@ void main() {
|
|||
addTearDown(tester.view.reset);
|
||||
final initial = DeviceSettings(
|
||||
assistant: on,
|
||||
assistantTarget: target,
|
||||
aiAssistant: assistant,
|
||||
searchEngine: engine,
|
||||
customSearchUrl: customUrl,
|
||||
);
|
||||
|
|
@ -92,12 +85,6 @@ void main() {
|
|||
testDependencies(
|
||||
studysetGateway: studysets,
|
||||
cardGateway: cards,
|
||||
assistantHandOff: AssistantHandOff(
|
||||
open: (url) async {
|
||||
handedOff.add(url);
|
||||
return opens;
|
||||
},
|
||||
),
|
||||
webSearch: WebSearch(
|
||||
open: (url) async {
|
||||
searched.add(url);
|
||||
|
|
@ -117,102 +104,29 @@ void main() {
|
|||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
/// What was last handed to Lumo, the default: the fragment's `prefill`.
|
||||
String askedLast() =>
|
||||
Uri.splitQueryString(handedOff.last.fragment)['prefill']!;
|
||||
/// What the one web search looked for, on Brave Search.
|
||||
String searchedFor() => searched.single.queryParameters['q']!;
|
||||
|
||||
/// 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 {
|
||||
/// Types [question] into the field and searches for it, with the field's
|
||||
/// own button: the Library's search is called Search too.
|
||||
Future<void> send(WidgetTester tester, String question) async {
|
||||
await tester.enterText(find.byType(TextField).last, question);
|
||||
await tester.pump();
|
||||
await tester.tap(find.byTooltip(send));
|
||||
await tester.tap(
|
||||
find.descendant(
|
||||
of: find.byType(AssistantField),
|
||||
matching: find.byTooltip('Search'),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
/// Unfolds the floating button.
|
||||
Future<void> unfold(WidgetTester tester) async {
|
||||
await tester.tap(find.byTooltip('Ask or search'));
|
||||
await tester.tap(find.byTooltip('Search the web'));
|
||||
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(
|
||||
|
|
@ -320,12 +234,13 @@ void main() {
|
|||
) async {
|
||||
await showApp(tester, on: false, location: RoutePaths.library);
|
||||
|
||||
expect(find.byTooltip('Ask or search'), findsNothing);
|
||||
expect(find.byTooltip('Search the web'), findsNothing);
|
||||
expect(find.byTooltip('Create studyset'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('Settings turns it on, kept for the next start, and chooses '
|
||||
'the search engine', (tester) async {
|
||||
'the search engine; no AI assistant to choose, nothing to connect '
|
||||
'(ADR 0022)', (tester) async {
|
||||
await showApp(tester, on: false, location: RoutePaths.settings);
|
||||
final settings = find
|
||||
.descendant(
|
||||
|
|
@ -334,15 +249,13 @@ void main() {
|
|||
)
|
||||
.first;
|
||||
await tester.scrollUntilVisible(
|
||||
find.text('Ask an AI assistant or search the web'),
|
||||
find.text('Search the web from the app'),
|
||||
200,
|
||||
scrollable: settings,
|
||||
);
|
||||
await tester.ensureVisible(
|
||||
find.text('Ask an AI assistant or search the web'),
|
||||
);
|
||||
await tester.ensureVisible(find.text('Search the web from the app'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Ask an AI assistant or search the web'));
|
||||
await tester.tap(find.text('Search the web from the app'));
|
||||
await tester.pumpAndSettle();
|
||||
expect((await store.read()).assistant, isTrue);
|
||||
|
||||
|
|
@ -360,84 +273,42 @@ void main() {
|
|||
|
||||
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',
|
||||
);
|
||||
expect(find.text('AI assistant'), findsNothing);
|
||||
expect(find.text('Lumo'), findsNothing);
|
||||
expect(find.text('Connect Claude'), findsNothing);
|
||||
});
|
||||
});
|
||||
|
||||
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 {
|
||||
testWidgets("Home's field searches the web for the question, and empties; "
|
||||
'it names no AI assistant and offers none (ADR 0022)', (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(find.byIcon(LucideIcons.sparkles), findsNothing);
|
||||
await send(tester, 'は vs が');
|
||||
|
||||
expect(handedOff, isEmpty);
|
||||
expect(searched.single.host, 'search.brave.com');
|
||||
expect(searched.single.queryParameters['q'], 'は vs が');
|
||||
expect((await store.read()).assistantTarget, AssistantTarget.web);
|
||||
expect(searchedFor(), 'は vs が');
|
||||
expect(
|
||||
tester.widget<TextField>(find.byType(TextField)).controller!.text,
|
||||
isEmpty,
|
||||
);
|
||||
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 showApp(tester);
|
||||
|
||||
await send(tester, 'particle は', 'Search');
|
||||
await send(tester, 'particle は');
|
||||
expect(onDevice, ['particle は']);
|
||||
expect(searched, isEmpty);
|
||||
|
||||
deviceAnswers = false;
|
||||
await send(tester, 'particle が', 'Search');
|
||||
await send(tester, 'particle が');
|
||||
expect(searched.single.host, 'search.brave.com');
|
||||
debugDefaultTargetPlatformOverride = null;
|
||||
});
|
||||
|
|
@ -445,12 +316,11 @@ void main() {
|
|||
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');
|
||||
await send(tester, 'kanji');
|
||||
|
||||
expect(searched.single.toString(), 'https://search.example.org/?s=kanji');
|
||||
});
|
||||
|
|
@ -462,26 +332,30 @@ void main() {
|
|||
|
||||
await tester.enterText(find.byType(TextField), ' ');
|
||||
await tester.pump();
|
||||
await tester.tap(find.byTooltip('Ask in Lumo'));
|
||||
await tester.tap(find.byTooltip('Search'));
|
||||
await tester.pump();
|
||||
|
||||
expect(handedOff, isEmpty);
|
||||
expect(searched, isEmpty);
|
||||
expect(onDevice, isEmpty);
|
||||
});
|
||||
|
||||
testWidgets("says so when Lumo can't be opened", (tester) async {
|
||||
testWidgets("says so when the search can't be opened", (tester) async {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.linux;
|
||||
opens = false;
|
||||
await showApp(tester);
|
||||
|
||||
await send(tester, 'Hello?', 'Ask in Lumo');
|
||||
await send(tester, 'Hello?');
|
||||
|
||||
expect(find.text("Lumo couldn't be opened."), findsOneWidget);
|
||||
expect(find.text("The search couldn't be opened."), findsOneWidget);
|
||||
debugDefaultTargetPlatformOverride = null;
|
||||
});
|
||||
|
||||
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 {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.linux;
|
||||
await showApp(tester, location: RoutePaths.library);
|
||||
final button = tester.getCenter(find.byTooltip('Ask or search'));
|
||||
final button = tester.getCenter(find.byTooltip('Search the web'));
|
||||
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));
|
||||
|
|
@ -489,18 +363,20 @@ void main() {
|
|||
|
||||
await unfold(tester);
|
||||
expect(find.byTooltip('Create studyset'), findsNothing);
|
||||
await send(tester, 'Which studyset needs me most?', 'Ask in Lumo');
|
||||
await send(tester, 'spaced repetition');
|
||||
|
||||
expect(asked(), 'Which studyset needs me most?');
|
||||
expect(find.byTooltip('Ask or search'), findsOneWidget);
|
||||
expect(searchedFor(), 'spaced repetition');
|
||||
expect(find.byTooltip('Search the web'), findsOneWidget);
|
||||
expect(find.byTooltip('Create studyset'), findsOneWidget);
|
||||
debugDefaultTargetPlatformOverride = null;
|
||||
});
|
||||
|
||||
testWidgets("a studyset's name goes along only when chosen, and never "
|
||||
'into a web search', (tester) async {
|
||||
testWidgets("a studyset's name is never offered to go 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'));
|
||||
final center = tester.getCenter(find.byTooltip('Search the web'));
|
||||
expect(center.dx, lessThan(100));
|
||||
expect(
|
||||
center.dy,
|
||||
|
|
@ -508,17 +384,8 @@ void main() {
|
|||
);
|
||||
|
||||
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);
|
||||
expect(find.byType(FilterChip), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('in a session, the button stands at the lower left, above '
|
||||
|
|
@ -539,7 +406,7 @@ void main() {
|
|||
);
|
||||
await showApp(tester, location: RoutePaths.study('japanese'));
|
||||
|
||||
final button = tester.getRect(find.byTooltip('Ask or search'));
|
||||
final button = tester.getRect(find.byTooltip('Search the web'));
|
||||
final check = tester.getRect(find.widgetWithText(FilledButton, 'Check'));
|
||||
expect(button.center.dx, lessThan(tester.view.physicalSize.width / 4));
|
||||
expect(button.bottom, lessThan(check.top));
|
||||
|
|
@ -562,19 +429,15 @@ void main() {
|
|||
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);
|
||||
expect(find.text('Studyset: Japanese'), findsNothing);
|
||||
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 send(tester, 'mnemonic');
|
||||
expect(searched.last.queryParameters['q'], 'mnemonic ひ hi');
|
||||
searched.clear();
|
||||
|
||||
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();
|
||||
|
|
|
|||
|
|
@ -1,167 +0,0 @@
|
|||
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