feat(learn): the assistant's field laid out as Claude's, with files attached and questions spoken, and no Check (ADR 0021, D67)
The question's text stands above its buttons: + attaching files, who answers as a chip (Effort now a submenu of it), the conversations kept, and the microphone while nothing is written, which gives way to the button sending. What goes along is one row above the text, opening a sheet with how far the assistant looks and the parts to send. A conversation's follow-up has the same + and microphone. Files are read on the device: an image goes as itself to an assistant that reads images, a PDF as its pages' text, a text file as its text, each cut at 20,000 characters and labelled by its name after the question. Voice is the device's own speech recognition (speech_to_text: Android, Apple, Windows; none on Linux), in the app's language, the microphone asked for once tapped. The privacy page says that Android's recogniser may send the sound to Google or the phone's maker. The field's Check goes, with its LanguageTool sheet (amending ADR 0024); the editors' underlining and Why? stay. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
5641285d31
commit
447e00c52a
5 changed files with 1843 additions and 1085 deletions
|
|
@ -17,6 +17,7 @@ import 'package:lucide_icons_flutter/lucide_icons.dart';
|
|||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
import '../../support/fake_card_gateway.dart';
|
||||
import '../../support/fake_speech_input.dart';
|
||||
import '../../support/fake_studyset_gateway.dart';
|
||||
import '../../support/memory_device_settings_store.dart';
|
||||
import '../../support/pump_app.dart';
|
||||
|
|
@ -47,8 +48,10 @@ void main() {
|
|||
late FakeStudysetGateway studysets;
|
||||
late FakeCardGateway cards;
|
||||
late MemoryDeviceSettingsStore store;
|
||||
late FakeSpeechInput speech;
|
||||
|
||||
setUp(() {
|
||||
speech = FakeSpeechInput();
|
||||
searched = [];
|
||||
onDevice = [];
|
||||
opens = true;
|
||||
|
|
@ -98,6 +101,7 @@ void main() {
|
|||
),
|
||||
deviceSettings: initial,
|
||||
deviceSettingsStore: store,
|
||||
speech: speech,
|
||||
),
|
||||
location: location,
|
||||
);
|
||||
|
|
@ -339,11 +343,70 @@ void main() {
|
|||
|
||||
await tester.enterText(find.byType(TextField), ' ');
|
||||
await tester.pump();
|
||||
await tester.tap(find.byTooltip('Search'));
|
||||
await tester.testTextInput.receiveAction(TextInputAction.search);
|
||||
await tester.pump();
|
||||
|
||||
expect(searched, isEmpty);
|
||||
expect(onDevice, isEmpty);
|
||||
expect(
|
||||
find.byTooltip('Search'),
|
||||
findsNothing,
|
||||
reason: 'the microphone stands in its place (D67)',
|
||||
);
|
||||
expect(find.byTooltip('Speak your question'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('the microphone writes what is said into the field, in the '
|
||||
"app's language, and gives way to the button sending once something "
|
||||
'is (D67)', (tester) async {
|
||||
await showApp(tester);
|
||||
await unfold(tester);
|
||||
|
||||
await tester.tap(find.byTooltip('Speak your question'));
|
||||
await tester.pump();
|
||||
expect(speech.languages, ['en-US'], reason: "the tests' locale");
|
||||
expect(find.byTooltip('Stop listening'), findsOneWidget);
|
||||
speech.say('what does');
|
||||
await tester.pump();
|
||||
speech.say('what does 火 mean');
|
||||
await tester.pump();
|
||||
expect(find.text('what does 火 mean'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.byTooltip('Stop listening'));
|
||||
await tester.pump();
|
||||
expect(find.byTooltip('Speak your question'), findsNothing);
|
||||
await tester.tap(find.byTooltip('Search'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(onDevice, ['what does 火 mean']);
|
||||
});
|
||||
|
||||
testWidgets("says so where the device can't listen, and shows no "
|
||||
'microphone where nothing recognises speech (D67)', (tester) async {
|
||||
speech.listens = false;
|
||||
await showApp(tester);
|
||||
await unfold(tester);
|
||||
await tester.tap(find.byTooltip('Speak your question'));
|
||||
await tester.pump();
|
||||
expect(find.textContaining("This device can't listen now"), findsOneWidget);
|
||||
expect(find.byTooltip('Speak your question'), findsOneWidget);
|
||||
|
||||
speech.supported = false;
|
||||
await tester.enterText(find.byType(TextField), 'x');
|
||||
await tester.pump();
|
||||
await tester.enterText(find.byType(TextField), '');
|
||||
await tester.pump();
|
||||
expect(find.byTooltip('Speak your question'), findsNothing);
|
||||
expect(
|
||||
tester
|
||||
.widget<IconButton>(
|
||||
find.ancestor(
|
||||
of: find.byIcon(LucideIcons.search),
|
||||
matching: find.byType(IconButton),
|
||||
),
|
||||
)
|
||||
.onPressed,
|
||||
isNull,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets("says so when the search can't be opened", (tester) async {
|
||||
|
|
@ -490,20 +553,29 @@ void main() {
|
|||
await showApp(tester, location: RoutePaths.study('japanese'));
|
||||
|
||||
await unfold(tester);
|
||||
// What goes along is one row above the field, opening its sheet (D67).
|
||||
expect(find.text('Send along'), findsOneWidget);
|
||||
await tester.tap(find.byTooltip('What goes along'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Studyset: Japanese'), findsNothing);
|
||||
expect(find.text('How far it looks'), findsNothing);
|
||||
await tester.tap(find.text('Front: ひ'));
|
||||
await tester.tap(find.text('Back: hi'));
|
||||
await tester.pump();
|
||||
await tester.tap(find.text('Done'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text(' · 2 parts'), findsOneWidget);
|
||||
await send(tester, 'mnemonic');
|
||||
expect(searched.last.queryParameters['q'], 'mnemonic ひ hi');
|
||||
searched.clear();
|
||||
|
||||
await unfold(tester);
|
||||
// The chips scroll sideways.
|
||||
await tester.ensureVisible(find.textContaining('Hint 1: ひらがな'));
|
||||
await tester.tap(find.byTooltip('What goes along'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.textContaining('Hint 1: ひらがな'));
|
||||
await tester.pump();
|
||||
await tester.tap(find.text('Done'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byTooltip('Search'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ import '../../support/fake_asset_sealing.dart';
|
|||
import '../../support/fake_asset_server_gateway.dart';
|
||||
import '../../support/fake_card_gateway.dart';
|
||||
import '../../support/fake_conversation_gateway.dart';
|
||||
import '../../support/fake_file_picker.dart';
|
||||
import '../../support/fake_grammar_gateway.dart';
|
||||
import '../../support/fake_settings_gateway.dart';
|
||||
import '../../support/fake_studyset_gateway.dart';
|
||||
|
|
@ -116,6 +117,7 @@ void main() {
|
|||
late FakeCardGateway cards;
|
||||
late FakeSettingsGateway settings;
|
||||
late FakeLlmClient llm;
|
||||
late FakeFilePicker picker;
|
||||
late MemoryDeviceSettingsStore store;
|
||||
late List<Uri> searched;
|
||||
late FakeAssetGateway assetGateway;
|
||||
|
|
@ -141,6 +143,7 @@ void main() {
|
|||
cards = FakeCardGateway();
|
||||
settings = FakeSettingsGateway();
|
||||
llm = FakeLlmClient();
|
||||
picker = FakeFilePicker();
|
||||
searched = [];
|
||||
assetGateway = FakeAssetGateway();
|
||||
assetFiles = MemoryAssetFileCache();
|
||||
|
|
@ -201,6 +204,7 @@ void main() {
|
|||
deviceSettingsStore: store,
|
||||
grammarGateway: grammar,
|
||||
grammarLanguages: grammarLanguages,
|
||||
files: picker,
|
||||
),
|
||||
location: location,
|
||||
);
|
||||
|
|
@ -245,6 +249,34 @@ void main() {
|
|||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
/// Opens the field's sheet of what goes along (D67).
|
||||
Future<void> openContext(WidgetTester tester) async {
|
||||
await tester.tap(find.byTooltip('What goes along'));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
/// Closes the sheet of what goes along.
|
||||
Future<void> closeContext(WidgetTester tester) async {
|
||||
await tester.tap(find.text('Done'));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
/// Chooses [labels] in the sheet of what goes along, opened for it.
|
||||
Future<void> choose(WidgetTester tester, List<String> labels) async {
|
||||
await openContext(tester);
|
||||
for (final label in labels) {
|
||||
await tester.tap(find.text(label));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
await closeContext(tester);
|
||||
}
|
||||
|
||||
/// Opens the menu of who answers, below the field.
|
||||
Future<void> openPicker(WidgetTester tester) async {
|
||||
await tester.tap(find.byTooltip('Who answers'));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
/// The settings the person's devices last wrote.
|
||||
PersonSettings lastSettings() =>
|
||||
decodeSettingsPayload(settings.writes.last.$2);
|
||||
|
|
@ -478,9 +510,10 @@ void main() {
|
|||
) async {
|
||||
assistants.offer = AssistantsOffer(assistants: [opus]);
|
||||
await showApp(tester);
|
||||
expect(find.widgetWithText(FilterChip, 'Low'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.byTooltip(tip));
|
||||
// In the menu of who answers, below the field (D67).
|
||||
await openPicker(tester);
|
||||
expect(find.byTooltip(tip), findsOneWidget);
|
||||
await tester.tap(find.text('Effort: Low'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Low (default)'), findsOneWidget);
|
||||
expect(find.text('Medium'), findsOneWidget);
|
||||
|
|
@ -492,7 +525,10 @@ void main() {
|
|||
await tester.pump();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.widgetWithText(FilterChip, 'High'), findsOneWidget);
|
||||
await openPicker(tester);
|
||||
expect(find.text('Effort: High'), findsOneWidget);
|
||||
await tester.tapAt(const Offset(4, 4));
|
||||
await tester.pumpAndSettle();
|
||||
expect((await store.read()).askEfforts, {
|
||||
'assistant:opus': LlmEffort.high,
|
||||
});
|
||||
|
|
@ -522,7 +558,10 @@ void main() {
|
|||
"dropped for the assistant's own", (tester) async {
|
||||
assistants.offer = AssistantsOffer(assistants: [opus]);
|
||||
await showApp(tester, askEfforts: {'assistant:opus': LlmEffort.max});
|
||||
expect(find.widgetWithText(FilterChip, 'Low'), findsOneWidget);
|
||||
await openPicker(tester);
|
||||
expect(find.text('Effort: Low'), findsOneWidget);
|
||||
await tester.tapAt(const Offset(4, 4));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
assistants.answers.add(_says('Because.'));
|
||||
await ask(tester, 'Why?');
|
||||
|
|
@ -534,12 +573,12 @@ void main() {
|
|||
'show none', (tester) async {
|
||||
await showApp(tester, web: true);
|
||||
expect(find.text('Ask Claude…'), findsOneWidget);
|
||||
await openPicker(tester);
|
||||
expect(find.byTooltip(tip), findsNothing);
|
||||
|
||||
await tester.tap(find.byTooltip('Who answers'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Web search'));
|
||||
await tester.pumpAndSettle();
|
||||
await openPicker(tester);
|
||||
expect(find.byTooltip(tip), findsNothing);
|
||||
});
|
||||
});
|
||||
|
|
@ -551,10 +590,9 @@ void main() {
|
|||
await showApp(tester, location: RoutePaths.studyset('japanese'));
|
||||
await unfold(tester);
|
||||
expect(find.text('Only what I choose'), findsOneWidget);
|
||||
expect(find.text('Studyset: Japanese'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Only what I choose'));
|
||||
await tester.pumpAndSettle();
|
||||
await openContext(tester);
|
||||
expect(find.text('Studyset: Japanese'), findsOneWidget);
|
||||
expect(find.text('My whole library'), findsOneWidget);
|
||||
expect(
|
||||
find.text('Its whole tree, from the top'),
|
||||
|
|
@ -563,6 +601,7 @@ void main() {
|
|||
);
|
||||
await tester.tap(find.text('This studyset with its topics'));
|
||||
await tester.pumpAndSettle();
|
||||
await closeContext(tester);
|
||||
expect(find.text('This studyset'), findsOneWidget);
|
||||
|
||||
assistants.answers.add(_says('Verbs, mostly.'));
|
||||
|
|
@ -588,8 +627,7 @@ void main() {
|
|||
await showApp(tester, location: RoutePaths.studyset('verbs'));
|
||||
await unfold(tester);
|
||||
|
||||
await tester.tap(find.text('Only what I choose'));
|
||||
await tester.pumpAndSettle();
|
||||
await openContext(tester);
|
||||
|
||||
expect(find.text('Its whole tree, from the top'), findsOneWidget);
|
||||
});
|
||||
|
|
@ -711,8 +749,7 @@ void main() {
|
|||
'assistant, the button marked while it holds (D9, D17)', (tester) async {
|
||||
await showApp(tester, location: RoutePaths.studyset('diary'));
|
||||
await unfold(tester);
|
||||
await tester.tap(find.text('Studyset: Diary'));
|
||||
await tester.pump();
|
||||
await choose(tester, ['Studyset: Diary']);
|
||||
|
||||
await ask(tester, 'What is this about?');
|
||||
|
||||
|
|
@ -743,8 +780,7 @@ void main() {
|
|||
await tester.pumpAndSettle();
|
||||
expect(find.byTooltip(marked), findsOneWidget);
|
||||
await unfold(tester, tooltip: marked);
|
||||
await tester.tap(find.text('Studyset: Diary'));
|
||||
await tester.pump();
|
||||
await choose(tester, ['Studyset: Diary']);
|
||||
assistants.answers.add(_says('Still your diary.'));
|
||||
await ask(tester, 'And now?');
|
||||
|
||||
|
|
@ -756,13 +792,7 @@ void main() {
|
|||
'them', (tester) async {
|
||||
studysets.receiveRemoteDeletion('diary');
|
||||
await showApp(tester);
|
||||
await tester.tap(
|
||||
find.descendant(
|
||||
of: find.byType(AssistantField),
|
||||
matching: find.text('Only what I choose'),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await openContext(tester);
|
||||
|
||||
expect(find.text('My whole library'), findsOneWidget);
|
||||
expect(find.text('Include private studysets'), findsNothing);
|
||||
|
|
@ -777,14 +807,12 @@ void main() {
|
|||
of: find.byType(AssistantField),
|
||||
matching: find.text(text),
|
||||
);
|
||||
await tester.tap(inField('Only what I choose'));
|
||||
await tester.pumpAndSettle();
|
||||
await openContext(tester);
|
||||
await tester.tap(find.text('My whole library'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(inField('Library'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Include private studysets'));
|
||||
await tester.pumpAndSettle();
|
||||
await closeContext(tester);
|
||||
expect(inField('Library, private ones too'), findsOneWidget);
|
||||
|
||||
await ask(tester, 'What do I study?');
|
||||
|
|
@ -830,8 +858,7 @@ void main() {
|
|||
await showApp(tester, location: RoutePaths.studyset('diary'));
|
||||
await unfold(tester);
|
||||
expect(find.text('Ask Local…'), findsOneWidget);
|
||||
await tester.tap(find.text('Studyset: Diary'));
|
||||
await tester.pump();
|
||||
await choose(tester, ['Studyset: Diary']);
|
||||
llm.replyText('A diary.');
|
||||
|
||||
await ask(tester, 'What is this?');
|
||||
|
|
@ -846,8 +873,7 @@ void main() {
|
|||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Desk'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Studyset: Diary'));
|
||||
await tester.pump();
|
||||
await choose(tester, ['Studyset: Diary']);
|
||||
await ask(tester, 'And this?');
|
||||
|
||||
expect(find.text('Send a private studyset to Desk?'), findsOneWidget);
|
||||
|
|
@ -1110,188 +1136,135 @@ void main() {
|
|||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
});
|
||||
group('attached files (D67)', () {
|
||||
Uint8List bytesOf(String text) => Uint8List.fromList(utf8.encode(text));
|
||||
|
||||
testWidgets('Check has what was typed in the field checked in the '
|
||||
"studyset's language, the answer in the app's", (tester) async {
|
||||
await showApp(tester, location: RoutePaths.studyset('japanese'));
|
||||
assistants.answers.add(_says('One mistake: を, not は.'));
|
||||
await unfold(tester);
|
||||
await tester.enterText(
|
||||
testWidgets('+ attaches a text file and a photo, shown above the '
|
||||
'question and sent after it, an image only to an assistant reading '
|
||||
'images; one taken off stays behind', (tester) async {
|
||||
assistants.offer = AssistantsOffer(assistants: [_seeing]);
|
||||
await showApp(tester);
|
||||
picker.nextMany = [
|
||||
(name: 'notes.md', bytes: bytesOf('て joins clauses.')),
|
||||
(name: 'board.png', bytes: bytesOf('a photo')),
|
||||
(name: 'song.mp3', bytes: bytesOf('ID3')),
|
||||
];
|
||||
|
||||
await tester.tap(find.byTooltip('Attach a photo or file'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(picker.asked.single, containsAll(['png', 'pdf', 'md']));
|
||||
expect(find.text('notes.md'), findsOneWidget);
|
||||
expect(find.text('board.png'), findsOneWidget);
|
||||
expect(
|
||||
find.textContaining("song.mp3 can't go along"),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.byTooltip('Ask'),
|
||||
findsWidgets,
|
||||
reason: 'files alone are enough to ask',
|
||||
);
|
||||
|
||||
// The snack bar gone first: it floats over the field.
|
||||
await tester.pump(const Duration(seconds: 6));
|
||||
await tester.pumpAndSettle();
|
||||
picker.nextMany = [(name: 'extra.txt', bytes: bytesOf('Not this.'))];
|
||||
await tester.tap(find.byTooltip('Attach a photo or file'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(
|
||||
find.descendant(
|
||||
of: find.byType(AssistantField),
|
||||
matching: find.byType(TextField),
|
||||
of: find.widgetWithText(InputChip, 'extra.txt'),
|
||||
matching: find.byIcon(LucideIcons.x),
|
||||
),
|
||||
'私はりんごは食べる',
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('extra.txt'), findsNothing);
|
||||
|
||||
assistants.answers.add(_says('Your notes and a whiteboard.'));
|
||||
await ask(tester, 'What did I send?');
|
||||
|
||||
final request = assistants.asked.single.$2;
|
||||
final asked = _asked(request);
|
||||
expect(asked, startsWith('What did I send?'));
|
||||
expect(
|
||||
asked,
|
||||
contains('The attached file “notes.md”:\nて joins clauses.'),
|
||||
);
|
||||
expect(asked, contains('The attached file “board.png”:\n'));
|
||||
expect(asked, isNot(contains('Not this.')));
|
||||
expect(
|
||||
request.messages.last.parts.whereType<LlmImage>().single.bytes,
|
||||
bytesOf('a photo'),
|
||||
);
|
||||
expect(request.system, contains('files they attach'));
|
||||
expect(
|
||||
find.text('Your notes and a whiteboard.', findRichText: true),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('an assistant reading no images is offered files alone, '
|
||||
'and says why a picture picked anyway stays behind', (tester) async {
|
||||
await showApp(tester);
|
||||
picker.next = (name: 'board.png', bytes: bytesOf('a photo'));
|
||||
|
||||
await tester.tap(find.byTooltip('Attach a file'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(picker.asked.single, isNot(contains('png')));
|
||||
expect(
|
||||
find.text('board.png is an image, and Claude reads none.'),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.widgetWithText(InputChip, 'board.png'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('a follow-up takes files too, and its microphone writes '
|
||||
'into it', (tester) async {
|
||||
await showApp(tester);
|
||||
assistants.answers
|
||||
..add(_says('It joins.'))
|
||||
..add(_says('Lines one and two.'));
|
||||
await ask(tester, 'Why て-form?');
|
||||
|
||||
picker.next = (name: 'lines.txt', bytes: bytesOf('One.\nTwo.'));
|
||||
await tester.tap(
|
||||
find.descendant(
|
||||
of: find.byType(BottomSheet),
|
||||
matching: find.byTooltip('Attach a file'),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.widgetWithText(InputChip, 'lines.txt'), findsOneWidget);
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byType(BottomSheet),
|
||||
matching: find.byTooltip('Speak your question'),
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
await tester.enterText(
|
||||
find.widgetWithText(TextField, 'Ask more…'),
|
||||
'And these?',
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
await tester.tap(find.byTooltip('Check for mistakes'));
|
||||
await tester.pump(const Duration(seconds: 1));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final (_, request) = assistants.asked.single;
|
||||
expect(request.system, contains('check what they wrote'));
|
||||
expect(request.system, contains('the language tagged ja'));
|
||||
expect(request.system, contains('Answer in the language tagged en'));
|
||||
expect(_asked(request), '私はりんごは食べる');
|
||||
expect(
|
||||
find.text('One mistake: を, not は.', findRichText: true),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('Check is only for an assistant, and only once something is '
|
||||
'written', (tester) async {
|
||||
await showApp(
|
||||
tester,
|
||||
web: true,
|
||||
location: RoutePaths.studyset('japanese'),
|
||||
);
|
||||
await unfold(tester);
|
||||
final check = find.widgetWithIcon(IconButton, LucideIcons.spellCheck);
|
||||
expect(
|
||||
tester.widget<IconButton>(check).onPressed,
|
||||
isNull,
|
||||
reason: 'nothing written yet',
|
||||
);
|
||||
|
||||
await tester.tap(find.byTooltip('Who answers'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Web search'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byTooltip('Check for mistakes'), findsNothing);
|
||||
});
|
||||
|
||||
/// Writes [text] into the field and checks it.
|
||||
Future<void> check(WidgetTester tester, String text) async {
|
||||
await tester.enterText(
|
||||
await tester.tap(
|
||||
find.descendant(
|
||||
of: find.byType(AssistantField),
|
||||
matching: find.byType(TextField),
|
||||
of: find.byType(BottomSheet),
|
||||
matching: find.byTooltip('Ask'),
|
||||
),
|
||||
text,
|
||||
);
|
||||
await tester.pump();
|
||||
await tester.tap(find.byTooltip('Check for mistakes'));
|
||||
await tester.pump(const Duration(seconds: 1));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
String fieldText(WidgetTester tester) => tester
|
||||
.widget<TextField>(
|
||||
find.descendant(
|
||||
of: find.byType(AssistantField),
|
||||
matching: find.byType(TextField),
|
||||
),
|
||||
)
|
||||
.controller!
|
||||
.text;
|
||||
|
||||
testWidgets("Check goes to the instance's LanguageTool first where it "
|
||||
"checks the studyset's language (ADR 0024): what it found, a "
|
||||
'suggestion taken into the field, and the assistant asked why only '
|
||||
'when asked, about the text as checked', (tester) async {
|
||||
await showApp(
|
||||
tester,
|
||||
location: RoutePaths.studyset('japanese'),
|
||||
grammarLanguages: ['ja', 'de'],
|
||||
);
|
||||
assistants.answers.add(_says('を marks the object.'));
|
||||
await unfold(tester);
|
||||
|
||||
await check(tester, '私はりんごは食べる');
|
||||
|
||||
expect(grammar.checked, [('私はりんごは食べる', 'ja')]);
|
||||
expect(assistants.asked, isEmpty);
|
||||
expect(find.text('Spelling and grammar'), findsOneWidget);
|
||||
expect(find.text('Particle'), findsOneWidget);
|
||||
expect(find.text('Use を for the object.'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.widgetWithText(ActionChip, 'を'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(fieldText(tester), '私はりんごを食べる');
|
||||
expect(find.text('Particle'), findsNothing, reason: 'taken');
|
||||
expect(
|
||||
find.text('Ask Claude why'),
|
||||
findsNothing,
|
||||
reason: 'nothing left to explain',
|
||||
);
|
||||
|
||||
// The sheet's, over the field's own.
|
||||
await tester.tap(find.byTooltip('Close').last);
|
||||
await tester.pumpAndSettle();
|
||||
grammar.checked.clear();
|
||||
await check(tester, '私はりんごは食べる');
|
||||
await tester.tap(find.text('Ask Claude why'));
|
||||
await tester.pump(const Duration(seconds: 1));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final (_, request) = assistants.asked.single;
|
||||
expect(request.system, contains('check what they wrote'));
|
||||
expect(_asked(request), '私はりんごは食べる');
|
||||
expect(
|
||||
find.text('を marks the object.', findRichText: true),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('where the instance checks the language, Check is there for '
|
||||
'the web search too, with no assistant to ask', (tester) async {
|
||||
assistants.offer = AssistantsOffer.none;
|
||||
await showApp(
|
||||
tester,
|
||||
web: true,
|
||||
location: RoutePaths.studyset('japanese'),
|
||||
grammarLanguages: ['ja'],
|
||||
);
|
||||
await unfold(tester, tooltip: 'Search the web');
|
||||
|
||||
await check(tester, '私はりんごは食べる');
|
||||
|
||||
expect(grammar.checked, hasLength(1));
|
||||
expect(find.text('Particle'), findsOneWidget);
|
||||
expect(find.textContaining('Ask '), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('where the instance checks other languages only, Check goes '
|
||||
'to the assistant as before', (tester) async {
|
||||
await showApp(
|
||||
tester,
|
||||
location: RoutePaths.studyset('japanese'),
|
||||
grammarLanguages: ['de'],
|
||||
);
|
||||
assistants.answers.add(_says('One mistake.'));
|
||||
await unfold(tester);
|
||||
|
||||
await check(tester, '私はりんごは食べる');
|
||||
|
||||
expect(grammar.checked, isEmpty);
|
||||
expect(assistants.asked, hasLength(1));
|
||||
});
|
||||
|
||||
testWidgets("where the instance's check fails, the sheet says so and "
|
||||
'offers to have the assistant check instead', (tester) async {
|
||||
grammar.refusal = GrammarRefusalReason.tooOften;
|
||||
await showApp(
|
||||
tester,
|
||||
location: RoutePaths.studyset('japanese'),
|
||||
grammarLanguages: ['ja'],
|
||||
);
|
||||
assistants.answers.add(_says('One mistake.'));
|
||||
await unfold(tester);
|
||||
|
||||
await check(tester, '私はりんごは食べる');
|
||||
|
||||
expect(
|
||||
find.text('Checked too often for now. Try again in a minute.'),
|
||||
findsOneWidget,
|
||||
);
|
||||
await tester.tap(find.text('Ask Claude to check'));
|
||||
await tester.pump(const Duration(seconds: 1));
|
||||
await tester.pumpAndSettle();
|
||||
expect(_asked(assistants.asked.single.$2), '私はりんごは食べる');
|
||||
final asked = _asked(assistants.asked.last.$2);
|
||||
expect(asked, 'And these?\n\nThe attached file “lines.txt”:\nOne.\nTwo.');
|
||||
expect(find.widgetWithText(InputChip, 'lines.txt'), findsNothing);
|
||||
});
|
||||
});
|
||||
|
||||
group('images (D12)', () {
|
||||
/// Writes a card into Verbs with a picture of a bowl on its front, as
|
||||
/// another device would have; its id.
|
||||
|
|
@ -1350,11 +1323,7 @@ void main() {
|
|||
await showApp(tester, location: RoutePaths.study('japanese'));
|
||||
await unfold(tester);
|
||||
|
||||
// The chips wrap to four lines on a phone's study screen.
|
||||
await tester.ensureVisible(find.text('Image 1'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Image 1'));
|
||||
await tester.pump();
|
||||
await choose(tester, ['Image 1']);
|
||||
assistants.answers.add(_says('A bowl of rice.'));
|
||||
await ask(tester, 'What is this?');
|
||||
|
||||
|
|
@ -1369,6 +1338,7 @@ void main() {
|
|||
await picturedCard();
|
||||
await showApp(tester, location: RoutePaths.study('japanese'));
|
||||
await unfold(tester);
|
||||
await openContext(tester);
|
||||
|
||||
expect(find.text('Image 1'), findsNothing);
|
||||
expect(find.textContaining('Front'), findsOneWidget);
|
||||
|
|
@ -1380,10 +1350,7 @@ void main() {
|
|||
final card = await picturedCard();
|
||||
await showApp(tester, location: RoutePaths.studyset('japanese'));
|
||||
await unfold(tester);
|
||||
await tester.tap(find.text('Only what I choose'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('This studyset with its topics'));
|
||||
await tester.pumpAndSettle();
|
||||
await choose(tester, ['This studyset with its topics']);
|
||||
assistants.answers
|
||||
..add([
|
||||
LlmFinished(
|
||||
|
|
@ -1431,10 +1398,7 @@ void main() {
|
|||
final card = await picturedCard();
|
||||
await showApp(tester, location: RoutePaths.studyset('japanese'));
|
||||
await unfold(tester);
|
||||
await tester.tap(find.text('Only what I choose'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('This studyset with its topics'));
|
||||
await tester.pumpAndSettle();
|
||||
await choose(tester, ['This studyset with its topics']);
|
||||
assistants.answers
|
||||
..add([
|
||||
LlmFinished(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,152 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/assistant/data/assistant_attachments.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/assistant/data/assistant_files.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/assistant/model/assistant_prompt.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/assistant/model/assistant_topic.dart';
|
||||
import 'package:halkyon_llm/halkyon_llm.dart';
|
||||
|
||||
import '../../../support/fake_pdf_engine.dart';
|
||||
|
||||
void main() {
|
||||
late FakePdfEngine pdfs;
|
||||
late AssistantAttachments attachments;
|
||||
|
||||
setUp(() {
|
||||
pdfs = FakePdfEngine();
|
||||
attachments = AssistantAttachments(pdfs);
|
||||
});
|
||||
|
||||
Uint8List bytes(String text) => Uint8List.fromList(utf8.encode(text));
|
||||
|
||||
test('a text file goes as its text, under its name', () async {
|
||||
final read = await attachments.read((
|
||||
name: 'notes.md',
|
||||
bytes: bytes('# て-form\nJoins clauses.'),
|
||||
), images: false);
|
||||
|
||||
expect(read.refused, isNull);
|
||||
expect(read.part!.kind, AssistantPartKind.file);
|
||||
expect(read.part!.name, 'notes.md');
|
||||
expect(read.part!.text, '# て-form\nJoins clauses.');
|
||||
expect(read.part!.image, isNull);
|
||||
});
|
||||
|
||||
test("a PDF goes as its pages' text, as PDFium reads it", () async {
|
||||
pdfs.texts
|
||||
..[1] = 'Chapter one.'
|
||||
..[2] = ' '
|
||||
..[3] = 'Chapter three.';
|
||||
final read = await attachments.read((
|
||||
name: 'Grammar.PDF',
|
||||
bytes: bytes('%PDF-1.7 pages:3'),
|
||||
), images: false);
|
||||
|
||||
expect(read.part!.text, 'Chapter one.\n\nChapter three.');
|
||||
});
|
||||
|
||||
test(
|
||||
"a file the app doesn't read, or binary named as text, is refused",
|
||||
() async {
|
||||
expect(
|
||||
(await attachments.read((
|
||||
name: 'song.mp3',
|
||||
bytes: bytes('ID3'),
|
||||
), images: true)).refused,
|
||||
AttachmentRefusal.notRead,
|
||||
);
|
||||
expect(
|
||||
(await attachments.read((
|
||||
name: 'broken.pdf',
|
||||
bytes: bytes('not a pdf'),
|
||||
), images: true)).refused,
|
||||
AttachmentRefusal.notRead,
|
||||
);
|
||||
expect(
|
||||
(await attachments.read((
|
||||
name: 'data.txt',
|
||||
bytes: Uint8List.fromList([0, 1, 2]),
|
||||
), images: true)).refused,
|
||||
AttachmentRefusal.notRead,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test('an image goes as itself, only to an assistant reading images, and '
|
||||
'only as large as providers take', () async {
|
||||
final photo = Uint8List.fromList([137, 80, 78, 71]);
|
||||
final read = await attachments.read((
|
||||
name: 'board.JPG',
|
||||
bytes: photo,
|
||||
), images: true);
|
||||
expect(read.part!.image!.mediaType, 'image/jpeg');
|
||||
expect(read.part!.image!.bytes, photo);
|
||||
|
||||
expect(
|
||||
(await attachments.read((
|
||||
name: 'board.png',
|
||||
bytes: photo,
|
||||
), images: false)).refused,
|
||||
AttachmentRefusal.noImages,
|
||||
);
|
||||
expect(
|
||||
(await attachments.read((
|
||||
name: 'huge.png',
|
||||
bytes: Uint8List(DeviceAssistantFiles.maxImageBytes + 1),
|
||||
), images: true)).refused,
|
||||
AttachmentRefusal.tooLarge,
|
||||
);
|
||||
});
|
||||
|
||||
test('the picker offers images only where the assistant reads them', () {
|
||||
expect(AssistantAttachments.extensionsFor(images: true), contains('png'));
|
||||
expect(
|
||||
AssistantAttachments.extensionsFor(images: false),
|
||||
isNot(contains('png')),
|
||||
);
|
||||
expect(AssistantAttachments.extensionsFor(images: false), contains('pdf'));
|
||||
});
|
||||
|
||||
test('an attached file goes after the question, labelled by its name, a '
|
||||
'long one cut, and is read back from the kept turn', () {
|
||||
final long = AssistantPart(
|
||||
kind: AssistantPartKind.file,
|
||||
text: 'x' * 30000,
|
||||
name: 'long.txt',
|
||||
);
|
||||
final image = LlmImage(
|
||||
mediaType: 'image/png',
|
||||
bytes: Uint8List.fromList(const [137, 80, 78, 71]),
|
||||
);
|
||||
final message = questionMessage('What is this?', [
|
||||
long,
|
||||
AssistantPart(
|
||||
kind: AssistantPartKind.file,
|
||||
text: '',
|
||||
name: 'board.png',
|
||||
image: image,
|
||||
),
|
||||
]);
|
||||
|
||||
final texts = [
|
||||
for (final LlmText(:text) in message.parts.whereType<LlmText>()) text,
|
||||
];
|
||||
expect(texts[1], startsWith('\n\nThe attached file “long.txt”:\nxxx'));
|
||||
expect(texts[1].length, lessThan(AssistantPart.maxFileLength + 50));
|
||||
expect(texts[2], '\n\nThe attached file “board.png”:\n');
|
||||
expect(message.parts.last, image);
|
||||
|
||||
final asked = askedIn(withoutImages([message]).single);
|
||||
expect(asked.question, 'What is this?');
|
||||
expect(
|
||||
[for (final part in asked.parts) part.name],
|
||||
[
|
||||
'long.txt',
|
||||
'board.png',
|
||||
],
|
||||
);
|
||||
expect(asked.parts.first.kind, AssistantPartKind.file);
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
import 'package:flutter/foundation.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/assistant/data/speech_input.dart';
|
||||
|
||||
/// A [SpeechInput] the test speaks into: [say] hands words on, [finish]
|
||||
/// ends the listen.
|
||||
class FakeSpeechInput() implements SpeechInput {
|
||||
@override
|
||||
bool supported = true;
|
||||
|
||||
/// Whether the device can listen when asked: false as if the microphone
|
||||
/// were refused.
|
||||
bool listens = true;
|
||||
|
||||
/// The languages asked to listen in, in order.
|
||||
final languages = <String?>[];
|
||||
|
||||
ValueChanged<String>? _onWords;
|
||||
VoidCallback? _onDone;
|
||||
|
||||
/// Whether a listen is under way.
|
||||
bool get listening => _onWords != null;
|
||||
|
||||
/// Hands on [words], as all heard so far.
|
||||
void say(String words) => _onWords?.call(words);
|
||||
|
||||
/// Ends the listen, as the recogniser does after a pause.
|
||||
void finish() {
|
||||
final done = _onDone;
|
||||
_onWords = null;
|
||||
_onDone = null;
|
||||
done?.call();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> listen({
|
||||
required String? language,
|
||||
required ValueChanged<String> onWords,
|
||||
required VoidCallback onDone,
|
||||
}) async {
|
||||
languages.add(language);
|
||||
if (!listens) {
|
||||
return false;
|
||||
}
|
||||
_onWords = onWords;
|
||||
_onDone = onDone;
|
||||
return true;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> stop() async => finish();
|
||||
}
|
||||
|
|
@ -25,6 +25,7 @@ import 'package:halkyon_learn_flutter/src/feature/archive/model/import_archive.d
|
|||
import 'package:halkyon_learn_flutter/src/feature/assistant/data/assistant_files.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/assistant/data/assistant_lookups.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/assistant/data/conversation_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/assistant/data/speech_input.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/assistant/data/web_search.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/data/card_deletions.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/data/card_gateway.dart';
|
||||
|
|
@ -105,6 +106,7 @@ import 'fake_publish_gateway.dart';
|
|||
import 'fake_review_gateway.dart';
|
||||
import 'fake_settings_gateway.dart';
|
||||
import 'fake_sound_player.dart';
|
||||
import 'fake_speech_input.dart';
|
||||
import 'fake_studyset_gateway.dart';
|
||||
import 'fake_tag_gateway.dart';
|
||||
import 'fake_youtube_videos.dart';
|
||||
|
|
@ -176,6 +178,7 @@ Dependencies testDependencies({
|
|||
bool syncedBefore = true,
|
||||
OpenPerson? openPerson,
|
||||
WebSearch? webSearch,
|
||||
SpeechInput? speech,
|
||||
JellyfinGateway? jellyfinGateway,
|
||||
JellyfinStore? jellyfinStore,
|
||||
JellyfinSuggestion? jellyfinSuggestion,
|
||||
|
|
@ -260,6 +263,7 @@ Dependencies testDependencies({
|
|||
open: (url) async => true,
|
||||
deviceSearch: (query) async => false,
|
||||
),
|
||||
speech: speech ?? FakeSpeechInput(),
|
||||
jellyfin: JellyfinRepository(
|
||||
(device) => jellyfinGateway ?? FakeJellyfinGateway(),
|
||||
jellyfinStore ?? MemoryJellyfinStore(),
|
||||
|
|
|
|||
Loading…
Reference in a new issue