chore: Halkyon Learn moves into apps/learn

Its server, client, app and curator, and the tools that write its
assets (maps, Anki fixtures, icons), as they are; paths are fixed in
the commits that follow.

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-19 20:08:06 +02:00
commit 649499be23
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
4 changed files with 0 additions and 0 deletions

View file

@ -0,0 +1,26 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/feature/kanji/model/hangul.dart';
void main() {
test('a Hangul syllable is written with its letters: the opening '
'consonant, the vowel and the closing one or cluster', () {
expect(hangulLetters('가'), ['ㄱ', 'ㅏ']);
expect(hangulLetters('닭'), ['ㄷ', 'ㅏ', 'ㄺ']);
expect(hangulLetters('쓰'), ['ㅆ', 'ㅡ']);
expect(hangulLetters('왜'), ['ㅇ', 'ㅙ']);
expect(hangulLetters('힣'), ['ㅎ', 'ㅣ', 'ㅎ']);
// What is no syllable stays as it is.
expect(hangulLetters('ㄱ'), ['ㄱ']);
expect(hangulLetters('食'), ['食']);
});
test('scripts whose strokes are drawn: Han characters, kana, Hangul; '
'not the alphabets', () {
expect(hasDrawnScript('食べる'), isTrue);
expect(hasDrawnScript('学习'), isTrue);
expect(hasDrawnScript('학교'), isTrue);
expect(hasDrawnScript('ㄱ'), isTrue);
expect(hasDrawnScript('das Haus'), isFalse);
expect(hasDrawnScript('Β β'), isFalse);
});
}

View file

@ -0,0 +1,57 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/feature/kanji/model/radical.dart';
void main() {
test('the 214 Kangxi radicals in their order, by their strokes from 1 '
'to 17', () {
expect(
[for (final radical in radicals) radical.number],
[
for (var number = 1; number <= 214; number++) number,
],
);
final strokes = [for (final radical in radicals) radical.strokes];
expect(strokes, orderedEquals([...strokes]..sort()));
expect((strokes.first, strokes.last), (1, 17));
// Const, as the table is: a record compares its lists by identity.
expect(radicals[84], const (
number: 85,
character: '水',
variants: ['氵'],
strokes: 4,
name: 'water',
));
});
test('each is one character, as is every form it takes in kanji', () {
for (final radical in radicals) {
for (final form in [radical.character, ...radical.variants]) {
expect(form.runes, hasLength(1), reason: '${radical.number}');
}
}
});
test("a radical's label is the dictionary's", () {
expect(radicalLabel(85), 'radical:85');
});
test('the forms a radical takes in each language: simplified Chinese its '
"own, traditional Chinese and Korean without Japanese's own", () {
final speech = radicals[148];
final dragon = radicals[211];
final food = radicals[183];
expect(radicalFormsIn(speech, 'zh-Hans'), ['讠']);
expect(radicalFormsIn(speech, 'zh-Hant'), isEmpty);
expect(radicalFormsIn(dragon, 'ja'), ['竜']);
expect(radicalFormsIn(dragon, 'zh'), ['龙']);
expect(radicalFormsIn(dragon, 'ko'), isEmpty);
// Simplified Chinese writes 食 as 饣 at a character's side, not 飠.
expect(radicalFormsIn(food, 'zh-CN'), ['饣']);
expect(radicalFormsIn(food, 'zh-TW'), ['飠']);
expect(radicalsIn('zh-Hans')[148].variants, ['讠']);
// Each form once, never the radical itself.
expect(radicalFormsIn(radicals[198], 'zh'), ['麦']);
expect(radicalFormsIn(radicals[202], 'zh'), isEmpty);
expect((hasRadicals('ko'), hasRadicals('el')), (true, false));
});
}

View file

@ -0,0 +1,128 @@
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));
});
}

View file

@ -0,0 +1,96 @@
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';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('a stroke', () {
test('as points: a line cut into steps, its ends kept', () {
final points = strokePolyline('M10,10L20,10');
expect(points, hasLength(11));
expect(points.first, (x: 10.0, y: 10.0));
expect(points.last, (x: 20.0, y: 10.0));
});
test('as points: a curve flattened, no step longer than asked, ending '
'where it ends', () {
final points = strokePolyline('M0,0c10,0,20,10,20,20', step: 2);
expect(points.last.x, closeTo(20, 1e-9));
expect(points.last.y, closeTo(20, 1e-9));
for (var i = 1; i < points.length; i++) {
final (:x, :y) = points[i];
final before = points[i - 1];
final dx = x - before.x;
final dy = y - before.y;
expect(dx * dx + dy * dy, lessThanOrEqualTo(4 + 1e-9));
}
});
});
group('the bundled strokes', () {
test("a character's strokes in order, and its components", () async {
final strokes = StrokeRepository();
final eat = await strokes.strokesOf('食');
expect(eat!.paths, hasLength(9));
expect(eat.paths.first, startsWith('M52.75,10.5'));
expect((await strokes.strokesOf('語'))!.components, ['言', '吾']);
expect((await strokes.strokesOf('あ'))!.paths, hasLength(3));
expect(strokes.read('食'), eat);
});
test('none for a character KanjiVG has no file for, or for more than '
'one character', () async {
final strokes = StrokeRepository();
expect(await strokes.strokesOf('😀'), isNull);
expect(await strokes.strokesOf('食べる'), isNull);
expect(await strokes.strokesOf(''), isNull);
});
test("Chinese draws its characters as Make Me a Hanzi does, KanjiVG's "
'for what it lacks; Korean its letters with its own strokes', () async {
final strokes = StrokeRepository();
// 说 is simplified Chinese's alone, which KanjiVG doesn't draw.
expect(await strokes.strokesOf('说'), isNull);
final say = await strokes.strokesOf('说', language: 'zh-Hans');
expect(say!.paths, hasLength(9));
expect(strokes.read('说', language: 'zh'), say);
// 水 is drawn by both, each in its own shapes.
expect(
(await strokes.strokesOf('水', language: 'zh'))!.paths,
isNot((await strokes.strokesOf('水'))!.paths),
);
// A kana Make Me a Hanzi doesn't draw comes from KanjiVG.
expect(await strokes.strokesOf('あ', language: 'zh'), isNotNull);
expect(
(await strokes.strokesOf('ㄹ', language: 'ko'))!.paths,
hasLength(3),
);
expect(
(await strokes.strokesOf('ㄺ', language: 'ko'))!.components,
['ㄹ', 'ㄱ'],
);
expect(await strokes.strokesOf('ㄹ', language: 'ja'), isNull);
});
test('each set by language: Japanese KanjiVG, Chinese Make Me a Hanzi '
'first, Korean its letters first', () {
expect(StrokeSet.of(null), [StrokeSet.kanjivg]);
expect(StrokeSet.of('zh-Hant'), [
StrokeSet.makemeahanzi,
StrokeSet.kanjivg,
]);
expect(StrokeSet.of('ko').first, StrokeSet.hangul);
});
test('the file a character is in: each 256 code points', () {
expect(strokeFileOf('食'), '098');
expect(strokeFileOf('あ'), '030');
});
});
}