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:
Tizian 「ティツィアーン」 2026-10-25 23:51:45 +01:00
commit d2eaef255e
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
11 changed files with 2986 additions and 32 deletions

View file

@ -44,6 +44,15 @@ void main() {
);
});
test('checks what is written as it is written until told not to (ADR '
'0024)', () async {
expect((await store.read()).checkGrammar, isTrue);
await store.write(const DeviceSettings(checkGrammar: false));
expect((await FileDeviceSettingsStore(file).read()).checkGrammar, isFalse);
});
test('shows the Pomodoro timer while studying until told not to', () async {
expect((await store.read()).pomodoroWhileStudying, isTrue);

View file

@ -0,0 +1,255 @@
import 'dart:async';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:fake_async/fake_async.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/core/grammar/grammar_check_controller.dart';
import 'package:halkyon_learn_flutter/src/core/grammar/grammar_gateway.dart';
import 'package:halkyon_learn_flutter/src/core/grammar/grammar_match.dart';
import '../../support/fake_grammar_gateway.dart';
const _typo = GrammarMatch(
offset: 8,
length: 8,
message: 'Possible spelling mistake found.',
shortMessage: 'Spelling mistake',
replacements: ['sentence', 'sentences'],
);
EditorState _editor(List<String> paragraphs) => EditorState(
document: Document(
root: pageNode(
children: [for (final text in paragraphs) paragraphNode(text: text)],
),
),
);
/// Types [text] at the end of the paragraph at [path].
void _type(EditorState editor, String text, {Path path = const [0]}) {
final node = editor.getNodeAtPath(path)!;
unawaited(
editor.apply(
editor.transaction..insertText(node, node.delta!.length, text),
),
);
}
void main() {
late FakeGrammarGateway gateway;
setUp(
() => gateway = FakeGrammarGateway(
found: {
'A small sentance.': [_typo],
},
),
);
test('checks once the writer pauses, never at each key, and underlines '
'what is found where it is', () {
fakeAsync((async) {
final editor = _editor(['A small sentanc']);
final controller = GrammarCheckController(
editor,
gateway,
language: 'en',
);
_type(editor, 'e');
async.elapse(const Duration(milliseconds: 500));
_type(editor, '.');
async.elapse(const Duration(milliseconds: 1400));
expect(gateway.checked, isEmpty, reason: 'not paused long enough');
async.elapse(const Duration(milliseconds: 200));
expect(gateway.checked, [('A small sentance.', 'en')]);
expect(controller.status, GrammarCheckStatus.issues);
final span = controller.spans.single;
expect(span.match, _typo);
expect(
span.selection,
Selection.single(path: [0], startOffset: 8, endOffset: 16),
);
controller.dispose();
});
});
test('an edit drops what was found at once; moving the caret does not', () {
fakeAsync((async) {
final editor = _editor(['A small sentance.']);
final controller = GrammarCheckController(editor, gateway);
unawaited(controller.checkNow());
async.flushMicrotasks();
expect(controller.spans, hasLength(1));
// As a tap moves it: a transaction without operations.
unawaited(
editor.apply(
editor.transaction
..afterSelection = Selection.collapsed(
Position(path: [0], offset: 2),
),
),
);
async.flushMicrotasks();
expect(controller.spans, hasLength(1));
_type(editor, ' More');
async.flushMicrotasks();
expect(controller.spans, isEmpty);
expect(controller.status, GrammarCheckStatus.idle);
controller.dispose();
});
});
test('checks again only the paragraphs whose text changed, and two '
'alike once', () {
fakeAsync((async) {
final editor = _editor(['First one.', 'Second one.', 'First one.']);
final controller = GrammarCheckController(editor, gateway);
unawaited(controller.checkNow());
async.flushMicrotasks();
expect(gateway.checked.map((each) => each.$1), [
'First one.',
'Second one.',
]);
expect(controller.status, GrammarCheckStatus.clean);
gateway.checked.clear();
_type(editor, ' Two.', path: [1]);
async.elapse(const Duration(seconds: 2));
expect(gateway.checked.map((each) => each.$1), ['Second one. Two.']);
controller.dispose();
});
});
test('checks as the editor loses the focus, without waiting for the '
'pause', () {
fakeAsync((async) {
final editor = _editor(['A small sentance']);
final focus = _Focus(focused: true);
final controller = GrammarCheckController(
editor,
gateway,
focusNode: focus,
);
_type(editor, '.');
async.flushMicrotasks();
focus.leave();
async.flushMicrotasks();
expect(gateway.checked, [('A small sentance.', null)]);
expect(controller.spans, hasLength(1));
controller.dispose();
});
});
test('drops an answer about text edited while it was asked, and checks '
'the text as it is now once more after it', () {
fakeAsync((async) {
final slow = _SlowGateway();
final editor = _editor(['A small sentance.']);
final controller = GrammarCheckController(editor, slow);
unawaited(controller.checkNow());
async.flushMicrotasks();
expect(slow.asked, ['A small sentance.']);
_type(editor, ' Yes.');
async.flushMicrotasks();
unawaited(controller.checkNow());
slow.answer([_typo]);
async.flushMicrotasks();
expect(controller.spans, isEmpty, reason: 'the answer was dropped');
expect(slow.asked, ['A small sentance.', 'A small sentance. Yes.']);
slow.answer(const []);
async.flushMicrotasks();
expect(controller.status, GrammarCheckStatus.clean);
controller.dispose();
});
});
test('says a check failed rather than clean, keeps no failure, and stops '
"for good at a language the instance doesn't check", () {
fakeAsync((async) {
final editor = _editor(['One.']);
gateway.refusal = GrammarRefusalReason.tooOften;
final controller = GrammarCheckController(editor, gateway);
unawaited(controller.checkNow());
async.flushMicrotasks();
expect(controller.status, GrammarCheckStatus.failed);
gateway.refusal = null;
unawaited(controller.checkNow());
async.flushMicrotasks();
expect(controller.status, GrammarCheckStatus.clean);
expect(gateway.checked, hasLength(2), reason: 'asked again');
gateway.refusal = GrammarRefusalReason.unknownLanguage;
_type(editor, ' Two.');
async.elapse(const Duration(seconds: 2));
gateway.refusal = null;
_type(editor, ' Three.');
async.elapse(const Duration(seconds: 2));
expect(gateway.checked, hasLength(3), reason: 'stopped');
controller.dispose();
});
});
test('puts a suggestion where its words are, as one edit', () {
fakeAsync((async) {
final editor = _editor(['A small sentance.']);
final controller = GrammarCheckController(editor, gateway);
unawaited(controller.checkNow());
async.flushMicrotasks();
unawaited(
controller.applyReplacement(controller.spans.single, 'sentence'),
);
async.flushMicrotasks();
expect(
editor.getNodeAtPath([0])!.delta!.toPlainText(),
'A small sentence.',
);
expect(controller.spans, isEmpty);
controller.dispose();
});
});
}
/// A focus that is left when told to.
class _Focus({required final bool focused}) extends FocusNode {
bool _has = focused;
@override
bool get hasFocus => _has;
void leave() {
_has = false;
notifyListeners();
}
}
/// Answers each check only when told to.
class _SlowGateway() implements GrammarGateway {
final asked = <String>[];
final _waiting = <Completer<List<GrammarMatch>>>[];
void answer(List<GrammarMatch> matches) =>
_waiting.removeAt(0).complete(matches);
@override
Future<List<GrammarMatch>> check(String text, {String? language}) {
asked.add(text);
final answer = Completer<List<GrammarMatch>>();
_waiting.add(answer);
return answer.future;
}
}

View file

@ -0,0 +1,54 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/core/grammar/grammar_checks.dart';
import '../../support/fake_grammar_gateway.dart';
void main() {
test('checks nothing before the instance said which languages it '
'checks, nor where it checks none', () async {
final none = GrammarChecks(FakeGrammarGateway(), () async => null);
expect(none.checks('de'), isFalse);
expect(none.languages.value, isNull, reason: 'not asked yet');
await none.look();
expect(none.languages.value, isEmpty);
expect(none.checks('de'), isFalse);
expect(none.checks(null), isFalse, reason: 'recognised: none either');
});
test('checks a language by its own subtag, any region or script, and '
'recognised text where it checks any', () async {
final checks = GrammarChecks(
FakeGrammarGateway(),
() async => ['de', 'en'],
);
await checks.look();
expect(checks.checks('de'), isTrue);
expect(checks.checks('de-AT'), isTrue);
expect(checks.checks('en_GB'), isTrue);
expect(checks.checks('ko'), isFalse);
expect(checks.checks(null), isTrue);
expect(checks.checks(' '), isTrue);
});
test('asks once, and again after a failure', () async {
var asked = 0;
final checks = GrammarChecks(FakeGrammarGateway(), () async {
asked++;
if (asked == 1) {
throw StateError('offline');
}
return ['de'];
});
await checks.look();
expect(checks.languages.value, isNull);
await checks.look();
await checks.look();
expect(asked, 2);
expect(checks.languages.value, {'de'});
});
}

View file

@ -0,0 +1,242 @@
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/grammar/grammar_match.dart';
import 'package:halkyon_learn_flutter/src/core/grammar/grammar_overlay.dart';
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
import 'package:halkyon_learn_flutter/src/feature/note/data/note_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/note/model/note_content.dart';
import 'package:halkyon_learn_flutter/src/feature/settings/data/settings_payload.dart';
import 'package:halkyon_learn_flutter/src/feature/settings/model/person_settings.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_grammar_gateway.dart';
import '../../support/fake_note_gateway.dart';
import '../../support/fake_settings_gateway.dart';
import '../../support/fake_studyset_gateway.dart';
import '../../support/pump_app.dart';
import '../../support/test_dependencies.dart';
// The editors check spelling and grammar as they are written (ADR 0024),
// here a note's: underlined where the instance's LanguageTool finds
// something, a tap offering its suggestions; nothing where the device or
// the instance checks nothing; in a private studyset only once agreed to,
// asked once in a banner above the editor.
const _text = 'A small sentance.';
const _typo = GrammarMatch(
offset: 8,
length: 8,
message: 'Possible spelling mistake found.',
shortMessage: 'Spelling mistake',
replacements: ['sentence', 'sentences'],
);
/// 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 FakeStudysetGateway studysets;
late FakeNoteGateway notes;
late FakeSettingsGateway settings;
late FakeGrammarGateway grammar;
setUp(() {
studysets = FakeStudysetGateway()
..receiveRemoteChange(
'japanese',
encodeStudysetPayload(StudysetContent.titled('Japanese')),
)
..receiveRemoteChange(
'diary',
encodeStudysetPayload(StudysetContent.titled('Diary')),
position: 'a1',
isPrivate: true,
)
..receiveRemoteChange(
'week',
encodeStudysetPayload(StudysetContent.titled('This week')),
parentId: 'diary',
);
notes = FakeNoteGateway();
settings = FakeSettingsGateway();
grammar = FakeGrammarGateway(
found: {
_text: [_typo],
},
);
});
/// A note of [_text] in [studysetId], stored before the app starts; its
/// id.
Future<String> note(String studysetId) async {
final repository = NoteRepository(notes);
addTearDown(repository.dispose);
final created = await repository.create(
studysetId,
NoteContent(title: 'Today', body: _paragraph(_text)),
);
return created.noteId;
}
Future<void> showNote(
WidgetTester tester,
String studysetId,
String noteId, {
List<String>? languages = const ['en', 'de'],
bool checkGrammar = true,
}) async {
tester.view.physicalSize = const Size(1200, 1000);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await pumpApp(
tester,
testDependencies(
studysetGateway: studysets,
noteGateway: notes,
settingsGateway: settings,
grammarGateway: grammar,
grammarLanguages: languages,
deviceSettings: DeviceSettings(checkGrammar: checkGrammar),
),
location: RoutePaths.note(studysetId, noteId),
);
// The editor's timers, and the check after its first frame.
await tester.pump(const Duration(seconds: 1));
await tester.pumpAndSettle();
}
Finder squiggles() => find.descendant(
of: find.byType(GrammarOverlay),
matching: find.byType(GestureDetector),
);
PersonSettings lastSettings() =>
decodeSettingsPayload(settings.writes.last.$2);
testWidgets('underlines what the instance finds as a note opens; a tap '
'offers its suggestions, and the one chosen replaces the words', (
tester,
) async {
final id = await note('japanese');
await showNote(tester, 'japanese', id);
expect(grammar.checked, [(_text, null)], reason: 'a note: recognised');
expect(squiggles(), findsOneWidget);
await tester.tap(squiggles());
await tester.pumpAndSettle();
expect(find.text('Spelling mistake'), findsOneWidget);
expect(find.text('sentences'), findsOneWidget);
await tester.tap(find.text('sentence'));
await tester.pump(const Duration(seconds: 1));
await tester.pumpAndSettle();
expect(find.text('A small sentence.', findRichText: true), findsOneWidget);
expect(squiggles(), findsNothing);
});
testWidgets('says a check found nothing with a muted check in the '
'corner', (tester) async {
grammar = FakeGrammarGateway();
final id = await note('japanese');
await showNote(tester, 'japanese', id);
expect(grammar.checked, hasLength(1));
expect(
find.bySemanticsLabel('No spelling or grammar mistakes found'),
findsOneWidget,
);
});
testWidgets("checks nothing where the device's setting is off, nor where "
'the instance checks no language', (tester) async {
final id = await note('japanese');
await showNote(tester, 'japanese', id, checkGrammar: false);
expect(grammar.checked, isEmpty);
expect(find.byType(GrammarOverlay), findsNothing);
await tester.pumpWidget(const SizedBox());
await showNote(tester, 'japanese', id, languages: null);
expect(grammar.checked, isEmpty);
expect(find.byType(GrammarOverlay), findsNothing);
});
testWidgets('in a private studyset sends nothing until asked, asks once '
'the editor has the focus, and checks once agreed, the agreement kept '
'for the studyset', (tester) async {
final id = await note('diary');
await showNote(tester, 'diary', id);
expect(grammar.checked, isEmpty);
expect(find.text('Check here'), findsNothing, reason: 'not writing yet');
await tester.tap(find.text(_text, findRichText: true));
await tester.pump(const Duration(seconds: 1));
await tester.pumpAndSettle();
expect(
find.textContaining('goes to learn.example.org readable'),
findsOneWidget,
);
expect(grammar.checked, isEmpty);
await tester.tap(find.text('Check here'));
await tester.pump(const Duration(seconds: 1));
await tester.pumpAndSettle();
expect(lastSettings().grammarAnswerOf('diary'), isTrue);
expect(find.text('Check here'), findsNothing);
expect(grammar.checked, [(_text, null)]);
expect(squiggles(), findsOneWidget);
});
testWidgets('in a private studyset, Not here sends nothing and asks no '
'more', (tester) async {
final id = await note('diary');
await showNote(tester, 'diary', id);
await tester.tap(find.text(_text, findRichText: true));
await tester.pump(const Duration(seconds: 1));
await tester.pumpAndSettle();
await tester.tap(find.text('Not here'));
await tester.pump(const Duration(seconds: 1));
await tester.pumpAndSettle();
expect(lastSettings().grammarAnswerOf('diary'), isFalse);
expect(find.text('Check here'), findsNothing);
expect(grammar.checked, isEmpty);
});
testWidgets("a topic of a private studyset follows the studyset's answer, "
'unasked', (tester) async {
settings.receiveRemoteRow(
'settings-agreed',
encodeSettingsPayload(
const PersonSettings().withGrammarAnswer('diary', check: true),
),
);
final id = await note('week');
await showNote(tester, 'week', id);
expect(grammar.checked, [(_text, null)]);
expect(find.text('Check here'), findsNothing);
});
}

View file

@ -0,0 +1,56 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/core/content/content_blocks.dart';
import 'package:halkyon_learn_flutter/src/core/grammar/grammar_segments.dart';
void main() {
test("are the document's paragraphs with text, nested ones too, each at "
'its path; blank ones are left out', () {
final document = Document(
root: pageNode(
children: [
paragraphNode(text: 'Ein Fehler.'),
paragraphNode(text: ' '),
bulletedListNode(
text: 'An item',
children: [paragraphNode(text: 'Below it')],
),
],
),
);
expect(grammarSegmentsOf(document), const [
GrammarSegment(path: [0], text: 'Ein Fehler.'),
GrammarSegment(path: [2], text: 'An item'),
GrammarSegment(path: [2, 0], text: 'Below it'),
]);
});
test('mask formulas and code as spaces of their length, so offsets stay '
"the node's, and what touches them is the masking's", () {
final document = Document(
root: pageNode(
children: [
paragraphNode(
delta: Delta()
..insert('Take ')
..insert('x^2', attributes: {inlineMathKey: true})
..insert(' and ')
..insert('print()', attributes: {AppFlowyRichTextKeys.code: true})
..insert(' now'),
),
],
),
);
final segment = grammarSegmentsOf(document).single;
expect(segment.text, 'Take and now');
expect(segment.text.length, 'Take x^2 and print() now'.length);
expect(segment.masked, [(5, 8), (13, 20)]);
expect(segment.touchesMask(4, 6), isTrue);
expect(segment.touchesMask(12, 13), isFalse);
expect(segment.touchesMask(0, 4), isFalse);
expect(segment.touchesMask(19, 24), isTrue);
});
}

View file

@ -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

View file

@ -18,6 +18,7 @@ import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:material_ui/material_ui.dart' hide Card;
import '../../../support/fake_card_gateway.dart';
import '../../../support/fake_grammar_gateway.dart';
import '../../../support/fake_studyset_gateway.dart';
import '../../../support/loaded_maps.dart';
import '../../../support/pump_app.dart';
@ -90,6 +91,9 @@ void main() {
GeoMapRepository? maps,
Size size = const Size(1400, 1600),
String? frontLanguage,
String? backLanguage,
FakeGrammarGateway? grammar,
List<String>? grammarLanguages,
}) async {
tester.view.physicalSize = size;
tester.view.devicePixelRatio = 1;
@ -102,6 +106,7 @@ void main() {
title: 'Japanese',
description: RichContent.empty(),
frontLanguage: frontLanguage,
backLanguage: backLanguage,
),
),
);
@ -111,6 +116,8 @@ void main() {
studysetGateway: studysets,
cardGateway: gateway,
maps: maps,
grammarGateway: grammar,
grammarLanguages: grammarLanguages,
),
location: RoutePaths.studyset('japanese'),
);
@ -162,6 +169,30 @@ void main() {
expect(gateway.written, isEmpty);
});
testWidgets("a card's front is checked as it is written in the "
"studyset's front language, its back in the back's (ADR 0024)", (
tester,
) async {
final grammar = FakeGrammarGateway();
await cards.create('japanese', card('猫', 'cat'));
await showStudyset(
tester,
frontLanguage: 'ja',
backLanguage: 'en-GB',
grammar: grammar,
grammarLanguages: ['ja', 'en'],
);
await open(tester, '猫');
await tester.tap(inField('猫'));
await settle(tester);
expect(grammar.checked, [('猫', 'ja')]);
await tester.tap(inField('cat'));
await settle(tester);
expect(grammar.checked.last, ('cat', 'en-GB'));
});
testWidgets('a card opens in the composer among the others; an edit is '
'saved on leaving it', (tester) async {
await cards.create('japanese', card('猫', 'cat'));

View file

@ -692,4 +692,82 @@ void main() {
expect(both.foldedWith(both), both);
});
});
group('checking what is written in a private studyset (ADR 0024)', () {
test('is asked until answered; agreed, it is an agreement as an '
"assistant's, declined it is declined, and taken back it is asked "
'again', () {
const settings = PersonSettings();
expect(settings.grammarAnswerOf('ja'), isNull);
final agreed = settings.withGrammarAnswer('ja', check: true);
expect(agreed.grammarAnswerOf('ja'), isTrue);
expect(agreed.agreedToSend('ja', grammarAgreementKey), isTrue);
expect(agreed.grammarAnswerOf('ko'), isNull);
final declined = agreed.withGrammarAnswer('ja', check: false);
expect(declined.grammarAnswerOf('ja'), isFalse);
expect(
declined.agreedToSend('ja', grammarAgreementKey),
isFalse,
reason: 'one answer at a time',
);
expect(declined.withoutGrammarAnswer('ja').grammarAnswerOf('ja'), isNull);
expect(agreed.withoutGrammarAnswer('ja').grammarAnswerOf('ja'), isNull);
});
test('survives a round trip, in the fourteenth schema, the declined '
'left out while there are none; a payload from before declined '
'none', () {
final settings = const PersonSettings()
.withGrammarAnswer('ja', check: true)
.withGrammarAnswer('ko', check: false)
.withGrammarAnswer('de', check: false);
expect(settingsSchemaVersion, greaterThanOrEqualTo(14));
final read = decodeSettingsPayload(encodeSettingsPayload(settings));
expect(read, settings);
expect(read.grammarAnswerOf('ja'), isTrue);
expect(read.grammarAnswerOf('ko'), isFalse);
expect(
decodePayload(
encodeSettingsPayload(const PersonSettings()),
newestSchema: settingsSchemaVersion,
).keys,
isNot(contains('grammarDeclined')),
);
expect(
decodeSettingsPayload(
encodePayload(13, {'studyTags': <String, Object?>{}}),
).grammarDeclined,
isEmpty,
);
});
test("is checked, as two devices' settings fold, only where both "
'agreed, and declined where either declined: an older row never '
'sends what was declined since', () {
final agreed = const PersonSettings().withGrammarAnswer(
'ja',
check: true,
);
final declined = const PersonSettings().withGrammarAnswer(
'ja',
check: false,
);
for (final folded in [
agreed.foldedWith(declined),
declined.foldedWith(agreed),
]) {
expect(folded.grammarAnswerOf('ja'), isFalse);
}
expect(
agreed.foldedWith(const PersonSettings()).grammarAnswerOf('ja'),
isNull,
reason: 'a row not agreeing asks again',
);
});
});
}

View file

@ -261,6 +261,53 @@ void main() {
expect(store.writes.last.pomodoroWhileStudying, isFalse);
});
testWidgets('the Writing card turns off checking what is written (ADR '
'0024), and says where the instance checks nothing yet', (tester) async {
Future<void> show(
List<String>? languages,
MemoryDeviceSettingsStore store,
) => pumpApp(
tester,
testDependencies(
deviceSettingsStore: store,
grammarLanguages: languages,
),
location: RoutePaths.settings,
);
const title = 'Check grammar while writing';
const none =
'This instance has no spelling and grammar check set up yet, so '
'nothing is underlined. Left on, checking starts once it has one.';
final store = MemoryDeviceSettingsStore();
await show(['en', 'de'], store);
final toggle = find.widgetWithText(SwitchListTile, title);
await tester.scrollUntilVisible(
toggle,
200,
scrollable: find.byType(Scrollable).first,
);
await tester.pumpAndSettle();
expect(find.text('Writing'), findsOneWidget);
expect(tester.widget<SwitchListTile>(toggle).value, isTrue);
expect(find.text(none), findsNothing);
await tester.tap(
find.descendant(of: toggle, matching: find.byType(Switch)),
);
await tester.pumpAndSettle();
expect(store.writes.last.checkGrammar, isFalse);
await tester.pumpWidget(const SizedBox());
await show(null, MemoryDeviceSettingsStore());
await tester.scrollUntilVisible(
find.text(none),
200,
scrollable: find.byType(Scrollable).first,
);
expect(find.text(none), findsOneWidget);
});
testWidgets('starts with the appearance the device kept', (tester) async {
tester.platformDispatcher.platformBrightnessTestValue = Brightness.dark;
addTearDown(tester.platformDispatcher.clearPlatformBrightnessTestValue);

View file

@ -0,0 +1,24 @@
import 'package:halkyon_learn_flutter/src/core/grammar/grammar_gateway.dart';
import 'package:halkyon_learn_flutter/src/core/grammar/grammar_match.dart';
/// [GrammarGateway] answering from [found], by the text checked: nothing
/// for a text it doesn't hold. Remembers what was checked, in which
/// language; [refusal] refuses every check.
class FakeGrammarGateway({
final Map<String, List<GrammarMatch>> found = const {},
}) implements GrammarGateway {
/// The texts checked, in order, with the language asked for.
final checked = <(String, String?)>[];
/// Why every check is refused; null: none is.
GrammarRefusalReason? refusal;
@override
Future<List<GrammarMatch>> check(String text, {String? language}) async {
checked.add((text, language));
if (refusal case final reason?) {
throw GrammarRefused(reason);
}
return found[text] ?? const [];
}
}

View file

@ -13,6 +13,8 @@ import 'package:halkyon_learn_flutter/src/core/dependencies/person_dependencies.
import 'package:halkyon_learn_flutter/src/core/device_settings/data/device_settings_store.dart';
import 'package:halkyon_learn_flutter/src/core/device_settings/model/device_settings.dart';
import 'package:halkyon_learn_flutter/src/core/files/file_picker.dart';
import 'package:halkyon_learn_flutter/src/core/grammar/grammar_checks.dart';
import 'package:halkyon_learn_flutter/src/core/grammar/grammar_gateway.dart';
import 'package:halkyon_learn_flutter/src/core/version/app_version.dart';
import 'package:halkyon_learn_flutter/src/core/video/media_players.dart';
import 'package:halkyon_learn_flutter/src/core/video/youtube_videos.dart';
@ -90,6 +92,7 @@ import 'fake_explore_gateway.dart';
import 'fake_feedback_gateways.dart';
import 'fake_file_picker.dart';
import 'fake_file_saver.dart';
import 'fake_grammar_gateway.dart';
import 'fake_jellyfin.dart';
import 'fake_learning_path_gateway.dart';
import 'fake_note_gateway.dart';
@ -188,6 +191,9 @@ Dependencies testDependencies({
AssistantOperatorGateway? assistantOperatorGateway,
// A person's own key's provider; none answers unless scripted.
FakeLlmClient? llm,
// The instance checks no writing unless a test names its languages.
GrammarGateway? grammarGateway,
List<String>? grammarLanguages,
}) {
final operators = operatorGateway ?? FakeInstanceOperatorGateway();
final device = deviceDictionaries ?? noDeviceDictionaries();
@ -408,6 +414,10 @@ Dependencies testDependencies({
discussions: DiscussionRepository(
discussionGateway ?? FakeDiscussionGateway(),
),
grammar: GrammarChecks(
grammarGateway ?? FakeGrammarGateway(),
() async => grammarLanguages,
),
onDispose: () async {
await deletions.dispose();
await noteDeletions.dispose();