halkyon/halkyon_learn_flutter/test/feature/archive/archive_test.dart
Tizian 「ティツィアーン」 0d4f7a432d
All checks were successful
CI / check (push) Successful in 15m38s
CI / deploy (push) Successful in 1m20s
fix(explore): a study button in a copy studies the copy's studyset
A study button names the studyset it studies by its id. Copied from a
release or an archive, the studysets get new ids, and a button in the
content kept naming the author's (or the archive's), which the person
doesn't have: it studied nothing. The copier now writes the content after
the studysets, each study button naming the studyset made of the one it
named; only a study button's studyset, as a short id could also be a word
of the content.

A later release taken into a copy still brings the author's ids into its
buttons: the copy updater doesn't rename them yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-11 16:51:42 +02:00

305 lines
11 KiB
Dart

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/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_dictionary_gateway.dart';
import '../../support/fake_learning_path_gateway.dart';
import '../../support/fake_note_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),
);
});
});
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',
),
],
),
),
reviews,
);
});
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',
);
});
});
}