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 json(ByteData payload) => jsonDecode(utf8.decode(Uint8List.sublistView(payload))) as Map; /// [files] (paths and their JSON or bytes) as a `.zip`. Uint8List zip(Map 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 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().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', ); }); }); }