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
3 changed files with 0 additions and 0 deletions

View file

@ -0,0 +1,234 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
import 'package:halkyon_learn_flutter/src/feature/anki/data/anki_field.dart';
void main() {
/// The person's files the tests know, by their names in the package.
const files = {
'cat.png': 'asset-1',
'neko.mp3': 'asset-2',
'my cat.png': 'a3',
};
/// [html] as blocks: each block's type and text, its files as `<url>`.
List<String> blocks(String html, {ClozeSide cloze = ClozeSide.none}) {
final content = ankiContent([html], assetOf: files.get, cloze: cloze);
final out = <String>[];
void visit(Object? node, String indent) {
if (node case {'children': final List<Object?> children}) {
for (final child in children) {
if (child case {
'type': final String type,
'data': final Map<String, Object?> data,
}) {
final text = [
for (final op in data['delta'] as List<Object?>? ?? const [])
if (op case {'insert': final String insert}) insert,
].join();
out.add(
'$indent$type ${data['url'] != null ? '<${data['url']}>' : text}'
.trimRight(),
);
}
visit(child, '$indent ');
}
}
}
visit(content.editorJson['document'], '');
return out;
}
/// The delta of [html]'s only block.
List<Object?> delta(String html) {
final document =
ankiContent([html], assetOf: files.get).editorJson['document']!
as Map<String, Object?>;
final [block] = document['children']! as List<Object?>;
return ((block! as Map<String, Object?>)['data']!
as Map<String, Object?>)['delta']!
as List<Object?>;
}
test('turns line breaks, divs and paragraphs into lines', () {
expect(blocks('cat<br>a pet'), ['paragraph cat', 'paragraph a pet']);
expect(blocks('<div>one</div><div>two</div>'), [
'paragraph one',
'paragraph two',
]);
expect(blocks('a<br><br>b'), ['paragraph a', 'paragraph', 'paragraph b']);
expect(blocks('<p>one</p>two'), ['paragraph one', 'paragraph two']);
});
test('keeps bold, italic, underline and strikethrough', () {
expect(delta('<b>猫</b> and <i>dog</i>, <u>u</u><s>s</s>'), [
{
'insert': '猫',
'attributes': {'bold': true},
},
{'insert': ' and '},
{
'insert': 'dog',
'attributes': {'italic': true},
},
{'insert': ', '},
{
'insert': 'u',
'attributes': {'underline': true},
},
{
'insert': 's',
'attributes': {'strikethrough': true},
},
]);
});
test('collapses whitespace as a browser shows it', () {
expect(blocks(' a\n b '), ['paragraph a b']);
expect(blocks('<b>a</b> <i>b</i>'), ['paragraph a b']);
});
test('decodes entities', () {
expect(blocks('&lt;tag&gt; &amp; more'), ['paragraph <tag> & more']);
});
test('turns lists into list items, nested ones as children', () {
expect(
blocks('<ul><li>one</li><li>two<ul><li>nested</li></ul></li></ul>'),
[
'bulleted_list one',
'bulleted_list two',
' bulleted_list nested',
],
);
expect(blocks('<ol><li>first</li><li>second</li></ol>after'), [
'numbered_list first',
'numbered_list second',
'paragraph after',
]);
});
test('turns images and sounds into blocks of the person files', () {
expect(blocks('a pet<img src="cat.png">'), [
'paragraph a pet',
'image <${assetUrl('asset-1')}>',
]);
expect(blocks('犬 [sound:neko.mp3]'), [
'paragraph 犬',
'audio <${assetUrl('asset-2')}>',
]);
expect(blocks('<img src="my%20cat.png">'), ['image <${assetUrl('a3')}>']);
});
test('leaves out files it has none for, and keeps images from the web '
'as such', () {
expect(blocks('a<img src="missing.png">[sound:missing.mp3]'), [
'paragraph a',
]);
expect(blocks('<img src="https://example.com/x.png">'), [
'image <https://example.com/x.png>',
]);
});
test('keeps the text of other elements', () {
expect(blocks('<font color="red">red</font> text'), ['paragraph red text']);
expect(blocks('a<script>alert(1)</script><style>b{}</style>'), [
'paragraph a',
]);
});
test('hides cloze deletions on the front and shows them on the back', () {
const html = '{{c1::Tokyo}} is the capital of {{c2::Japan::country}}';
expect(blocks(html, cloze: ClozeSide.front), [
'paragraph […] is the capital of [country]',
]);
expect(blocks(html, cloze: ClozeSide.back), [
'paragraph Tokyo is the capital of Japan',
]);
expect(delta('{{c1::Tokyo}}').single, {'insert': '{{c1::Tokyo}}'});
});
test('puts several fields one after another', () {
expect(
ankiContent(['front', '', 'back'], assetOf: files.get).plainText,
'front\nback',
);
expect(ankiContent(['', ' '], assetOf: files.get).isBlank, isTrue);
});
test('finds the media files a field refers to', () {
expect(
ankiMediaNames(
'<img src="cat.png"> [sound:neko.mp3] <img src="http://x/y.png"><img src="">',
),
{'cat.png', 'neko.mp3'},
);
});
test("a ruby's reading becomes furigana on its text, each base its own", () {
final content = ankiContent([
'<ruby>漢<rp>(</rp><rt>かん</rt><rp>)</rp>字<rt>じ</rt></ruby>を読む',
], assetOf: files.get);
final document = content.editorJson['document']! as Map<String, Object?>;
final line = (document['children']! as List).single as Map;
expect((line['data']! as Map)['delta'], [
{
'insert': '漢',
'attributes': {'ruby': 'かん'},
},
{
'insert': '字',
'attributes': {'ruby': 'じ'},
},
{'insert': 'を読む'},
]);
});
test('formatting inside a ruby stays, with the reading over it', () {
final content = ankiContent([
'<ruby><b>漢</b>字<rt>かんじ</rt></ruby>',
], assetOf: files.get);
final document = content.editorJson['document']! as Map<String, Object?>;
final line = (document['children']! as List).single as Map;
expect((line['data']! as Map)['delta'], [
{
'insert': '漢',
'attributes': {'ruby': 'かんじ', 'bold': true},
},
{
'insert': '字',
'attributes': {'ruby': 'かんじ'},
},
]);
});
test("Anki's bracket furigana becomes readings: the text before the "
'brackets back to a space, the space dropped; a sound stays', () {
expect(
ankiBracketsToRuby('日本語[にほんご]を 勉強[べんきょう]する'),
'<ruby>日本語<rt>にほんご</rt></ruby>を<ruby>勉強<rt>べんきょう</rt></ruby>する',
);
expect(
ankiBracketsToRuby('<b>猫[ねこ]</b>'),
'<b><ruby>猫<rt>ねこ</rt></ruby></b>',
);
expect(ankiBracketsToRuby('犬[sound:inu.mp3]'), '犬[sound:inu.mp3]');
});
test('the fields a note type shows through a furigana filter, whatever '
'filters come with it', () {
expect(
ankiFuriganaFieldNames([
'{{Expression}}',
'{{FrontSide}}<hr id=answer>{{furigana:Reading}}<br>{{kanji:Word}}',
'{{text:kana:Sentence}}{{#Meaning}}{{Meaning}}{{/Meaning}}',
]),
{'Reading', 'Word', 'Sentence'},
);
});
}
extension on Map<String, String> {
String? get(String name) => this[name];
}

View file

@ -0,0 +1,270 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/core/asset/data/asset_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/anki/data/anki_importer.dart';
import 'package:halkyon_learn_flutter/src/feature/anki/data/anki_package.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card.dart';
import 'package:halkyon_learn_flutter/src/feature/study/data/review_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/study/model/rating.dart';
import 'package:halkyon_learn_flutter/src/feature/study/model/review.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
import 'package:halkyon_learn_flutter/src/feature/tag/data/tag_repository.dart';
import '../../../support/fake_asset_gateway.dart';
import '../../../support/fake_asset_server_gateway.dart';
import '../../../support/fake_card_gateway.dart';
import '../../../support/fake_review_gateway.dart';
import '../../../support/fake_studyset_gateway.dart';
import '../../../support/fake_tag_gateway.dart';
import '../../../support/memory_asset_file_cache.dart';
import 'anki_package_test.dart' show fixture;
void main() {
late Directory temp;
late AnkiPackage package;
late StudysetRepository studysets;
late CardRepository cards;
late TagRepository tags;
late ReviewRepository reviews;
late FakeAssetGateway assetRows;
late AssetRepository assets;
late AnkiImporter importer;
late String japanese;
setUp(() async {
temp = Directory.systemTemp.createTempSync('anki');
package = readAnkiPackage(fixture('anki_latest.apkg'), temp: temp);
studysets = StudysetRepository(FakeStudysetGateway());
cards = CardRepository(FakeCardGateway());
tags = TagRepository(FakeTagGateway(), cardChanges: cards.changes);
reviews = ReviewRepository(FakeReviewGateway());
assetRows = FakeAssetGateway();
assets = AssetRepository(
assetRows,
FakeAssetServerGateway(),
MemoryAssetFileCache(),
);
importer = AnkiImporter(studysets, cards, tags, reviews, assets);
japanese = await studysets.create(StudysetContent.titled('Japanese'));
});
tearDown(() async {
temp.deleteSync(recursive: true);
await studysets.dispose();
await tags.dispose();
await cards.dispose();
await reviews.dispose();
await assets.dispose();
});
Future<AnkiImportResult> import({
AnkiImportMode mode = AnkiImportMode.flatten,
bool keepProgress = true,
bool includeMedia = true,
}) => importer.import(
package,
studysetId: japanese,
mode: mode,
keepProgress: keepProgress,
includeMedia: includeMedia,
);
/// The cards of [studysetId] as `front | back`, in order.
Future<List<String>> sides(String studysetId) async => [
for (final card in await cards.cardsOf(studysetId))
'${card.content.front.plainText} | ${card.content.back.plainText}',
];
Future<Map<String, String>> childrenOf(String id) async => {
for (final studyset in await studysets.all())
if (studyset.parentId == id) studyset.content.title: studyset.id,
};
test('makes a card of every note, cloze notes with their deletions hidden '
'on the front', () async {
final result = await import();
expect(await sides(japanese), [
'猫 | cat\na pet',
'犬 | dog',
'[…] is the capital of [country] | Tokyo is the capital of Japan\nextra',
'one\ntwo | <tag> & more',
]);
expect(result.cards, 4);
expect(result.skippedNotes, 0);
expect(await childrenOf(japanese), isEmpty);
});
test('marks every card as imported', () async {
await import();
expect(
[for (final stamp in await cards.allCards()) stamp.imported],
everyElement(isTrue),
);
});
test('adds the files the notes show, and shows them on the cards', () async {
final result = await import();
expect(result.files, 2);
expect(result.skippedFiles, 0);
expect(
{for (final row in assetRows.rows) row.mimeType},
{'image/png', 'audio/mpeg'},
);
final [cat, dog, ...] = await cards.cardsOf(japanese);
expect(cat.content.back.hasImages, isTrue);
expect(dog.content.front.hasSounds, isTrue);
expect(
{...cat.content.back.assetIds, ...dog.content.front.assetIds},
{for (final row in assetRows.rows) row.id},
);
});
test('without its files, the cards go without them', () async {
final result = await import(includeMedia: false);
expect(result.files, 0);
expect(assetRows.rows, isEmpty);
final [cat, dog, ...] = await cards.cardsOf(japanese);
expect(cat.content.back.hasImages, isFalse);
expect(dog.content.front.hasSounds, isFalse);
});
test('brings the review history over, each card its own and a reversed '
"card's to the reverse schedule", () async {
final result = await import();
final [cat, dog, ...] = await cards.cardsOf(japanese);
List<(Rating, StudyMode)> of(Card card, List<Review> all) => [
for (final review in all)
if (review.cardId == card.id) (review.rating, review.mode),
];
final all = await reviews.all();
// The manual reschedule is no answer.
expect(of(cat, all), [(Rating.easy, StudyMode.review)]);
expect(of(dog, all), [
(Rating.again, StudyMode.review),
(Rating.good, StudyMode.review),
(Rating.easy, StudyMode.reverse),
]);
expect(result.reviews, 4);
final schedules = await reviews.schedulesOf([dog.id]);
expect(schedules[dog.id]?.due, isNotNull);
});
test('without the progress, every card starts new', () async {
final result = await import(keepProgress: false);
expect(result.reviews, 0);
expect(await reviews.all(), isEmpty);
});
test("keeps the notes' tags", () async {
await import();
final tagging = await tags.read();
final [cat, dog, cloze, _] = await cards.cardsOf(japanese);
Set<String> namesOf(Card card) => {
for (final tag in tagging.of(card.id)) tag.name,
};
expect(namesOf(cat), {'animal', 'JLPT::N5'});
expect(namesOf(dog), {'animal'});
expect(namesOf(cloze), isEmpty);
});
test('a studyset made for an import that failed goes if nothing came '
'into it, and stays if something did', () async {
final empty = await importer.newStudysetFor(package, fallbackTitle: 'x');
await importer.discardIfEmpty(empty);
expect(
(await studysets.all()).map((studyset) => studyset.id),
isNot(contains(empty)),
);
await import();
await importer.discardIfEmpty(japanese);
expect(
(await studysets.all()).map((studyset) => studyset.id),
contains(japanese),
);
});
for (final name in ['anki_furigana.apkg', 'anki_furigana_legacy.apkg']) {
test("a field the note type shows with Anki's furigana gets readings; "
'the same text in another field stays as written ($name)', () async {
final furigana = readAnkiPackage(fixture(name), temp: temp);
expect(furigana.collection.notes.single.furiganaFields, {1});
await importer.import(
furigana,
studysetId: japanese,
mode: AnkiImportMode.flatten,
keepProgress: false,
includeMedia: false,
);
final card = (await cards.cardsOf(japanese)).single;
expect(card.content.front.plainText.trim(), '日本語[にほんご]');
final document =
card.content.back.editorJson['document']! as Map<String, Object?>;
final readings = [
for (final block in document['children']! as List<Object?>)
if (block case {'data': {'delta': final List<Object?> delta}})
for (final op in delta)
if (op case {
'insert': final String text,
'attributes': {'ruby': final String ruby},
})
(text, ruby),
];
expect(readings, [('日本語', 'にほんご'), ('勉強', 'べんきょう')]);
});
}
test('makes a studyset of each deck below the top one', () async {
await import(mode: AnkiImportMode.subStudysets);
final children = await childrenOf(japanese);
expect(children.keys, ['Animals']);
expect(await sides(japanese), hasLength(2));
expect(await sides(children['Animals']!), [
'犬 | dog',
'one\ntwo | <tag> & more',
]);
});
test('or tags each card with the decks it came from', () async {
await import(mode: AnkiImportMode.tagBySubdeck);
expect(await childrenOf(japanese), isEmpty);
final tagging = await tags.read();
final [cat, dog, _, list] = await cards.cardsOf(japanese);
Set<String> namesOf(Card card) => {
for (final tag in tagging.of(card.id)) tag.name,
};
expect(namesOf(cat), {'animal', 'JLPT::N5'});
expect(namesOf(dog), {'animal', 'Animals'});
expect(namesOf(list), {'Animals'});
});
test('tells how far it got', () async {
final progress = <double>[];
await importer.import(
package,
studysetId: japanese,
mode: AnkiImportMode.flatten,
keepProgress: true,
includeMedia: true,
onProgress: progress.add,
);
expect(progress, isNotEmpty);
expect(progress.last, 1);
expect(progress, orderedEquals([...progress]..sort()));
});
}

View file

@ -0,0 +1,205 @@
import 'dart:io';
import 'dart:typed_data';
import 'package:archive/archive.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/feature/anki/data/anki_package.dart';
import 'package:halkyon_learn_flutter/src/feature/anki/data/anki_package_reader.dart';
import 'package:halkyon_learn_flutter/src/feature/anki/model/anki_collection.dart';
/// The packages `tool/anki/make_fixtures.py` exported with Anki: one
/// collection in each format Anki has written.
const fixtures = {
'the latest format': 'anki_latest.apkg',
'the legacy format': 'anki_legacy.apkg',
'the format before Anki 2.1.50': 'anki_before_2_1_50.apkg',
};
/// The fixture [name]'s bytes.
Uint8List fixture(String name) =>
File('test/feature/anki/fixtures/$name').readAsBytesSync();
void main() {
late Directory temp;
setUp(() => temp = Directory.systemTemp.createTempSync('anki'));
tearDown(() => temp.deleteSync(recursive: true));
for (final MapEntry(key: format, value: name) in fixtures.entries) {
group('A package in $format', () {
late AnkiPackage package;
late List<AnkiNote> notes;
setUp(() {
package = readAnkiPackage(fixture(name), temp: temp);
notes = package.collection.notes;
});
test('has every note with its fields, tags, kind and deck', () {
expect(
[for (final note in notes) note.fields],
[
['<b>猫</b>', 'cat<br>a pet<img src="cat.png">'],
['犬 [sound:neko.mp3]', 'dog'],
['{{c1::Tokyo}} is the capital of {{c2::Japan::country}}', 'extra'],
['<ul><li>one</li><li>two</li></ul>', '&lt;tag&gt; &amp; more'],
],
);
expect(
[for (final note in notes) note.tags],
[
['animal', 'JLPT::N5'],
['animal'],
<String>[],
<String>[],
],
);
expect(
[for (final note in notes) note.kind],
[
AnkiNoteKind.standard,
AnkiNoteKind.standard,
AnkiNoteKind.cloze,
AnkiNoteKind.standard,
],
);
expect([for (final note in notes) note.templateCount], [1, 2, 1, 1]);
expect(
[for (final note in notes) note.deckPath],
[
['Japanese'],
['Japanese', 'Animals'],
['Japanese'],
['Japanese', 'Animals'],
],
);
expect(package.collection.deckPaths, hasLength(2));
});
test('has every card with its reviews, oldest first', () {
final [cat, dog, cloze, list] = notes;
expect([for (final card in dog.cards) card.ord], [0, 1]);
expect([for (final card in cloze.cards) card.ord], [0, 1]);
expect(list.cards.single.reviews, isEmpty);
final [forward, reverse] = dog.cards;
expect([for (final review in forward.reviews) review.ease], [1, 3]);
expect([for (final review in reverse.reviews) review.ease], [4]);
expect(
forward.reviews.first.at.isBefore(forward.reviews.last.at),
isTrue,
);
expect(forward.reviews.first.at.isUtc, isTrue);
// Answered easy, then rescheduled by hand: no answer.
expect(
[
for (final review in cat.cards.single.reviews)
(review.ease, review.kind),
],
[
(4, AnkiReviewKind.learning),
(0, AnkiReviewKind.manual),
],
);
expect(package.collection.hasReviews, isTrue);
});
test('ships its media by the names the notes use', () {
expect(package.collection.mediaNames, {'cat.png', 'neko.mp3'});
expect(
package.mediaBytes('cat.png')?.sublist(0, 8),
[0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a],
);
expect(package.mediaBytes('neko.mp3'), hasLength(4170));
expect(package.mediaBytes('missing.png'), isNull);
});
test('leaves no file behind', () {
expect(temp.listSync(), isEmpty);
});
});
}
test(
'reads a package on an isolate of its own, and hands it over whole',
() async {
final package = await IsolateAnkiPackageReader().read(
fixture('anki_latest.apkg'),
);
expect(package.collection.notes, hasLength(4));
expect(package.mediaBytes('neko.mp3'), hasLength(4170));
},
);
group('Refuses', () {
Matcher refused(AnkiPackageProblem problem) => throwsA(
isA<AnkiPackageException>().having((e) => e.problem, 'problem', problem),
);
Uint8List zip(Map<String, List<int>> files) {
final archive = Archive();
for (final MapEntry(:key, :value) in files.entries) {
archive.add(ArchiveFile.bytes(key, value));
}
return Uint8List.fromList(ZipEncoder().encode(archive));
}
test('what is not a zip file', () {
expect(
() => readAnkiPackage(Uint8List.fromList([1, 2, 3]), temp: temp),
refused(AnkiPackageProblem.notAPackage),
);
});
test('a zip file without a collection', () {
expect(
() => readAnkiPackage(
zip({
'notes.txt': [1],
}),
temp: temp,
),
refused(AnkiPackageProblem.notAPackage),
);
});
test('a package of a format newer than this app knows', () {
expect(
() => readAnkiPackage(
zip({
'meta': [0x08, 0x04],
'collection.anki21b': [1],
}),
temp: temp,
),
refused(AnkiPackageProblem.newerFormat),
);
});
test('a damaged collection', () {
expect(
() => readAnkiPackage(
zip({
'collection.anki2': [1, 2, 3],
}),
temp: temp,
),
refused(AnkiPackageProblem.unreadable),
);
final latest = ZipDecoder().decodeBytes(fixture('anki_latest.apkg'));
final collection = latest.findFile('collection.anki21b')!.readBytes()!;
expect(
() => readAnkiPackage(
zip({
'meta': latest.findFile('meta')!.readBytes()!,
'collection.anki21b': collection.sublist(0, collection.length ~/ 2),
}),
temp: temp,
),
refused(AnkiPackageProblem.unreadable),
);
});
});
}