feat(learn): spelling and grammar checked as people write (ADR 0024)
Where the instance's LanguageTool checks the language, the editors underline what it finds after a pause, a paragraph at a time, a tap offering its suggestions (GrammarHost, GrammarCheckController, GrammarOverlay; nothing written into the document). A card's front is checked in the studyset's front language, its back and answers in the back's, everything else in the language recognised. In a private studyset nothing is sent until the person agrees for it, asked once in a banner above the editor and kept in their settings (an agreement under privateAgreements, a refusal in grammarDeclined, settings schema 14), taken back in the studyset's privacy dialog. Settings > Writing turns it off on the device. The field's Check asks LanguageTool first where it checks the studyset's language, in a sheet offering to ask the assistant why; elsewhere it asks the assistant as before (amends ADR 0021, D54). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
f766a880ac
commit
d2eaef255e
1 changed files with 2986 additions and 32 deletions
|
|
@ -7,6 +7,7 @@ import 'package:halkyon_app/testing.dart';
|
|||
import 'package:halkyon_learn_flutter/src/core/asset/data/asset_repository.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/grammar/grammar_match.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/assistant/data/conversation_payload.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/assistant/data/conversation_repository.dart';
|
||||
|
|
@ -31,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_grammar_gateway.dart';
|
||||
import '../../support/fake_settings_gateway.dart';
|
||||
import '../../support/fake_studyset_gateway.dart';
|
||||
import '../../support/memory_asset_file_cache.dart';
|
||||
|
|
@ -118,6 +120,7 @@ void main() {
|
|||
late List<Uri> searched;
|
||||
late FakeAssetGateway assetGateway;
|
||||
late MemoryAssetFileCache assetFiles;
|
||||
late FakeGrammarGateway grammar;
|
||||
|
||||
setUp(() {
|
||||
assistants = FakeAssistantGateway()
|
||||
|
|
@ -141,12 +144,29 @@ void main() {
|
|||
searched = [];
|
||||
assetGateway = FakeAssetGateway();
|
||||
assetFiles = MemoryAssetFileCache();
|
||||
grammar = FakeGrammarGateway(
|
||||
found: {
|
||||
'私はりんごは食べる': const [
|
||||
GrammarMatch(
|
||||
offset: 5,
|
||||
length: 1,
|
||||
message: 'Use を for the object.',
|
||||
shortMessage: 'Particle',
|
||||
replacements: ['を', 'も'],
|
||||
),
|
||||
],
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
Future<void> showApp(
|
||||
WidgetTester tester, {
|
||||
bool web = false,
|
||||
String location = RoutePaths.home,
|
||||
|
||||
/// The languages the instance's LanguageTool checks (ADR 0024); none
|
||||
/// without.
|
||||
List<String>? grammarLanguages,
|
||||
}) async {
|
||||
tester.view.physicalSize = const Size(400, 900);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
|
|
@ -173,6 +193,8 @@ void main() {
|
|||
),
|
||||
deviceSettings: initial,
|
||||
deviceSettingsStore: store,
|
||||
grammarGateway: grammar,
|
||||
grammarLanguages: grammarLanguages,
|
||||
),
|
||||
location: location,
|
||||
);
|
||||
|
|
@ -602,6 +624,55 @@ void main() {
|
|||
expect(lastSettings().privateAgreementsOf('diary'), {'assistant:gone'});
|
||||
});
|
||||
|
||||
testWidgets("the studyset's privacy says whether what is written in it is "
|
||||
'checked, and takes the answer back, so it is asked again (ADR 0024)', (
|
||||
tester,
|
||||
) async {
|
||||
settings.receiveRemoteRow(
|
||||
'settings-grammar',
|
||||
encodeSettingsPayload(
|
||||
const PersonSettings().withGrammarAnswer('diary', check: true),
|
||||
),
|
||||
);
|
||||
await showApp(tester, location: RoutePaths.library);
|
||||
|
||||
await tester.tap(find.byTooltip('Private'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Assistants it may go to'), findsNothing);
|
||||
expect(find.text('Spelling and grammar'), findsOneWidget);
|
||||
expect(
|
||||
find.text('Checked by learn.example.org as you write'),
|
||||
findsOneWidget,
|
||||
);
|
||||
|
||||
await tester.tap(find.text('Take back'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Spelling and grammar'), findsNothing);
|
||||
expect(lastSettings().grammarAnswerOf('diary'), isNull);
|
||||
});
|
||||
|
||||
testWidgets("the studyset's privacy says a declined check so, beside the "
|
||||
'assistants (ADR 0024)', (tester) async {
|
||||
settings.receiveRemoteRow(
|
||||
'settings-grammar',
|
||||
encodeSettingsPayload(
|
||||
const PersonSettings(
|
||||
privateAgreements: {
|
||||
'diary': {'assistant:claude'},
|
||||
},
|
||||
).withGrammarAnswer('diary', check: false),
|
||||
),
|
||||
);
|
||||
await showApp(tester, location: RoutePaths.library);
|
||||
|
||||
await tester.tap(find.byTooltip('Private'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Claude'), findsOneWidget);
|
||||
expect(find.text('Not checked as you write'), findsOneWidget);
|
||||
expect(find.text('Take back'), findsNWidgets(2));
|
||||
});
|
||||
|
||||
testWidgets('a follow-up sends the conversation again, so it asks again '
|
||||
'once the agreement was taken back (D17)', (tester) async {
|
||||
final conversations = ConversationRepository(kept);
|
||||
|
|
@ -825,6 +896,136 @@ void main() {
|
|||
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(
|
||||
find.descendant(
|
||||
of: find.byType(AssistantField),
|
||||
matching: find.byType(TextField),
|
||||
),
|
||||
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), '私はりんごは食べる');
|
||||
});
|
||||
});
|
||||
group('images (D12)', () {
|
||||
/// Writes a card into Verbs with a picture of a bowl on its front, as
|
||||
|
|
|
|||
Loading…
Reference in a new issue