feat(assistant): ask Claude from Home, the Library, a studyset and a session
A device setting, off unless the person turns it on (Settings › Assistant), shows a field at the foot of Home and a button that unfolds into one: above Create studyset in the Library (smaller), in the free lane on the right of a studyset's bottom bar (floating alone from expanded windows on), and in the card's lower right corner while studying. A question is handed to Claude, not answered here: the app opens claude.ai/new with the question written in, followed by what it is about (the person's learning on the instance, a studyset by name, or the studied card's front and back as text), cut at 12,000 characters, for the person to read and send in Claude. A Claude plan can't drive a chat inside another app, as Anthropic allows no third party to offer sign-in with it; Claude is to read the studysets through a connector on the instance instead (next). Nothing leaves the app until the person sends it there. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
e5d5fddea5
commit
f4baf053b3
1 changed files with 1140 additions and 25 deletions
|
|
@ -0,0 +1,291 @@
|
|||
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/claude_hand_off.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/assistant/model/assistant_topic.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> opened;
|
||||
late bool opens;
|
||||
late FakeStudysetGateway studysets;
|
||||
late FakeCardGateway cards;
|
||||
|
||||
setUp(() {
|
||||
opened = [];
|
||||
opens = true;
|
||||
cards = FakeCardGateway();
|
||||
studysets = FakeStudysetGateway()
|
||||
..receiveRemoteChange(
|
||||
'japanese',
|
||||
encodeStudysetPayload(
|
||||
StudysetContent(title: 'Japanese', description: RichContent.empty()),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
/// Shows the app at [location] on a phone, asking Claude [on] or off.
|
||||
Future<void> showApp(
|
||||
WidgetTester tester, {
|
||||
bool on = true,
|
||||
String location = RoutePaths.home,
|
||||
MemoryDeviceSettingsStore? store,
|
||||
}) async {
|
||||
tester.view.physicalSize = const Size(400, 900);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
await pumpApp(
|
||||
tester,
|
||||
testDependencies(
|
||||
studysetGateway: studysets,
|
||||
cardGateway: cards,
|
||||
claude: ClaudeHandOff(
|
||||
open: (url) async {
|
||||
opened.add(url);
|
||||
return opens;
|
||||
},
|
||||
),
|
||||
deviceSettings: DeviceSettings(assistant: on),
|
||||
deviceSettingsStore: store,
|
||||
),
|
||||
location: location,
|
||||
);
|
||||
await tester.pump(const Duration(seconds: 1));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
/// What was handed to Claude.
|
||||
String asked() => opened.single.queryParameters['q']!;
|
||||
|
||||
/// Types [question] into the field and hands it over.
|
||||
Future<void> ask(WidgetTester tester, String question) async {
|
||||
await tester.enterText(find.byType(TextField).last, question);
|
||||
await tester.pump();
|
||||
await tester.tap(find.byTooltip('Ask in Claude'));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
group('Handing a question to Claude', () {
|
||||
test('opens a new conversation at claude.ai with it written in', () {
|
||||
final url = ClaudeHandOff.urlFor('Was heißt ひ? & mehr');
|
||||
|
||||
expect(url.scheme, 'https');
|
||||
expect(url.host, 'claude.ai');
|
||||
expect(url.path, '/new');
|
||||
expect(url.queryParameters['q'], 'Was heißt ひ? & mehr');
|
||||
});
|
||||
|
||||
test('cuts a long question short where claude.ai would', () {
|
||||
final prompt = ClaudeHandOff.clip('a' * 20000);
|
||||
|
||||
expect(prompt.length, ClaudeHandOff.maxPromptLength);
|
||||
expect(prompt, endsWith('…'));
|
||||
});
|
||||
});
|
||||
|
||||
group("A card's text, as it goes along", () {
|
||||
test("a word's back holds its reading and meanings", () {
|
||||
final card = CardContent(
|
||||
type: CardType.vocabulary,
|
||||
front: paragraph('火'),
|
||||
back: RichContent.empty(),
|
||||
reading: 'ひ',
|
||||
meanings: const ['fire', 'flame'],
|
||||
);
|
||||
|
||||
expect(cardFrontText(card), '火');
|
||||
expect(cardBackText(card), 'ひ\nfire; flame');
|
||||
});
|
||||
|
||||
test('a question holds only its right answers, then the explanation', () {
|
||||
final card = CardContent(
|
||||
type: CardType.multipleChoice,
|
||||
front: paragraph('Which is fire?'),
|
||||
back: RichContent.empty(),
|
||||
answers: [
|
||||
CardAnswer(content: paragraph('火'), correct: true),
|
||||
CardAnswer(content: paragraph('水')),
|
||||
],
|
||||
explanation: paragraph('水 is water.'),
|
||||
);
|
||||
|
||||
expect(cardBackText(card), '火\n水 is water.');
|
||||
});
|
||||
});
|
||||
|
||||
group('Off unless turned on', () {
|
||||
testWidgets('no field on Home', (tester) async {
|
||||
await showApp(tester, on: false);
|
||||
|
||||
expect(find.byTooltip('Ask in Claude'), 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 Claude'), findsNothing);
|
||||
expect(find.byTooltip('Create studyset'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('Settings turns it on, kept for the next start', (
|
||||
tester,
|
||||
) async {
|
||||
final store = MemoryDeviceSettingsStore();
|
||||
await showApp(
|
||||
tester,
|
||||
on: false,
|
||||
location: RoutePaths.settings,
|
||||
store: store,
|
||||
);
|
||||
|
||||
await tester.scrollUntilVisible(
|
||||
find.text('Ask Claude'),
|
||||
200,
|
||||
scrollable: find
|
||||
.descendant(
|
||||
of: find.byType(SettingsScreen),
|
||||
matching: find.byType(Scrollable),
|
||||
)
|
||||
.first,
|
||||
);
|
||||
await tester.ensureVisible(find.text('Ask Claude'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Ask Claude'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect((await store.read()).assistant, isTrue);
|
||||
});
|
||||
});
|
||||
|
||||
testWidgets("Home's field hands the question over, saying where from, and "
|
||||
'empties', (tester) async {
|
||||
await showApp(tester);
|
||||
|
||||
await ask(tester, 'What should I repeat today?');
|
||||
|
||||
expect(opened.single.host, 'claude.ai');
|
||||
expect(asked(), startsWith('What should I repeat today?\n\n'));
|
||||
expect(asked(), contains('learn.example.org'));
|
||||
expect(
|
||||
tester.widget<TextField>(find.byType(TextField)).controller!.text,
|
||||
isEmpty,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('nothing to hand over while the field is blank', (tester) async {
|
||||
await showApp(tester);
|
||||
|
||||
await tester.enterText(find.byType(TextField), ' ');
|
||||
await tester.pump();
|
||||
await tester.tap(find.byTooltip('Ask in Claude'));
|
||||
await tester.pump();
|
||||
|
||||
expect(opened, isEmpty);
|
||||
});
|
||||
|
||||
testWidgets("says so when Claude can't be opened", (tester) async {
|
||||
opens = false;
|
||||
await showApp(tester);
|
||||
|
||||
await ask(tester, 'Hello?');
|
||||
|
||||
expect(find.text("Claude couldn't be opened."), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets("the Library's button sits above Create studyset, unfolds into "
|
||||
'the field, and folds back once asked', (tester) async {
|
||||
await showApp(tester, location: RoutePaths.library);
|
||||
final button = tester.getCenter(find.byTooltip('Ask Claude'));
|
||||
final create = tester.getCenter(find.byTooltip('Create studyset'));
|
||||
expect(button.dy, lessThan(create.dy));
|
||||
expect(button.dx, closeTo(create.dx, 8));
|
||||
|
||||
await tester.tap(find.byTooltip('Ask Claude'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byTooltip('Create studyset'), findsNothing);
|
||||
await ask(tester, 'Which studyset needs me most?');
|
||||
|
||||
expect(asked(), startsWith('Which studyset needs me most?'));
|
||||
expect(find.byTooltip('Ask Claude'), findsOneWidget);
|
||||
expect(find.byTooltip('Create studyset'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('closing the unfolded field asks nothing', (tester) async {
|
||||
await showApp(tester, location: RoutePaths.library);
|
||||
|
||||
await tester.tap(find.byTooltip('Ask Claude'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byTooltip('Close'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(opened, isEmpty);
|
||||
expect(find.byTooltip('Create studyset'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets("a studyset's button, in the lower right, asks about it by "
|
||||
'name', (tester) async {
|
||||
await showApp(tester, location: RoutePaths.studyset('japanese'));
|
||||
final center = tester.getCenter(find.byTooltip('Ask Claude'));
|
||||
expect(center.dx, greaterThan(300));
|
||||
expect(center.dy, greaterThan(600));
|
||||
|
||||
await tester.tap(find.byTooltip('Ask Claude'));
|
||||
await tester.pumpAndSettle();
|
||||
await ask(tester, 'Quiz me on it');
|
||||
|
||||
expect(asked(), startsWith('Quiz me on it\n\n'));
|
||||
expect(asked(), contains('“Japanese”'));
|
||||
});
|
||||
|
||||
testWidgets("a study session's button sends the card's text along", (
|
||||
tester,
|
||||
) async {
|
||||
final repository = CardRepository(cards);
|
||||
addTearDown(repository.dispose);
|
||||
await repository.create(
|
||||
'japanese',
|
||||
CardContent(front: paragraph('ひ'), back: paragraph('hi')),
|
||||
);
|
||||
await showApp(tester, location: RoutePaths.study('japanese'));
|
||||
|
||||
await tester.tap(find.byTooltip('Ask Claude'));
|
||||
await tester.pumpAndSettle();
|
||||
await ask(tester, 'How do I remember this?');
|
||||
|
||||
expect(asked(), startsWith('How do I remember this?\n\n'));
|
||||
expect(asked(), contains('“Japanese”'));
|
||||
expect(asked(), contains('Front: ひ'));
|
||||
expect(asked(), contains('Back: hi'));
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue