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:
parent
11abd9afce
commit
649499be23
3 changed files with 0 additions and 0 deletions
|
|
@ -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('<tag> & 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];
|
||||
}
|
||||
|
|
@ -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()));
|
||||
});
|
||||
}
|
||||
|
|
@ -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>', '<tag> & 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),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue