feat(archive): export a studyset as an archive to import again
Export content now asks what to export as: an archive first, a .zip the Library's Import takes back as the person's own, for a backup or a move to another instance; Markdown, to read elsewhere, below it. The archive holds the studyset with everything inside it but what waits in Recently deleted, laid out as the instance writes a release, unlinked from Explore; dictionary cards go whole, with the person's edits. Every file its content names is downloaded first, and an archive missing one is not kept: the person is told how many and to try again online. With my progress (ticked at first) adds the reviews, the answers to learning paths' steps, the pages read in PDFs, and what a release leaves out as only its author's: the exam prepared for, a card's report, a PDF's reading deadline. Stars, archived studysets and the tags' colours come along either way. The import of v0.17.0 refuses a file it doesn't know, so what it doesn't read goes into the files it reads, as keys it skips, and the format stays 1: archive.json's tags, a studyset.json's archived and starred, a step's answers, a PDF's readings. This import reads them; an archive it can't read all of still writes nothing, its tags included. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
197726f5a3
commit
6f3ba7b421
4 changed files with 1671 additions and 30 deletions
|
|
@ -0,0 +1,514 @@
|
|||
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/core/theme/palette_color.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 '../../support/fake_asset_gateway.dart';
|
||||
import '../../support/fake_asset_server_gateway.dart';
|
||||
import '../../support/fake_card_gateway.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()),
|
||||
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),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
@ -16,6 +16,7 @@ import 'package:halkyon_learn_flutter/src/feature/explore/data/release_copier.da
|
|||
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';
|
||||
|
|
@ -34,6 +35,7 @@ import '../../support/fake_card_gateway.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';
|
||||
|
|
@ -161,6 +163,90 @@ void main() {
|
|||
),
|
||||
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,
|
||||
),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -207,9 +293,40 @@ void main() {
|
|||
),
|
||||
),
|
||||
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 = {
|
||||
|
|
|
|||
|
|
@ -3,11 +3,15 @@ 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_payload.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/asset/data/asset_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/router/route_paths.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/export/widget/export_studyset.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/model/studyset_content.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
|
@ -16,6 +20,7 @@ import '../../../support/fake_asset_gateway.dart';
|
|||
import '../../../support/fake_asset_server_gateway.dart';
|
||||
import '../../../support/fake_card_gateway.dart';
|
||||
import '../../../support/fake_file_saver.dart';
|
||||
import '../../../support/fake_review_gateway.dart';
|
||||
import '../../../support/fake_studyset_gateway.dart';
|
||||
import '../../../support/memory_asset_file_cache.dart';
|
||||
import '../../../support/pump_app.dart';
|
||||
|
|
@ -27,6 +32,7 @@ void main() {
|
|||
late FakeAssetGateway assetGateway;
|
||||
late MemoryAssetFileCache cache;
|
||||
late FakeFileSaver saver;
|
||||
late FakeReviewGateway reviewGateway;
|
||||
|
||||
setUp(() {
|
||||
studysets = FakeStudysetGateway()
|
||||
|
|
@ -38,15 +44,23 @@ void main() {
|
|||
assetGateway = FakeAssetGateway();
|
||||
cache = MemoryAssetFileCache();
|
||||
saver = FakeFileSaver();
|
||||
reviewGateway = FakeReviewGateway();
|
||||
});
|
||||
|
||||
Future<void> card(CardContent content) async {
|
||||
Future<String> card(CardContent content) {
|
||||
final cards = CardRepository(cardGateway);
|
||||
addTearDown(cards.dispose);
|
||||
await cards.create('spanish', content);
|
||||
return cards.create('spanish', content);
|
||||
}
|
||||
|
||||
Future<void> exportFromMenu(WidgetTester tester) async {
|
||||
/// Opens the studyset's Export content, and, unless [choose] is null,
|
||||
/// chooses it (the format's name, as the sheet shows it), sets
|
||||
/// [withProgress] and exports.
|
||||
Future<void> exportFromMenu(
|
||||
WidgetTester tester, {
|
||||
String? choose = 'Markdown',
|
||||
bool withProgress = true,
|
||||
}) async {
|
||||
tester.view.physicalSize = const Size(1200, 1000);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
|
|
@ -58,6 +72,7 @@ void main() {
|
|||
assetGateway: assetGateway,
|
||||
assetFiles: cache,
|
||||
fileSaver: saver,
|
||||
reviewGateway: reviewGateway,
|
||||
),
|
||||
location: RoutePaths.studyset('spanish'),
|
||||
);
|
||||
|
|
@ -72,9 +87,139 @@ void main() {
|
|||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Export content'));
|
||||
await tester.pumpAndSettle();
|
||||
if (choose == null) {
|
||||
return;
|
||||
}
|
||||
await tester.tap(find.text(choose));
|
||||
await tester.pumpAndSettle();
|
||||
if (!withProgress) {
|
||||
await tester.tap(find.text('With my progress'));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
await tester.tap(find.widgetWithText(FilledButton, 'Export'));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
testWidgets('Export content keeps the studyset as a Markdown file, named '
|
||||
/// The archive saved, by its files' paths.
|
||||
Map<String, List<int>> savedArchive() => {
|
||||
for (final file in ZipDecoder().decodeBytes(saver.saved.single.bytes).files)
|
||||
file.name: file.content,
|
||||
};
|
||||
|
||||
testWidgets('Export content offers an archive first, with the progress '
|
||||
'and Markdown below; the progress is only asked about for the '
|
||||
'archive', (tester) async {
|
||||
await exportFromMenu(tester, choose: null);
|
||||
|
||||
expect(
|
||||
tester
|
||||
.widget<RadioGroup<ExportFormat>>(
|
||||
find.byType(RadioGroup<ExportFormat>),
|
||||
)
|
||||
.groupValue,
|
||||
ExportFormat.archive,
|
||||
);
|
||||
expect(
|
||||
tester
|
||||
.widget<CheckboxListTile>(
|
||||
find.widgetWithText(CheckboxListTile, 'With my progress'),
|
||||
)
|
||||
.value,
|
||||
isTrue,
|
||||
);
|
||||
await tester.tap(find.text('Markdown'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('With my progress'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('an archive keeps the studyset with its reviews as a zip '
|
||||
'named after it, which the import takes back', (tester) async {
|
||||
final hola = await card(
|
||||
CardContent(
|
||||
front: RichContent.plain('hola'),
|
||||
back: RichContent.plain('hello'),
|
||||
),
|
||||
);
|
||||
await ReviewRepository(reviewGateway).record(
|
||||
cardId: hola,
|
||||
rating: Rating.good,
|
||||
reviewedAt: DateTime.utc(2026, 10),
|
||||
duration: const Duration(seconds: 2),
|
||||
);
|
||||
|
||||
await exportFromMenu(tester, choose: 'Archive');
|
||||
|
||||
final saved = saver.saved.single;
|
||||
expect(saved.name, 'Spanish vocab.zip');
|
||||
expect(saved.mimeType, 'application/zip');
|
||||
final files = savedArchive();
|
||||
expect(
|
||||
files.keys,
|
||||
containsAll(['archive.json', 'studyset.json', 'cards/$hola.json']),
|
||||
);
|
||||
expect(utf8.decode(files['reviews.json']!), contains('"card": "$hola"'));
|
||||
expect(find.text('Exported as an archive (.zip).'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('without the progress, an archive holds no reviews', (
|
||||
tester,
|
||||
) async {
|
||||
final hola = await card(
|
||||
CardContent(
|
||||
front: RichContent.plain('hola'),
|
||||
back: RichContent.plain('hello'),
|
||||
),
|
||||
);
|
||||
await ReviewRepository(reviewGateway).record(
|
||||
cardId: hola,
|
||||
rating: Rating.good,
|
||||
reviewedAt: DateTime.utc(2026, 10),
|
||||
duration: const Duration(seconds: 2),
|
||||
);
|
||||
|
||||
await exportFromMenu(tester, choose: 'Archive', withProgress: false);
|
||||
|
||||
expect(savedArchive().keys, isNot(contains('reviews.json')));
|
||||
});
|
||||
|
||||
testWidgets('an archive missing a file this device cannot have now is '
|
||||
'not kept, and says so', (tester) async {
|
||||
final elsewhere = await assetGateway.insert(
|
||||
sha256: 'f' * 64,
|
||||
mimeType: 'image/png',
|
||||
byteSize: 3,
|
||||
payload: encodeAssetPayload(fileName: 'cat.png'),
|
||||
);
|
||||
await card(
|
||||
CardContent(
|
||||
front: RichContent({
|
||||
'document': {
|
||||
'type': 'page',
|
||||
'children': [
|
||||
{
|
||||
'type': 'image',
|
||||
'data': {'url': assetUrl(elsewhere)},
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
back: RichContent.plain('gato'),
|
||||
),
|
||||
);
|
||||
|
||||
await exportFromMenu(tester, choose: 'Archive');
|
||||
|
||||
expect(saver.saved, isEmpty);
|
||||
expect(
|
||||
find.text(
|
||||
"1 file isn't on this device and couldn't be downloaded. Connect "
|
||||
'to the internet and try again.',
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('as Markdown, it keeps the studyset as a Markdown file, named '
|
||||
'after it', (tester) async {
|
||||
await card(
|
||||
CardContent(
|
||||
|
|
|
|||
|
|
@ -24,10 +24,7 @@ class FakePathAnswerGateway() implements PathAnswerGateway {
|
|||
];
|
||||
|
||||
@override
|
||||
Future<void> insert(
|
||||
List<({String stepId, DateTime answeredAt, String outcome, String? answer})>
|
||||
answers,
|
||||
) async {
|
||||
Future<void> insert(List<LoggedAnswer> answers) async {
|
||||
for (final answer in answers) {
|
||||
_answers.add((
|
||||
id: 'answer-${(_nextId++).toString().padLeft(4, '0')}',
|
||||
|
|
|
|||
Loading…
Reference in a new issue