halkyon/halkyon_learn_flutter/test/feature/kanji/stroke_check_test.dart
Tizian 「ティツィアーン」 89ad2c237d
feat(app): write kanji, kana and words by hand, each stroke checked
The study option Write by hand (settings schema 7) has character cards
and words written in a session, on a schedule of their own (review mode
`write`; older apps skip a mode they don't know): what to write on top, a
character's meanings and readings or a word's reading and meanings, and
below it a square to write it in, character by character.

Each stroke is checked as it ends against KanjiVG's: both resampled to 22
points, then their shape once centred, where they lie, how long they are
and which way they go, each against what a loose hand strays by; a stroke
closer to one still to come is out of order. One that counts is drawn as
KanjiVG draws it, and the hand's offset from the middle is forgiven in the
strokes after; any other flashes red, says why and counts as a mistake, as
does a hint. Two misses in a row show the next stroke faintly. Once
written, or given up on (Show it), the rating the mistakes suggest is
outlined: none Good, one or two OK, more Bad.

The square takes every touch on it at once, so writing never scrolls the
card or swipes to rate. Characters without a stroke order (in a word) are
written for the person.

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>
2026-10-09 18:09:34 +02:00

128 lines
4.4 KiB
Dart

import 'dart:math' as math;
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/feature/kanji/data/stroke_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/kanji/model/character_strokes.dart';
import 'package:halkyon_learn_flutter/src/feature/kanji/model/stroke_check.dart';
import 'package:halkyon_learn_flutter/src/feature/study/model/rating.dart';
/// Characters whose strokes the checks are tried on: kanji of few and many
/// strokes, parallel ones whose order matters, crossing ones, and kana.
const _characters = ['三', '十', '日', '木', '食', '語', 'あ', 'ア'];
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
late Map<String, List<List<StrokePoint>>> strokes;
setUpAll(() async {
final repository = StrokeRepository();
strokes = {
for (final character in _characters)
character: (await repository.strokesOf(character))!.polylines(),
};
});
/// [points] moved by [dx], [dy].
List<StrokePoint> moved(List<StrokePoint> points, double dx, double dy) => [
for (final (:x, :y) in points) (x: x + dx, y: y + dy),
];
/// [points] grown by [factor] around their middle.
List<StrokePoint> grown(List<StrokePoint> points, double factor) {
final cx = points.map((p) => p.x).reduce((a, b) => a + b) / points.length;
final cy = points.map((p) => p.y).reduce((a, b) => a + b) / points.length;
return [
for (final (:x, :y) in points)
(x: cx + (x - cx) * factor, y: cy + (y - cy) * factor),
];
}
/// [points] as a hand wavers: off the line by up to [amplitude], twice
/// along it.
List<StrokePoint> wavering(List<StrokePoint> points, double amplitude) => [
for (final (i, (:x, :y)) in points.indexed)
(
x: x + amplitude * math.sin(i / points.length * 4 * math.pi),
y: y + amplitude * math.cos(i / points.length * 4 * math.pi),
),
];
test('a stroke written as it should be counts, also moved a little, '
'wavering, or a size larger or smaller', () {
for (final MapEntry(key: character, value: lines) in strokes.entries) {
for (final (i, line) in lines.indexed) {
for (final (how, written) in [
('as drawn', line),
('moved', moved(line, 5, -5)),
('wavering', wavering(line, 1.5)),
('larger', grown(line, 1.2)),
('smaller', grown(line, 0.85)),
]) {
expect(
checkStroke(written, lines, i),
StrokeVerdict.right,
reason: '$character, stroke ${i + 1}, $how',
);
}
}
}
});
test('a stroke written the other way round does not count, but for a '
'dot, which goes no way to speak of', () {
for (final MapEntry(key: character, value: lines) in strokes.entries) {
for (final (i, line) in lines.indexed) {
if (lengthOf(line) < 12) {
continue;
}
expect(
checkStroke(line.reversed.toList(), lines, i),
isNot(StrokeVerdict.right),
reason: '$character, stroke ${i + 1}',
);
}
}
});
test('a later stroke written before its turn is out of order', () {
for (final MapEntry(key: character, value: lines) in strokes.entries) {
for (var i = 0; i < lines.length; i++) {
for (var j = i + 1; j < lines.length; j++) {
expect(
checkStroke(lines[j], lines, i),
StrokeVerdict.wrongOrder,
reason: '$character: stroke ${j + 1} for ${i + 1}',
);
}
}
}
});
test('a stroke nowhere near any to come is wrong', () {
final lines = strokes['十']!;
// A diagonal through the box.
final slash = [
for (var t = 0.0; t <= 1; t += 0.02) (x: 10 + 80 * t, y: 10 + 80 * t),
];
expect(checkStroke(slash, lines, 0), StrokeVerdict.wrong);
expect(checkStroke(const [(x: 50, y: 50)], lines, 0), StrokeVerdict.wrong);
});
test('the rating suggested: none wrong Good, one or two Hard, more '
'Again', () {
expect(
[
for (final mistakes in [0, 1, 2, 3, 7]) suggestedRating(mistakes),
],
[Rating.good, Rating.hard, Rating.hard, Rating.again, Rating.again],
);
});
test('resampled: evenly along the line, the ends kept', () {
final points = resampled(const [(x: 0, y: 0), (x: 10, y: 0)], 11);
expect(points, hasLength(11));
expect(points.first, (x: 0.0, y: 0.0));
expect(points[5].x, closeTo(5, 1e-9));
expect(points.last, (x: 10.0, y: 0.0));
});
}