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,515 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:clock/clock.dart';
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/core/asset/model/stored_file.dart';
import 'package:halkyon_learn_flutter/src/core/content/content_blocks.dart';
import 'package:halkyon_learn_flutter/src/core/content/published_ref.dart';
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
import 'package:halkyon_learn_flutter/src/feature/archive/data/archive_exporter.dart';
import 'package:halkyon_learn_flutter/src/feature/archive/data/archive_importer.dart';
import 'package:halkyon_learn_flutter/src/feature/archive/data/archive_reader.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
import 'package:halkyon_learn_flutter/src/feature/dictionary/data/dictionary_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/explore/data/release_copier.dart';
import 'package:halkyon_learn_flutter/src/feature/export/data/export_archive.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/path_progress_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/exercise.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/learning_path.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/progress.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/step_content.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/pdf_source/data/pdf_source_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/model/pdf_source.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/exam_plan.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_tree.dart';
import 'package:halkyon_learn_flutter/src/feature/tag/data/tag_repository.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import '../../support/fake_asset_gateway.dart';
import '../../support/fake_asset_server_gateway.dart';
import '../../support/fake_card_gateway.dart';
import '../../support/fake_device_dictionaries.dart';
import '../../support/fake_dictionary_gateway.dart';
import '../../support/fake_learning_path_gateway.dart';
import '../../support/fake_note_gateway.dart';
import '../../support/fake_path_answer_gateway.dart';
import '../../support/fake_pdf_source_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';
/// One person's repositories, on fake gateways of their own.
final class _Person() {
final studysets = StudysetRepository(FakeStudysetGateway());
final cards = CardRepository(FakeCardGateway());
final notes = NoteRepository(FakeNoteGateway());
final paths = LearningPathRepository(FakeLearningPathGateway());
final progress = PathProgressRepository(FakePathAnswerGateway());
final reviews = ReviewRepository(FakeReviewGateway());
final assets = AssetRepository(
FakeAssetGateway(),
FakeAssetServerGateway(),
MemoryAssetFileCache(),
);
late final tags = TagRepository(
FakeTagGateway(),
cardChanges: cards.changes,
noteChanges: notes.changes,
pathChanges: paths.changes,
);
late final pdfs = PdfSourceRepository(FakePdfSourceGateway(), assets);
ArchiveExporter exporter({
Future<StoredFile?> Function(String assetId)? fileOf,
}) => ArchiveExporter(
studysets,
cards,
notes,
paths,
progress,
pdfs,
tags,
reviews,
fileOf ?? assets.fileOf,
);
ArchiveImporter importer() => ArchiveImporter(
ReleaseCopier(studysets, cards, notes, paths, pdfs, tags, assets),
DictionaryRepository(FakeDictionaryGateway(), noDeviceDictionaries()),
reviews,
progress,
studysets,
pdfs,
);
}
/// A person with a studyset of their own, Own, holding one of everything,
/// so that nothing an import makes has the id it had where it was exported
/// (the fakes number what they store from 1).
Future<_Person> _personWithOwn() async {
final person = _Person();
final own = await person.studysets.create(StudysetContent.titled('Own'));
final file = await person.assets.add(
Uint8List.fromList(utf8.encode('%PDF-1.7 Own')),
fileName: 'own.pdf',
mimeType: AssetRepository.pdfMediaType,
);
await person.pdfs.add(
own,
PdfSourceContent(title: 'Own', fileName: 'own.pdf', assetId: file),
);
await person.cards.create(
own,
CardContent(front: RichContent.plain('x'), back: RichContent.plain('y')),
);
await person.notes.create(own, NoteContent(body: RichContent.plain('z')));
final path = await person.paths.createPath(
own,
LearningPathContent.titled('Own'),
);
await person.paths.createStep(
path,
StepKind.info,
StepContent.empty(StepKind.info),
);
return person;
}
final _png = Uint8List.fromList([0x89, 0x50, 0x4e, 0x47, 1, 2, 3]);
final _pdfBytes = Uint8List.fromList(utf8.encode('%PDF-1.7 Genki'));
final _start = DateTime.utc(2026, 10, 1, 8);
final _report = CardReport(
category: ReportCategory.typo,
at: _start,
note: 'a, not o',
);
const _deadline = ReadingDeadline(
targetPage: 40,
due: (year: 2026, month: 11, day: 30),
);
final _exam = ExamChoice(plan: 'n5', date: DateTime(2026, 12, 6));
/// [action] done [minutes] after [_start].
Future<T> _at<T>(int minutes, Future<T> Function() action) => withClock(
Clock.fixed(_start.add(Duration(minutes: minutes))),
action,
);
/// A page tied to [pdf]'s third page: a screenshot of it.
RichContent _screenshot(String image, String pdf) => RichContent({
'document': {
'type': 'page',
'children': [
{
'type': 'image',
'data': {'url': assetUrl(image), imagePdfKey: pdf, 'page': 3},
},
],
},
});
/// The screenshot's attributes in [content].
Map<String, Object?> _imageOf(RichContent content) =>
((((content.toJson()['editor']! as Map)['document']! as Map)['children']!
as List)
.single
as Map)['data']
as Map<String, Object?>;
/// What [_library] made, by name.
typedef _Made = ({
String japanese,
String grammar,
String old,
String kana,
String step,
String pdf,
String image,
});
/// A copy of a published Japanese studyset: starred, preparing for an exam,
/// with Grammar inside (archived; a learning path, its step answered,
/// started afresh and answered again), Vocabulary (a PDF, read to page 5,
/// started afresh, read to page 2, with a deadline; a card reported and
/// reviewed, started afresh and reviewed back to front; a card and a note
/// tied to the PDF's third page, the card showing a screenshot of it) and
/// Old, waiting in Recently deleted.
Future<_Made> _library(_Person a) async {
final image = await a.assets.add(
_png,
fileName: 'page-3.png',
mimeType: 'image/png',
);
final file = await a.assets.add(
_pdfBytes,
fileName: 'genki.pdf',
mimeType: AssetRepository.pdfMediaType,
);
const origin = PublishedRef(publication: 'jp', item: 'top', version: 3);
final japanese = await a.studysets.create(
StudysetContent(
title: 'Japanese',
description: RichContent.plain('Hiragana first.'),
license: 'CC-BY-SA-4.0',
published: origin,
publishedAt: 'learn.halkyon.eu',
),
);
final grammar = await a.studysets.create(
StudysetContent.titled('Grammar'),
parentId: japanese,
);
final vocabulary = await a.studysets.create(
StudysetContent.titled('Vocabulary'),
parentId: japanese,
);
final old = await a.studysets.create(
StudysetContent.titled('Old'),
parentId: japanese,
);
await a.studysets.markDeleted(old);
final top = (await a.studysets.all()).singleWhere((s) => s.id == japanese);
await a.studysets.updateContent(
japanese,
top.content
.withExamPlans([
ExamPlan(id: 'n5', name: 'JLPT N5', topics: [grammar]),
])
.withExam(_exam),
);
await a.studysets.star(japanese);
await a.studysets.archive(grammar);
final path = await a.paths.createPath(
grammar,
LearningPathContent.titled('Particles'),
);
final (:stepId, revisionId: _) = await a.paths.createStep(
path,
StepKind.info,
StepContent.empty(
StepKind.info,
).withField(StepField.body, RichContent.plain('は marks the topic.')),
);
await _at(1, () => a.progress.record(stepId, AnswerOutcome.continued));
await _at(2, () => a.progress.reset({stepId}));
await _at(3, () => a.progress.record(stepId, AnswerOutcome.continued));
final pdf = await a.pdfs.add(
vocabulary,
PdfSourceContent(
title: 'Genki',
fileName: 'genki.pdf',
assetId: file,
pageCount: 40,
deadline: _deadline,
published: origin,
),
);
await _at(4, () => a.pdfs.reached(pdf, 5));
await _at(5, () => a.pdfs.resetReading(pdf));
await _at(6, () => a.pdfs.reached(pdf, 2));
final kana = await a.cards.create(
vocabulary,
CardContent(
front: RichContent.plain('あ'),
back: RichContent.plain('a'),
report: _report,
published: origin,
),
);
final page = await a.cards.create(
vocabulary,
CardContent(
front: RichContent.plain('です'),
back: _screenshot(image, pdf),
source: (pdfId: pdf, page: 3),
),
);
final note = await a.notes.create(
vocabulary,
NoteContent(
title: 'Lesson 1',
body: RichContent.plain('です ends a sentence politely.'),
source: (pdfId: pdf, page: 3),
),
);
final kanaTag = await a.tags.create(
'Kana',
colorName: PaletteColor.copper.name,
);
final episode = await a.tags.create('あかね噺 S1 E1', fromJellyfin: true);
await a.tags.setTagsOf(kana, {kanaTag});
await a.tags.setTagsOf(page, {episode});
await a.tags.setTagsOfNote(note.noteId, {kanaTag});
await a.reviews.record(
cardId: kana,
rating: Rating.good,
reviewedAt: _start.add(const Duration(minutes: 7)),
duration: const Duration(seconds: 3),
);
await a.reviews.reset({kana}, _start.add(const Duration(minutes: 8)));
await a.reviews.record(
cardId: kana,
rating: Rating.hard,
reviewedAt: _start.add(const Duration(minutes: 9)),
duration: const Duration(seconds: 2),
mode: StudyMode.reverse,
);
return (
japanese: japanese,
grammar: grammar,
old: old,
kana: kana,
step: stepId,
pdf: pdf,
image: image,
);
}
/// A review as the log holds it, without its id and card.
(Rating, DateTime, Duration, StudyMode) _logged(Review review) =>
(review.rating, review.reviewedAt, review.duration, review.mode);
void main() {
test('a studyset comes back from its archive as it was, as the '
"person's own, unlinked from Explore: the studysets inside it in "
'their order, but those waiting in Recently deleted; starred and '
'archived; the exam it prepares for; its cards, notes, paths and '
"PDFs with their files, tags in their colours, a card's report and "
"a PDF's deadline; what is tied to a PDF's page tied to the PDF "
'made of it; the reviews, answers and pages read, fresh starts '
'included', () async {
final a = _Person();
final made = await _library(a);
final b = await _personWithOwn();
final imported = await b.importer().import(
readArchive(
zipExport(await a.exporter().export(made.japanese, withProgress: true)),
),
);
final all = await b.studysets.all();
final japanese = all.singleWhere((s) => s.id == imported.studysetId);
expect(
childrenOf(japanese.id, all).map((s) => s.content.title),
['Grammar', 'Vocabulary'],
);
expect(all, hasLength(4));
final grammar = all.singleWhere((s) => s.content.title == 'Grammar');
final vocabulary = all.singleWhere((s) => s.content.title == 'Vocabulary');
expect(japanese.isStarred, isTrue);
expect(grammar.isArchived, isTrue);
expect(vocabulary.isStarred || vocabulary.isArchived, isFalse);
expect(japanese.content.license, 'CC-BY-SA-4.0');
expect(japanese.content.published, isNull);
expect(japanese.content.publishedAt, isNull);
expect(japanese.content.examPlans.single.topics, [grammar.id]);
expect(japanese.content.exam, _exam);
final pdf = (await b.pdfs.sourcesOf(vocabulary.id)).single;
expect(pdf.content.deadline, _deadline);
expect(pdf.content.published, isNull);
expect((await b.assets.fileOf(pdf.content.assetId))?.bytes, _pdfBytes);
expect(await b.pdfs.furthestPages(), {pdf.id: 2});
final cards = await b.cards.cardsOf(vocabulary.id);
expect(cards.map((c) => c.content.front.plainText), ['あ', 'です']);
final [kana, page] = cards;
expect(kana.content.report, _report);
expect(kana.content.published, isNull);
expect(page.content.source, (pdfId: pdf.id, page: 3));
final screenshot = _imageOf(page.content.back);
expect(screenshot[imagePdfKey], pdf.id);
final image = await b.assets.fileOf(assetIdOf(screenshot['url'])!);
expect(image?.bytes, _png);
expect(image?.fileName, 'page-3.png');
final note = (await b.notes.notesOf(vocabulary.id)).single;
expect(note.content.source, (pdfId: pdf.id, page: 3));
final tagging = await b.tags.read();
final kanaTag = tagging.of(kana.id).single;
expect(kanaTag.name, 'Kana');
expect(kanaTag.colorName, PaletteColor.copper.name);
expect(tagging.ofNote(note.id), [kanaTag]);
expect(tagging.of(page.id).single.fromJellyfin, isTrue);
final path = (await b.paths.pathsOf(grammar.id)).single;
final step = (await b.paths.stepsOf(path.id)).single;
expect(
[
for (final answer in await b.progress.answersOf({step.id}))
(answer.answeredAt, answer.outcome, answer.answer),
],
unorderedEquals([
for (final answer in await a.progress.answersOf({made.step}))
(answer.answeredAt, answer.outcome, answer.answer),
]),
);
expect(
(await b.reviews.reviewsOf({kana.id})).map(_logged),
unorderedEquals((await a.reviews.reviewsOf({made.kana})).map(_logged)),
);
expect(imported.reviews, 3);
});
test('without the progress it holds the content alone, as a release '
'does: no reviews, answers or pages read, no exam prepared for, '
'reports or reading deadlines; stars, the archive and tag colours '
'stay', () async {
final a = _Person();
final made = await _library(a);
final files = await a.exporter().export(
made.japanese,
withProgress: false,
);
expect(files.map((file) => file.path), isNot(contains('reviews.json')));
final b = await _personWithOwn();
await b.importer().import(readArchive(zipExport(files)));
final all = await b.studysets.all();
final japanese = all.singleWhere((s) => s.content.title == 'Japanese');
expect(japanese.content.exam, isNull);
expect(japanese.content.examPlans, isNotEmpty);
expect(japanese.isStarred, isTrue);
expect(
all.singleWhere((s) => s.content.title == 'Grammar').isArchived,
isTrue,
);
expect(await b.reviews.all(), isEmpty);
expect(await b.pdfs.furthestPages(), isEmpty);
expect(
(await b.pdfs.all()).map((pdf) => pdf.content.deadline),
[null, null],
);
final cards = await b.cards.cardsOf(
all.singleWhere((s) => s.content.title == 'Vocabulary').id,
);
expect(cards.map((card) => card.content.report), [null, null]);
expect(
(await b.tags.read()).named('Kana')?.colorName,
PaletteColor.copper.name,
);
final grammar = all.singleWhere((s) => s.content.title == 'Grammar');
final path = (await b.paths.pathsOf(grammar.id)).single;
final step = (await b.paths.stepsOf(path.id)).single;
expect(await b.progress.answersOf({step.id}), isEmpty);
});
test('keeps to the files the import of v0.17.0 reads, which refuses a '
'file of a kind it does not know: the progress beyond reviews sits '
'in them as keys it leaves alone; each studyset inside in a '
'directory named by its id', () async {
final a = _Person();
final made = await _library(a);
final files = await a.exporter().export(made.japanese, withProgress: true);
final item = RegExp(
r'^([^/]+/)*(studyset\.json|(cards|notes|pdfs)/[^/]+\.json'
r'|paths/[^/]+/(path\.json|steps/[^/]+\.json))$',
);
for (final file in files) {
expect(
const {'archive.json', 'reviews.json', 'files.json'}.contains(
file.path,
) ||
file.path.startsWith('files/') ||
item.hasMatch(file.path),
isTrue,
reason: file.path,
);
}
Object? jsonOf(String path) => jsonDecode(
utf8.decode(files.singleWhere((file) => file.path == path).bytes),
);
expect(jsonOf('archive.json'), containsPair('format', 1));
expect(
jsonOf('${made.grammar}/studyset.json'),
containsPair('archived', true),
);
expect(
files.where((file) => file.path.startsWith('${made.old}/')),
isEmpty,
);
for (final review in jsonOf('reviews.json')! as List<Object?>) {
expect((review! as Map)['rating'], inInclusiveRange(1, 4));
}
});
test('refuses to leave out a file this device cannot have now, saying '
'how many: an archive missing them would look whole', () async {
final a = _Person();
final made = await _library(a);
await expectLater(
a
.exporter(
fileOf: (id) async =>
id == made.image ? null : await a.assets.fileOf(id),
)
.export(made.japanese, withProgress: true),
throwsA(
isA<ArchiveFilesMissing>().having((e) => e.count, 'count', 1),
),
);
});
}

View file

@ -0,0 +1,424 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:archive/archive.dart';
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/core/content/content_blocks.dart';
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
import 'package:halkyon_learn_flutter/src/feature/archive/data/archive_importer.dart';
import 'package:halkyon_learn_flutter/src/feature/archive/data/archive_reader.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_payload.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
import 'package:halkyon_learn_flutter/src/feature/dictionary/data/dictionary_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/explore/data/release_copier.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_payload.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_step_payload.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/path_progress_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/exercise.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/learning_path.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/step_content.dart';
import 'package:halkyon_learn_flutter/src/feature/note/data/note_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/data/pdf_source_repository.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/studyset/data/studyset_payload.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_device_dictionaries.dart';
import '../../support/fake_dictionary_gateway.dart';
import '../../support/fake_learning_path_gateway.dart';
import '../../support/fake_note_gateway.dart';
import '../../support/fake_path_answer_gateway.dart';
import '../../support/fake_pdf_source_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';
/// The JSON a synced [payload] holds.
Map<String, Object?> json(ByteData payload) =>
jsonDecode(utf8.decode(Uint8List.sublistView(payload)))
as Map<String, Object?>;
/// [files] (paths and their JSON or bytes) as a `.zip`.
Uint8List zip(Map<String, Object> files) {
final archive = Archive();
for (final MapEntry(key: path, value: content) in files.entries) {
archive.add(
ArchiveFile.bytes(
path,
content is Uint8List ? content : utf8.encode(jsonEncode(content)),
),
);
}
return Uint8List.fromList(ZipEncoder().encodeBytes(archive));
}
/// A Japanese studyset with Vocabulary inside: a card of its own, a word
/// named by its JMdict entry, one naming an entry the instance doesn't
/// hold, and reviews of the first two.
Map<String, Object> japanese({int format = 1}) => {
'archive.json': {'format': format},
'studyset.json': {
'id': 'japanese',
'position': 'a0',
'payload': json(
encodeStudysetPayload(
StudysetContent(
title: 'Japanese',
description: RichContent.empty(),
license: 'CC-BY-NC-SA-4.0',
),
),
),
},
'vocabulary/studyset.json': {
'id': 'vocabulary',
'position': 'a0',
'payload': json(
encodeStudysetPayload(StudysetContent.titled('Vocabulary')),
),
},
'cards/own.json': {
'id': 'own',
'position': 'a0',
'tags': ['Kana'],
'payload': json(
encodeCardPayload(
CardContent(
front: RichContent.plain('あ'),
back: RichContent.plain('a'),
),
),
),
},
'vocabulary/cards/table.json': {
'id': 'table',
'position': 'a0',
'entry': {'source': 'jmdict', 'language': 'en', 'id': '1078630'},
},
'vocabulary/cards/gone.json': {
'id': 'gone',
'position': 'a1',
'entry': {'source': 'jmdict', 'language': 'en', 'id': '404'},
},
'reviews.json': [
{'card': 'own', 'rating': 3, 'reviewedAt': '2026-06-09T11:11:08Z'},
{
'card': 'table',
'rating': 1,
'reviewedAt': '2026-06-10T08:00:00Z',
'durationMs': 4200,
},
{'card': 'gone', 'rating': 4, 'reviewedAt': '2026-06-11T08:00:00Z'},
],
};
void main() {
group('reading', () {
test('an archive says what it holds', () {
final archive = readArchive(zip(japanese()));
expect(archive.studysets, 2);
expect(archive.cards, 3);
expect(archive.reviews, hasLength(3));
expect(archive.reviews[1].durationMs, 4200);
expect(archive.reviews[1].reviewedAt, DateTime.utc(2026, 6, 10, 8));
expect(archive.reviews.first.mode, 'review');
});
test('refuses a file that is no zip, a zip without archive.json, a '
'newer format and reviews it cannot read', () {
Matcher refused(ArchiveProblem problem) => throwsA(
isA<ArchiveUnreadable>().having((e) => e.problem, 'problem', problem),
);
expect(
() => readArchive(Uint8List.fromList(utf8.encode('not a zip'))),
refused(ArchiveProblem.notAnArchive),
);
expect(
() => readArchive(zip({...japanese()}..remove('archive.json'))),
refused(ArchiveProblem.notAnArchive),
);
expect(
() => readArchive(zip(japanese(format: 2))),
refused(ArchiveProblem.newerFormat),
);
expect(
() => readArchive(
zip({
...japanese(),
'reviews.json': [
{'card': 'own', 'rating': 7, 'reviewedAt': '2026-06-09'},
],
}),
),
refused(ArchiveProblem.unreadable),
);
for (final unreadable in <Map<String, Object>>[
{
'archive.json': {'format': 1, 'tags': 'Kana'},
},
{
'pdfs/book.json': {
'id': 'book',
'readings': [
{'readAt': 'yesterday', 'kind': 'reached', 'page': 3},
],
},
},
{
'paths/drill/steps/go.json': {
'id': 'go',
'answers': [
{'answeredAt': '2026-06-09T10:00:00Z', 'outcome': 1},
],
},
},
]) {
expect(
() => readArchive(zip({...japanese(), ...unreadable})),
refused(ArchiveProblem.unreadable),
reason: '$unreadable',
);
}
});
test('reads the progress kept where v0.17.0 leaves it alone: tags in '
'their colours, archived and starred studysets, answers to steps '
'and pages read', () {
final archive = readArchive(
zip({
...japanese(),
'archive.json': {
'format': 1,
'tags': [
{'name': 'Kana', 'color': 'copper'},
{'name': 'あかね噺 S1 E1', 'color': 'cobalt', 'origin': 'jellyfin'},
],
},
'vocabulary/studyset.json': {
...japanese()['vocabulary/studyset.json']! as Map<String, Object?>,
'archived': true,
'starred': true,
},
'pdfs/book.json': {
'id': 'book',
'readings': [
{'readAt': '2026-06-09T10:00:00Z', 'kind': 'reached', 'page': 3},
],
},
'paths/drill/steps/go.json': {
'id': 'go',
'answers': [
{'answeredAt': '2026-06-09T11:00:00Z', 'outcome': 'continued'},
],
},
}),
);
expect(archive.tags, [
(name: 'Kana', colorName: 'copper', fromJellyfin: false),
(name: 'あかね噺 S1 E1', colorName: 'cobalt', fromJellyfin: true),
]);
expect(archive.archived, {'vocabulary'});
expect(archive.starred, {'vocabulary'});
expect(archive.readings, [
(
pdf: 'book',
readAt: DateTime.utc(2026, 6, 9, 10),
kind: 'reached',
page: 3,
),
]);
expect(archive.answers, [
(
step: 'go',
answeredAt: DateTime.utc(2026, 6, 9, 11),
outcome: 'continued',
answer: null,
),
]);
});
});
group('importing', () {
late CardRepository cards;
late StudysetRepository studysets;
late TagRepository tags;
late ReviewRepository reviews;
late LearningPathRepository paths;
late ArchiveImporter importer;
setUp(() {
studysets = StudysetRepository(FakeStudysetGateway());
cards = CardRepository(FakeCardGateway());
final notes = NoteRepository(FakeNoteGateway());
paths = LearningPathRepository(FakeLearningPathGateway());
tags = TagRepository(
FakeTagGateway(),
cardChanges: cards.changes,
noteChanges: notes.changes,
pathChanges: paths.changes,
);
final assets = AssetRepository(
FakeAssetGateway(),
FakeAssetServerGateway(),
MemoryAssetFileCache(),
);
final pdfs = PdfSourceRepository(FakePdfSourceGateway(), assets);
reviews = ReviewRepository(FakeReviewGateway());
importer = ArchiveImporter(
ReleaseCopier(studysets, cards, notes, paths, pdfs, tags, assets),
DictionaryRepository(
FakeDictionaryGateway(
words: [
word(
'1078630',
'テーブル',
glosses: ['table'],
labels: ['common', 'jlpt:n5'],
pos: ['n'],
version: 'v1',
),
],
),
noDeviceDictionaries(),
),
reviews,
PathProgressRepository(FakePathAnswerGateway()),
studysets,
pdfs,
);
});
test('an archive this app cannot read all of writes nothing, not even '
'the tags it lists', () async {
final archive = readArchive(
zip({
...japanese(),
'archive.json': {
'format': 1,
'tags': [
{'name': 'Kana', 'color': 'copper'},
],
},
'cards/future.json': {
'id': 'future',
'position': 'a9',
'tags': ['Kana'],
'payload': {'schemaVersion': cardSchemaVersion + 1},
},
}),
);
await expectLater(
importer.import(archive),
throwsA(isA<ReleaseUnreadable>()),
);
expect((await tags.read()).all, isEmpty);
expect(await studysets.all(), isEmpty);
});
test('content naming a studyset of the archive, as a study button '
'does, names it as it is in the library', () async {
final archive = {
...japanese(),
'paths/drill/path.json': {
'id': 'drill',
'position': 'a0',
'payload': json(
encodeLearningPathPayload(LearningPathContent.titled('Drill')),
),
},
'paths/drill/steps/go.json': {
'id': 'go',
'position': 'a0',
'kind': StepKind.info.value,
'payload': json(
encodeStepPayload(
StepContent.empty(StepKind.info).withField(
StepField.body,
RichContent({
'document': {
'type': 'page',
'children': [
{
'type': studyButtonBlockType,
'data': {studyButtonStudysetKey: 'vocabulary'},
},
],
},
}),
),
),
),
},
};
await importer.import(readArchive(zip(archive)));
final vocabulary = (await studysets.all()).singleWhere(
(s) => s.content.title == 'Vocabulary',
);
final path = (await paths.pathsOf(
(await studysets.all()).singleWhere((s) => s.parentId == null).id,
)).single;
final step = (await paths.stepsOf(path.id)).single;
expect(
jsonEncode(step.content.body.toJson()),
contains('"$studyButtonStudysetKey":"${vocabulary.id}"'),
);
});
test("brings the studysets and cards in as the person's own, a word "
'from the dictionary as adding it makes it, the reviews onto the '
'cards they were of; an entry the instance lacks is left out and '
'counted', () async {
final imported = await importer.import(readArchive(zip(japanese())));
expect(imported.cards, 2);
expect(imported.reviews, 2);
expect(imported.missingEntries, 1);
final all = await studysets.all();
expect(all.map((s) => s.content.title), ['Japanese', 'Vocabulary']);
expect(all.every((s) => s.content.published == null), isTrue);
final vocabulary = all.singleWhere(
(s) => s.content.title == 'Vocabulary',
);
final word = (await cards.cardsOf(vocabulary.id)).single;
expect(word.content.entry, (source: 'jmdict', id: '1078630'));
expect(word.content.entryVersion, 'v1');
final own = (await cards.cardsOf(imported.studysetId)).single;
expect(own.content.published, isNull);
final tagging = await tags.read();
expect(
{for (final tag in tagging.of(word.id)) tag.name},
containsAll(['JLPT-N5']),
);
expect({for (final tag in tagging.of(own.id)) tag.name}, {'Kana'});
final logged = await reviews.all();
expect(
{for (final review in logged) review.cardId: review.rating},
{own.id: Rating.good, word.id: Rating.again},
);
expect(
(await cards.allCards()).every((card) => card.imported),
isTrue,
reason: 'counted as imported, not written',
);
});
});
}

View file

@ -0,0 +1,91 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_screen.dart';
import 'package:material_ui/material_ui.dart';
import '../../support/fake_card_gateway.dart';
import '../../support/fake_dictionary_gateway.dart';
import '../../support/fake_file_picker.dart';
import '../../support/fake_studyset_gateway.dart';
import '../../support/pump_app.dart';
import '../../support/test_dependencies.dart';
import 'archive_test.dart' show japanese, zip;
void main() {
late FakeFilePicker files;
late FakeStudysetGateway studysets;
setUp(() {
files = FakeFilePicker();
studysets = FakeStudysetGateway();
});
Future<void> show(WidgetTester tester) async {
tester.view.physicalSize = const Size(1400, 1000);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await pumpApp(
tester,
testDependencies(
studysetGateway: studysets,
cardGateway: FakeCardGateway(),
files: files,
dictionaryGateway: FakeDictionaryGateway(
words: [
word('1078630', 'テーブル', glosses: ['table']),
],
),
),
location: RoutePaths.library,
);
await tester.pump(const Duration(seconds: 1));
await tester.pumpAndSettle();
}
Future<void> choose(WidgetTester tester) async {
await tester.tap(find.byTooltip('Library options'));
await tester.pumpAndSettle();
await tester.tap(find.text('Import'));
await tester.pumpAndSettle();
await tester.tap(find.text('Halkyon archive'));
await tester.pump();
await tester.pump();
await tester.pumpAndSettle();
}
testWidgets("the Library's Import takes an archive: says what it holds, "
'imports it once confirmed, and opens it', (tester) async {
files.next = (name: 'japanese.zip', bytes: zip(japanese()));
await show(tester);
await choose(tester);
expect(find.text('“Japanese” holds:'), findsOneWidget);
expect(find.text('· 2 studysets'), findsOneWidget);
expect(find.text('· 3 reviews'), findsOneWidget);
await tester.tap(find.widgetWithText(FilledButton, 'Import'));
await tester.pumpAndSettle();
expect(find.byType(StudysetScreen), findsOneWidget);
expect(
find.textContaining('Imported: 2 flashcards and 2 reviews.'),
findsOneWidget,
);
expect(find.textContaining("1 word isn't"), findsOneWidget);
final repository = StudysetRepository(studysets);
addTearDown(repository.dispose);
expect((await repository.all()).length, 2);
});
testWidgets('a file that is no archive says so, and imports nothing', (
tester,
) async {
files.next = (name: 'notes.zip', bytes: zip({'notes.txt': {}}));
await show(tester);
await choose(tester);
expect(find.text("This file isn't a Halkyon archive."), findsOneWidget);
expect(find.widgetWithText(FilledButton, 'Import'), findsNothing);
});
}