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,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),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue