chore: Halkyon Learn moves into apps/learn

Its server, client, app and curator, and the tools that write its
assets (maps, Anki fixtures, icons), as they are; paths are fixed in
the commits that follow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-19 20:08:06 +02:00
commit 649499be23
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
5 changed files with 0 additions and 0 deletions

View file

@ -0,0 +1,227 @@
import 'dart:convert';
import 'dart:typed_data';
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/published_ref.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_payload.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card_browser.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
import 'package:halkyon_learn_flutter/src/feature/explore/data/release_copier.dart';
import 'package:halkyon_learn_flutter/src/feature/explore/model/explore_studyset.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_repository.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/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 'package:material_ui/material_ui.dart' hide Card;
import '../../support/fake_asset_gateway.dart';
import '../../support/fake_asset_server_gateway.dart';
import '../../support/fake_card_gateway.dart';
import '../../support/fake_explore_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_studyset_gateway.dart';
import '../../support/fake_tag_gateway.dart';
import '../../support/memory_asset_file_cache.dart';
import '../../support/pump_app.dart';
import '../../support/test_dependencies.dart';
/// The JSON a synced [payload] holds.
Map<String, Object?> json(ByteData payload) =>
jsonDecode(utf8.decode(Uint8List.sublistView(payload)))
as Map<String, Object?>;
/// Release [version] of Japanese with [cards] (by id: front and back).
PublishedRelease japanese(int version, Map<String, (String, String)> cards) =>
PublishedRelease(
publicId: 'japanese',
version: version,
items: {
'studyset.json': {
'id': 'root',
'payload': json(
encodeStudysetPayload(StudysetContent.titled('Japanese')),
),
},
for (final (i, MapEntry(key: id, value: (front, back)))
in cards.entries.indexed)
'cards/$id.json': {
'id': id,
'position': 'a$i',
'payload': json(
encodeCardPayload(
CardContent(
front: RichContent.plain(front),
back: RichContent.plain(back),
),
),
),
},
},
);
ExploreStudyset listed(int version) => ExploreStudyset(
publicId: 'japanese',
location: 'halkyon.eu/@alice/japanese',
handle: 'alice',
version: version,
releasedAt: DateTime.utc(2026, 10, 9),
title: 'Japanese',
description: RichContent.empty(),
license: 'CC-BY-SA-4.0',
);
void main() {
late FakeStudysetGateway studysetGateway;
late FakeCardGateway cardGateway;
late String root;
setUp(() async {
studysetGateway = FakeStudysetGateway();
cardGateway = FakeCardGateway();
final studysets = StudysetRepository(studysetGateway);
final cards = CardRepository(cardGateway);
final notes = NoteRepository(FakeNoteGateway());
final paths = LearningPathRepository(FakeLearningPathGateway());
final assets = AssetRepository(
FakeAssetGateway(),
FakeAssetServerGateway(),
MemoryAssetFileCache(),
);
// The person added release 1 to their library before.
root =
await ReleaseCopier(
studysets,
cards,
notes,
paths,
PdfSourceRepository(FakePdfSourceGateway(), assets),
TagRepository(FakeTagGateway()),
assets,
).copy(
japanese(1, {'cat': ('猫', 'cat')}),
location: 'halkyon.eu/@alice/japanese',
fileOf: (_) async => Uint8List(0),
);
});
Future<void> showCopy(WidgetTester tester, FakeExploreGateway explore) async {
tester.view.physicalSize = const Size(1200, 1000);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await pumpApp(
tester,
testDependencies(
studysetGateway: studysetGateway,
cardGateway: cardGateway,
exploreGateway: explore,
),
location: RoutePaths.studyset(root),
);
await tester.pump(const Duration(seconds: 1));
await tester.pumpAndSettle();
}
testWidgets('a newer release is offered on the copy, with what it brings, '
'and taken at a tap', (tester) async {
await showCopy(
tester,
FakeExploreGateway(
published: [listed(2)],
releases: [
japanese(1, {'cat': ('猫', 'cat')}),
japanese(2, {'cat': ('猫', 'a cat'), 'dog': ('犬', 'dog')}),
],
),
);
expect(find.text('v2 is out: 1 changed, 1 new.'), findsOneWidget);
await tester.tap(find.text('Update'));
await tester.pumpAndSettle();
expect(find.text('Updated to v2.'), findsOneWidget);
expect(find.text('Update'), findsNothing);
final cards = await CardRepository(cardGateway).cardsOf(root);
expect(
[for (final card in cards) card.content.back.plainText],
[
'a cat',
'dog',
],
);
});
testWidgets('nothing is offered while the copy holds the latest', (
tester,
) async {
await showCopy(
tester,
FakeExploreGateway(
published: [listed(1)],
releases: [
japanese(1, {'cat': ('猫', 'cat')}),
],
),
);
expect(find.text('Update'), findsNothing);
});
testWidgets('a withdrawn studyset offers nothing', (tester) async {
await showCopy(tester, FakeExploreGateway());
expect(find.text('Update'), findsNothing);
});
test('the card browser finds the cards the author withdrew', () {
BrowsedCard browsed({required bool withdrawn}) => (
card: Card(
id: 'card',
position: 'a0',
content: CardContent(
front: RichContent.plain('去'),
back: RichContent.plain('gone'),
published: PublishedRef(
publication: 'japanese',
item: 'gone',
version: 2,
withdrawn: withdrawn,
),
),
heads: const {'revision'},
),
studysetId: 'japanese',
tags: const [],
schedule: null,
);
final now = DateTime.utc(2026, 10, 9);
expect(
isInState(
browsed(withdrawn: true),
CardStatus.withdrawn,
now: now,
startOfToday: now,
),
isTrue,
);
expect(
isInState(
browsed(withdrawn: false),
CardStatus.withdrawn,
now: now,
startOfToday: now,
),
isFalse,
);
});
}

View file

@ -0,0 +1,611 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:crypto/crypto.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/rich_content.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.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
import 'package:halkyon_learn_flutter/src/feature/explore/data/copy_updater.dart';
import 'package:halkyon_learn_flutter/src/feature/explore/data/release_copier.dart';
import 'package:halkyon_learn_flutter/src/feature/explore/model/explore_studyset.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_repository.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_payload.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/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/exam_plan.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_learning_path_gateway.dart';
import '../../support/fake_note_gateway.dart';
import '../../support/fake_pdf_source_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?>;
CardContent card(String front, String back) =>
CardContent(front: RichContent.plain(front), back: RichContent.plain(back));
/// [json] with the keys of every map in it sorted, as the server writes a
/// release's files.
Map<String, Object?> sortedKeys(Map<String, Object?> json) => {
for (final key in json.keys.toList()..sort()) key: _sorted(json[key]),
};
Object? _sorted(Object? json) => switch (json) {
final Map<String, Object?> map => sortedKeys(map),
final List<Object?> list => [for (final value in list) _sorted(value)],
_ => json,
};
final book = Uint8List.fromList(utf8.encode('%PDF-1.7 a book'));
final bookHash = sha256.convert(book).toString();
const bookAsset = '0192aaaa-0000-7000-8000-000000000002';
/// Release [version] of a Japanese studyset, described as [description],
/// planning for [examPlans], with [cards] (by id: front, back and tags) in
/// it, a Kanji studyset in it holding [kanjiCards], a Grammar studyset
/// [withGrammar], and a PDF titled [bookTitle]; its files' keys sorted, as
/// the server writes them.
PublishedRelease japanese(
int version, {
String description = 'From N5 on.',
List<ExamPlan> examPlans = const [],
Map<String, (String, String, List<String>)> cards = const {},
Map<String, (String, String, List<String>)> kanjiCards = const {},
bool withKanji = true,
bool withGrammar = false,
String bookTitle = 'A book',
}) {
Map<String, Object?> cardFile(
String id,
(String, String, List<String>) card,
int position,
) => {
'id': id,
'position': 'a$position',
'tags': card.$3,
'payload': json(
encodeCardPayload(
CardContent(
front: RichContent.plain(card.$1),
back: RichContent.plain(card.$2),
),
),
),
};
final items = <String, Map<String, Object?>>{
'studyset.json': {
'id': 'japanese',
'payload': json(
encodeStudysetPayload(
StudysetContent(
title: 'Japanese',
description: RichContent.plain(description),
license: 'CC-BY-SA-4.0',
examPlans: examPlans,
),
),
),
},
for (final (i, MapEntry(key: id, value: card)) in cards.entries.indexed)
'cards/$id.json': cardFile(id, card, i),
if (withKanji) ...{
'kanji/studyset.json': {
'id': 'kanji',
'position': 'a1',
'payload': json(
encodeStudysetPayload(StudysetContent.titled('Kanji')),
),
},
for (final (i, MapEntry(key: id, value: card))
in kanjiCards.entries.indexed)
'kanji/cards/$id.json': cardFile(id, card, i),
},
if (withGrammar)
'grammar/studyset.json': {
'id': 'grammar',
'position': 'a2',
'payload': json(
encodeStudysetPayload(StudysetContent.titled('Grammar')),
),
},
'pdfs/book.json': {
'id': 'book',
'position': 'a0',
'payload': json(
encodePdfSourcePayload(
PdfSourceContent(
title: bookTitle,
fileName: 'book.pdf',
assetId: bookAsset,
),
),
),
},
'files.json': {
bookAsset: {
'sha256': bookHash,
'mimeType': 'application/pdf',
'size': book.length,
'payload': {'schemaVersion': 1, 'fileName': 'book.pdf'},
},
},
};
return PublishedRelease(
publicId: 'publication-1',
version: version,
items: {
for (final MapEntry(key: path, value: item) in items.entries)
path: sortedKeys(item),
},
);
}
void main() {
late StudysetRepository studysets;
late CardRepository cards;
late TagRepository tags;
late PdfSourceRepository pdfs;
late ReleaseCopier copier;
late CopyUpdater updater;
late List<String> downloaded;
setUp(() {
studysets = StudysetRepository(FakeStudysetGateway());
cards = CardRepository(FakeCardGateway());
final notes = NoteRepository(FakeNoteGateway());
final paths = LearningPathRepository(FakeLearningPathGateway());
tags = TagRepository(
FakeTagGateway(),
cardChanges: cards.changes,
noteChanges: notes.changes,
pathChanges: paths.changes,
);
final assets = AssetRepository(
FakeAssetGateway(),
FakeAssetServerGateway(),
MemoryAssetFileCache(),
);
pdfs = PdfSourceRepository(FakePdfSourceGateway(), assets);
copier = ReleaseCopier(studysets, cards, notes, paths, pdfs, tags, assets);
updater = CopyUpdater(studysets, cards, notes, paths, pdfs, tags, assets);
downloaded = [];
});
Future<Uint8List> fileOf(String sha256) async {
downloaded.add(sha256);
return book;
}
Future<String> copyOf(PublishedRelease release) => copier.copy(
release,
location: 'halkyon.eu/@alice/japanese',
fileOf: fileOf,
);
Future<Card> cardFrom(String rootId, String item) async {
for (final studyset in await studysets.all()) {
for (final card in await cards.cardsOf(studyset.id)) {
if (card.content.published?.item == item) {
return card;
}
}
}
throw StateError('No card from $item.');
}
test(
'counts what a release would bring, from the one the copy took',
() async {
final v1 = japanese(
1,
cards: {
'cat': ('猫', 'cat', []),
'dog': ('犬', 'dog', []),
'gone': ('去', 'gone', []),
},
);
final root = await copyOf(v1);
final update = await updater.plan(
root,
v1,
japanese(
2,
cards: {
'cat': ('猫', 'a cat', []),
'dog': ('犬', 'dog', []),
'new': ('新', 'new', []),
},
),
);
expect(
update,
const CopyUpdate(version: 2, changed: 1, added: 1, withdrawn: 1),
);
},
);
test('what only the release changed comes in, what the learner changed '
'stays, and a side both changed is a conflict to settle', () async {
final v1 = japanese(
1,
cards: {
'cat': ('猫', 'cat', []),
'dog': ('犬', 'dog', []),
'fish': ('魚', 'fish', []),
},
);
final root = await copyOf(v1);
// The learner writes the dog's front in kana, and the fish's back.
final dog = await cardFrom(root, 'dog');
await cards.save(
dog,
dog.content.withField(CardField.front, RichContent.plain('いぬ')),
);
final fish = await cardFrom(root, 'fish');
await cards.save(
fish,
fish.content.withField(CardField.back, RichContent.plain('a fish')),
);
final toSettle = await updater.take(
root,
v1,
japanese(
2,
cards: {
'cat': ('猫', 'a cat', []),
'dog': ('犬', 'a dog', []),
'fish': ('魚', 'the fish', []),
},
),
fileOf: fileOf,
);
expect(toSettle, 1);
final cat = await cardFrom(root, 'cat');
expect(cat.content.back.plainText, 'a cat');
expect(cat.content.published?.version, 2);
final dogNow = await cardFrom(root, 'dog');
expect(dogNow.content.front.plainText, 'いぬ');
expect(dogNow.content.back.plainText, 'a dog');
expect(dogNow.hasConflict, isFalse);
expect((await cardFrom(root, 'fish')).conflicts.keys, [CardField.back]);
});
test('new items come in where they belong; what the author withdrew '
'stays, marked; what the learner deleted stays deleted', () async {
final v1 = japanese(
1,
cards: {'cat': ('猫', 'cat', []), 'gone': ('去', 'gone', [])},
kanjiCards: {'one': ('一', 'one', [])},
);
final root = await copyOf(v1);
final kanji = (await studysets.all()).singleWhere(
(studyset) => studyset.parentId == root,
);
await cards.delete(await cardFrom(root, 'cat'));
// The learner deleted Kanji: what comes new in it doesn't come.
await cards.delete(await cardFrom(root, 'one'));
await studysets.delete(kanji.id);
await updater.take(
root,
v1,
japanese(
2,
cards: {'cat': ('猫', 'a cat', []), 'new': ('新', 'new', [])},
kanjiCards: {'one': ('一', 'one', []), 'two': ('二', 'two', [])},
),
fileOf: fileOf,
);
final inRoot = await cards.cardsOf(root);
expect(
[for (final card in inRoot) card.content.published?.item],
[
'gone',
'new',
],
);
final gone = inRoot.first;
expect(gone.content.published?.withdrawn, isTrue);
expect(gone.content.published?.version, 2);
expect(gone.content.back.plainText, 'gone');
expect(
(await studysets.all()).where((studyset) => studyset.parentId == root),
isEmpty,
);
});
test('a new studyset comes with what is in it', () async {
final v1 = japanese(1, withKanji: false);
final root = await copyOf(v1);
await updater.take(
root,
v1,
japanese(2, kanjiCards: {'one': ('一', 'one', [])}),
fileOf: fileOf,
);
final kanji = (await studysets.all()).singleWhere(
(studyset) => studyset.parentId == root,
);
expect(kanji.content.title, 'Kanji');
expect(kanji.content.published?.item, 'kanji');
expect((await cards.cardsOf(kanji.id)).single.content.front.plainText, '一');
});
test("exam plans the release brings name the copy's studysets; the exam "
'the learner prepares for stays theirs', () async {
final v1 = japanese(1);
final root = await copyOf(v1);
final copy = (await studysets.all()).singleWhere((s) => s.id == root);
final exam = ExamChoice(plan: 'auto:jlpt:1', date: DateTime(2026, 12, 6));
await studysets.updateContent(root, copy.content.withExam(exam));
await updater.take(
root,
v1,
japanese(
2,
examPlans: const [
ExamPlan(id: 'kanji-test', name: 'Kanji test', topics: ['kanji']),
],
),
fileOf: fileOf,
);
final all = await studysets.all();
final now = all.singleWhere((s) => s.id == root);
final kanji = all.singleWhere((s) => s.parentId == root);
expect(now.content.examPlans, [
ExamPlan(id: 'kanji-test', name: 'Kanji test', topics: [kanji.id]),
]);
expect(now.content.exam, exam);
});
test("the author's later changes to an exam plan come in, a topic new in "
"the release named by the copy's studyset", () async {
final v1 = japanese(
1,
examPlans: const [
ExamPlan(
id: 'kanji-test',
name: 'Kanji test',
topics: ['kanji'],
tags: ['JLPT-N5'],
),
],
);
final root = await copyOf(v1);
await updater.take(
root,
v1,
japanese(
2,
withGrammar: true,
examPlans: const [
ExamPlan(
id: 'kanji-test',
name: 'Kanji and grammar test',
topics: ['kanji', 'grammar'],
tags: ['JLPT-N5'],
),
],
),
fileOf: fileOf,
);
final all = await studysets.all();
String titled(String title) =>
all.singleWhere((s) => s.content.title == title).id;
expect(all.singleWhere((s) => s.id == root).content.examPlans, [
ExamPlan(
id: 'kanji-test',
name: 'Kanji and grammar test',
topics: [titled('Kanji'), titled('Grammar')],
tags: const ['JLPT-N5'],
),
]);
});
test("the learner's own change to an exam plan stays theirs", () async {
const plan = ExamPlan(
id: 'kanji-test',
name: 'Kanji test',
topics: ['kanji'],
tags: ['JLPT-N5'],
);
final v1 = japanese(1, examPlans: const [plan]);
final root = await copyOf(v1);
final copy = (await studysets.all()).singleWhere((s) => s.id == root);
final mine = ExamPlan(
id: 'kanji-test',
name: 'My kanji test',
topics: copy.content.examPlans.single.topics,
tags: const ['JLPT-N5'],
);
await studysets.updateContent(root, copy.content.withExamPlans([mine]));
await updater.take(
root,
v1,
japanese(
2,
description: 'From N5 to N3.',
examPlans: const [
ExamPlan(
id: 'kanji-test',
name: 'Kanji exam',
topics: ['kanji'],
tags: ['JLPT-N5'],
),
],
),
fileOf: fileOf,
);
final now = (await studysets.all()).singleWhere((s) => s.id == root);
expect(now.content.examPlans, [mine]);
expect(now.content.description.plainText, 'From N5 to N3.');
});
test("a studyset's or PDF's fields the learner changed stay theirs; the "
"copy says it holds the newer release; files it has aren't fetched "
'again', () async {
final v1 = japanese(1);
final root = await copyOf(v1);
final copy = (await studysets.all()).singleWhere((s) => s.id == root);
await studysets.updateContent(
root,
copy.content.copyWith(title: 'My Japanese'),
);
final pdf = (await pdfs.sourcesOf(root)).single;
await pdfs.setDeadline(
pdf.id,
const ReadingDeadline(
targetPage: 10,
due: (year: 2026, month: 11, day: 1),
),
);
downloaded.clear();
await updater.take(
root,
v1,
japanese(2, description: 'From N5 to N3.', bookTitle: 'The book'),
fileOf: fileOf,
);
final now = (await studysets.all()).singleWhere((s) => s.id == root);
expect(now.content.title, 'My Japanese');
expect(now.content.description.plainText, 'From N5 to N3.');
expect(now.content.published?.version, 2);
expect(now.content.publishedAt, 'halkyon.eu/@alice/japanese');
final book = (await pdfs.sourcesOf(root)).single;
expect(book.content.title, 'The book');
expect(book.content.deadline?.targetPage, 10);
expect(downloaded, isEmpty);
});
test("the release's tags come and go; the learner's own stay", () async {
final v1 = japanese(
1,
cards: {
'cat': ('猫', 'cat', ['Noun', 'N5']),
},
);
final root = await copyOf(v1);
final cat = await cardFrom(root, 'cat');
final mine = await tags.create('Animals');
final tagging = await tags.read();
await tags.setTagsOf(cat.id, {
...{for (final tag in tagging.of(cat.id)) tag.id},
mine,
});
await updater.take(
root,
v1,
japanese(
2,
cards: {
'cat': ('猫', 'cat', ['Noun', 'JLPT-N5']),
},
),
fileOf: fileOf,
);
expect(
[for (final tag in (await tags.read()).of(cat.id)) tag.name]..sort(),
['Animals', 'JLPT-N5', 'Noun'],
);
});
test("what is tied to a page of a PDF stays tied to the copy's PDF when "
'the release changes it, and what a release ties to a PDF it brings '
'is tied to the one made of it', () async {
PublishedRelease release(int version) {
final base = japanese(version);
Map<String, Object?> page(String id, String back, String pdf) =>
sortedKeys({
'id': id,
'position': 'a5',
'tags': const <String>[],
'payload': json(
encodeCardPayload(
CardContent(
front: RichContent.plain(id),
back: RichContent.plain(back),
source: (pdfId: pdf, page: 3),
),
),
),
});
return PublishedRelease(
publicId: base.publicId,
version: version,
items: {
...base.items,
'cards/page.json': page(
'page',
version == 1 ? 'is' : 'is, politely',
'book',
),
if (version > 1) ...{
'pdfs/workbook.json': sortedKeys({
'id': 'workbook',
'position': 'a1',
'payload': json(
encodePdfSourcePayload(
const PdfSourceContent(
title: 'Workbook',
fileName: 'workbook.pdf',
assetId: bookAsset,
),
),
),
}),
'cards/drill.json': page('drill', 'a drill', 'workbook'),
},
},
);
}
final v1 = release(1);
final root = await copyOf(v1);
await updater.take(root, v1, release(2), fileOf: fileOf);
final copied = await pdfs.sourcesOf(root);
String pdfTitled(String title) =>
copied.singleWhere((pdf) => pdf.content.title == title).id;
final page = await cardFrom(root, 'page');
expect(page.content.back.plainText, 'is, politely');
expect(page.content.source, (pdfId: pdfTitled('A book'), page: 3));
expect(
(await cardFrom(root, 'drill')).content.source,
(pdfId: pdfTitled('Workbook'), page: 3),
);
});
}

View file

@ -0,0 +1,525 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_app/testing.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/router/route_paths.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_payload.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
import 'package:halkyon_learn_flutter/src/feature/explore/data/explore_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/explore/model/explore_studyset.dart';
import 'package:halkyon_learn_flutter/src/feature/profile/model/public_profile.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/studyset/widget/studyset_screen.dart';
import 'package:material_ui/material_ui.dart';
import '../../support/fake_card_gateway.dart';
import '../../support/fake_explore_gateway.dart';
import '../../support/fake_profile_gateway.dart';
import '../../support/fake_studyset_gateway.dart';
import '../../support/pump_app.dart';
import '../../support/test_dependencies.dart';
/// A published studyset as Explore lists it.
ExploreStudyset published(
String title, {
String publicId = 'japanese',
String handle = 'alice',
String displayName = 'Alice',
String? frontLanguage,
int cards = 812,
int paths = 0,
int? learners,
String? basedOn,
String? yours,
}) => ExploreStudyset(
publicId: publicId,
location: 'halkyon.eu/@$handle/$publicId',
handle: handle,
displayName: displayName,
version: 3,
releasedAt: DateTime.utc(2026, 10, 9),
notes: 'N4 kanji',
title: title,
description: RichContent.plain('Kana, kanji and grammar from N5 on.'),
frontLanguage: frontLanguage,
license: 'CC-BY-SA-4.0',
licenseHolder: 'Alice',
cards: cards,
paths: paths,
learners: learners,
basedOn: basedOn,
yours: yours,
);
/// The JSON a synced [payload] holds.
Map<String, Object?> json(ByteData payload) =>
jsonDecode(utf8.decode(Uint8List.sublistView(payload)))
as Map<String, Object?>;
/// Release 3 of the published studyset [publicId]: a studyset titled
/// [title] with one card.
PublishedRelease release(String publicId, String title) => PublishedRelease(
publicId: publicId,
version: 3,
items: {
'studyset.json': {
'id': 'root',
'payload': json(encodeStudysetPayload(StudysetContent.titled(title))),
},
'cards/cat.json': {
'id': 'cat',
'position': 'a0',
'tags': const <String>[],
'payload': json(
encodeCardPayload(
CardContent(
front: RichContent.plain('猫'),
back: RichContent.plain('cat'),
),
),
),
},
},
);
void main() {
late FakeStudysetGateway studysets;
setUp(() => studysets = FakeStudysetGateway());
Future<void> showExplore(
WidgetTester tester,
FakeExploreGateway explore, {
bool identified = true,
FakeProfileGateway? profiles,
FakeCardGateway? cards,
}) async {
tester.view.physicalSize = const Size(900, 1400);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await pumpApp(
tester,
testDependencies(
authGateway: FakeAuthGateway(
status: SignedIn(
authUserId: 'github-person',
isIdentified: identified,
),
),
studysetGateway: studysets,
exploreGateway: explore,
profileGateway: profiles,
cardGateway: cards,
),
location: RoutePaths.explore,
);
await tester.pumpAndSettle();
}
testWidgets('lists what is published, newest first, with its author, '
'latest release, what it holds and its licence', (tester) async {
await showExplore(
tester,
FakeExploreGateway(published: [published('Japanese')]),
);
expect(find.text('Japanese'), findsOneWidget);
// Its author, release and licence quieter, below what it holds.
expect(find.text('Kana, kanji and grammar from N5 on.'), findsOneWidget);
expect(find.text('812 flashcards'), findsOneWidget);
expect(find.text('@alice · v3 · CC BY-SA 4.0'), findsOneWidget);
expect(find.text('Add'), findsOneWidget);
});
testWidgets('says how many learn it here, from 5 on, and what a copy is '
'based on', (tester) async {
await showExplore(
tester,
FakeExploreGateway(
published: [
published(
'Japanese',
learners: 10,
basedOn: 'halkyon.eu/@tae/japanese',
),
published('Latin', publicId: 'latin'),
],
),
);
expect(find.text('10+ learn this here'), findsOneWidget);
expect(find.text('based on halkyon.eu/@tae/japanese'), findsOneWidget);
});
testWidgets('an anonymous person explores too', (tester) async {
await showExplore(
tester,
FakeExploreGateway(published: [published('Japanese')]),
identified: false,
);
expect(find.text('Japanese'), findsOneWidget);
});
testWidgets('searches once typing pauses', (tester) async {
final explore = FakeExploreGateway(
published: [
published('Japanese'),
published('Latin', publicId: 'latin', handle: 'bob'),
],
);
await showExplore(tester, explore);
await tester.enterText(find.byType(SearchBar), 'lat');
await tester.pump(const Duration(milliseconds: 100));
expect(explore.asked, hasLength(1));
await tester.pump(const Duration(milliseconds: 400));
await tester.pumpAndSettle();
expect(explore.asked.last.filter.text, 'lat');
expect(find.text('Latin'), findsOneWidget);
expect(find.text('Japanese'), findsNothing);
});
testWidgets('filters by language and by kind of content', (tester) async {
final explore = FakeExploreGateway(
published: [
published('Japanese', frontLanguage: 'ja'),
published('Latin', publicId: 'latin', paths: 2),
],
);
await showExplore(tester, explore);
await tester.tap(find.text('Languages'));
await tester.pumpAndSettle();
await tester.tap(find.text('Any').first);
await tester.pumpAndSettle();
await tester.tap(find.text('Japanese').last);
await tester.pumpAndSettle();
await tester.tapAt(const Offset(10, 10));
await tester.pumpAndSettle();
expect(explore.asked.last.filter.frontLanguage, 'ja');
expect(find.text('Japanese → Any'), findsOneWidget);
expect(find.text('Latin'), findsNothing);
await tester.tap(find.text('Content'));
await tester.pumpAndSettle();
await tester.tap(find.text('Learning paths'));
await tester.pumpAndSettle();
await tester.tapAt(const Offset(10, 10));
await tester.pumpAndSettle();
expect(explore.asked.last.filter.kinds, {ContentKind.paths});
expect(find.text('Nothing published here matches.'), findsOneWidget);
await tester.tap(find.text('Clear filters'));
await tester.pumpAndSettle();
expect(find.text('Latin'), findsOneWidget);
expect(explore.asked.last.filter, const ExploreFilter());
});
testWidgets('shows the next page only when asked', (tester) async {
await showExplore(
tester,
FakeExploreGateway(
published: [
published('One', publicId: 'one'),
published('Two', publicId: 'two'),
published('Three', publicId: 'three'),
],
),
);
expect(find.text('Three'), findsNothing);
await tester.tap(find.text('Show more'));
await tester.pumpAndSettle();
expect(find.text('Three'), findsOneWidget);
expect(find.text('Show more'), findsNothing);
});
testWidgets('says when nothing is published yet', (tester) async {
await showExplore(tester, FakeExploreGateway());
expect(
find.text('Studysets that others share will appear here.'),
findsOneWidget,
);
});
testWidgets('a licence holder of the instance is named by their handle, '
"without the @, opening their profile; one from elsewhere isn't", (
tester,
) async {
final japanese = published('Japanese');
await showExplore(
tester,
FakeExploreGateway(
published: [
ExploreStudyset(
publicId: japanese.publicId,
location: japanese.location,
handle: 'alice',
version: 3,
releasedAt: japanese.releasedAt,
title: 'Japanese',
description: japanese.description,
license: 'CC-BY-SA-4.0',
licenseHolder: '@alice',
),
],
),
profiles: FakeProfileGateway(
others: {
'alice': const PublicProfile(
handle: 'alice',
address: 'alice@halkyon.eu',
displayName: 'Alice',
bio: 'Learning Japanese.',
links: ['https://alice.example', 'https://blog.example'],
verifiedLinks: ['https://alice.example'],
),
},
),
);
await tester.tap(find.text('Japanese'));
await tester.pumpAndSettle();
expect(find.textContaining('@alice', findRichText: true), findsWidgets);
expect(find.text('alice'), findsOneWidget);
await tester.tap(find.text('alice'));
await tester.pumpAndSettle();
expect(find.text('Learning Japanese.'), findsOneWidget);
expect(find.text('@alice@halkyon.eu'), findsOneWidget);
// The link whose page links back to her profile is marked; the other
// isn't.
expect(find.byTooltip('Links back to this profile'), findsOneWidget);
expect(
find.descendant(
of: find.widgetWithText(ListTile, 'alice.example'),
matching: find.byTooltip('Links back to this profile'),
),
findsOneWidget,
);
});
testWidgets('a summary shows all of it, and adds it to the library as '
'a copy, opened from there', (tester) async {
await showExplore(
tester,
FakeExploreGateway(
published: [published('Japanese')],
releases: [release('japanese', 'Japanese')],
),
);
await tester.tap(find.text('Japanese'));
await tester.pumpAndSettle();
expect(find.text('Latest release: v3 · Oct 9, 2026'), findsOneWidget);
expect(find.text('N4 kanji'), findsOneWidget);
expect(
find.text('Kana, kanji and grammar from N5 on.', findRichText: true),
findsWidgets,
);
await tester.tap(find.text('Add to library').last);
await tester.pumpAndSettle();
final copies = await StudysetRepository(studysets).all();
expect(copies.single.content.title, 'Japanese');
expect(copies.single.content.publishedAt, 'halkyon.eu/@alice/japanese');
expect(find.text('Added to your library.'), findsOneWidget);
expect(find.text('In library'), findsOneWidget);
await tester.tap(find.text('Open'));
await tester.pumpAndSettle();
expect(find.text('Japanese'), findsWidgets);
expect(find.byType(StudysetScreen), findsOneWidget);
});
testWidgets('a studyset the person has a copy of is in their library '
'already', (tester) async {
studysets.receiveRemoteChange(
'mine',
encodeStudysetPayload(
StudysetContent(
title: 'Japanese',
description: RichContent.empty(),
published: const PublishedRef(
publication: 'japanese',
item: 'root',
version: 2,
),
publishedAt: 'halkyon.eu/@alice/japanese',
),
),
);
await showExplore(
tester,
FakeExploreGateway(published: [published('Japanese')]),
);
expect(find.text('In library'), findsOneWidget);
expect(find.text('Add'), findsNothing);
});
group("the person's own studyset", () {
setUp(
() => studysets.receiveRemoteChange(
'mine',
encodeStudysetPayload(StudysetContent.titled('Japanese')),
),
);
testWidgets('shows as theirs and opens, adding no copy; the summary adds '
'a copy anyway when asked', (tester) async {
await showExplore(
tester,
FakeExploreGateway(
published: [published('Japanese', yours: 'mine')],
releases: [release('japanese', 'Japanese')],
),
);
expect(find.text('Add'), findsNothing);
await tester.tap(find.text('Yours'));
await tester.pumpAndSettle();
expect(find.byType(StudysetScreen), findsOneWidget);
expect(await StudysetRepository(studysets).all(), hasLength(1));
await openTab(tester, 'Explore');
await tester.tap(find.text('Japanese').first);
await tester.pumpAndSettle();
await tester.tap(find.text('Add a copy anyway'));
await tester.pumpAndSettle();
final all = await StudysetRepository(studysets).all();
expect(all, hasLength(2));
expect(
all.where((studyset) => studyset.content.publishedAt != null),
hasLength(1),
);
});
testWidgets('deleted from the library, it can be added again', (
tester,
) async {
studysets.receiveRemoteChange(
'mine',
encodeStudysetPayload(StudysetContent.titled('Japanese')),
deletedAt: DateTime.utc(2026, 10, 15),
);
await showExplore(
tester,
FakeExploreGateway(
published: [published('Japanese', yours: 'mine')],
releases: [release('japanese', 'Japanese')],
),
);
expect(find.text('Yours'), findsNothing);
expect(find.text('Add'), findsOneWidget);
});
});
testWidgets('a release in a schema this app does not know is not added, '
'and says why', (tester) async {
final newer = release('japanese', 'Japanese');
await showExplore(
tester,
FakeExploreGateway(
published: [published('Japanese')],
releases: [
PublishedRelease(
publicId: 'japanese',
version: 3,
items: {
...newer.items,
'cards/future.json': {
'id': 'future',
'payload': {'schemaVersion': cardSchemaVersion + 1},
},
},
),
],
),
);
await tester.tap(find.text('Add'));
await tester.pumpAndSettle();
expect(
find.text('This studyset needs a newer version of the app.'),
findsOneWidget,
);
expect(await StudysetRepository(studysets).all(), isEmpty);
});
group('says what kept a studyset from being added', () {
Future<void> add(
WidgetTester tester, {
Exception? failRelease,
FakeCardGateway? cards,
}) async {
await showExplore(
tester,
FakeExploreGateway(
published: [published('Japanese')],
releases: [release('japanese', 'Japanese')],
)..failRelease = failRelease,
cards: cards,
);
await tester.tap(find.text('Add'));
await tester.pumpAndSettle();
}
testWidgets('no connection, or no answer in time', (tester) async {
await add(tester, failRelease: const InstanceUnreachable());
expect(
find.text("Couldn't reach your instance. Try again."),
findsOneWidget,
);
});
testWidgets("the instance's own error", (tester) async {
await add(tester, failRelease: const InstanceFailed());
expect(
find.text(
"Your instance couldn't give out this studyset. Try again later.",
),
findsOneWidget,
);
});
testWidgets('this device, writing it', (tester) async {
await add(
tester,
cards: FakeCardGateway()..failWrites = Exception('full'),
);
expect(
find.text("Couldn't add it to your library on this device."),
findsOneWidget,
);
expect(find.textContaining('reach'), findsNothing);
});
});
testWidgets('without the instance, it says so and tries again', (
tester,
) async {
final explore = FakeExploreGateway(published: [published('Japanese')])
..fail = Exception('offline');
await showExplore(tester, explore);
expect(find.text("Explore can't be reached right now."), findsOneWidget);
explore.fail = null;
await tester.tap(find.text('Retry'));
await tester.pumpAndSettle();
expect(find.text('Japanese'), findsOneWidget);
});
}

View file

@ -0,0 +1,490 @@
import 'dart:convert';
import 'dart:typed_data';
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/published_ref.dart';
import 'package:halkyon_learn_flutter/src/core/content/rich_content.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/explore/data/release_copier.dart';
import 'package:halkyon_learn_flutter/src/feature/explore/model/explore_studyset.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_payload.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_payload.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/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/exam_plan.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_learning_path_gateway.dart';
import '../../support/fake_note_gateway.dart';
import '../../support/fake_pdf_source_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?>;
/// An item's file as a release holds it.
Map<String, Object?> item(
String id,
ByteData payload, {
String position = 'a0',
List<String>? tags,
String? kind,
}) => {
'id': id,
'position': position,
'tags': ?tags,
'kind': ?kind,
'payload': json(payload),
};
const imageAsset = '0192aaaa-0000-7000-8000-000000000001';
const bookAsset = '0192aaaa-0000-7000-8000-000000000002';
final image = Uint8List.fromList(utf8.encode('an image'));
final book = Uint8List.fromList(utf8.encode('%PDF-1.7 a book'));
void main() {
late StudysetRepository studysets;
late CardRepository cards;
late NoteRepository notes;
late LearningPathRepository paths;
late PdfSourceRepository pdfs;
late TagRepository tags;
late AssetRepository assets;
late ReleaseCopier copier;
late FakeTagGateway tagGateway;
setUp(() {
studysets = StudysetRepository(FakeStudysetGateway());
cards = CardRepository(FakeCardGateway());
notes = NoteRepository(FakeNoteGateway());
paths = LearningPathRepository(FakeLearningPathGateway());
tagGateway = FakeTagGateway();
tags = TagRepository(
tagGateway,
cardChanges: cards.changes,
noteChanges: notes.changes,
pathChanges: paths.changes,
);
assets = AssetRepository(
FakeAssetGateway(),
FakeAssetServerGateway(),
MemoryAssetFileCache(),
);
pdfs = PdfSourceRepository(FakePdfSourceGateway(), assets);
copier = ReleaseCopier(studysets, cards, notes, paths, pdfs, tags, assets);
});
CardContent card(String front, String back) => CardContent(
front: RichContent.plain(front),
back: RichContent.plain(back),
);
/// Release 3 of a Japanese studyset holding Kanji, as the instance gives
/// it out: cards in both (the second out of order, tagged; one showing an
/// image), a note, a path of two steps, and a PDF in Kanji.
PublishedRelease japanese() => PublishedRelease(
publicId: 'publication-1',
version: 3,
items: {
'studyset.json': item(
'japanese',
encodeStudysetPayload(
StudysetContent(
title: 'Japanese',
description: RichContent.plain('From N5 on.'),
license: 'CC-BY-SA-4.0',
licenseHolder: 'Alice',
),
),
),
'kanji/studyset.json': item(
'kanji',
encodeStudysetPayload(StudysetContent.titled('Kanji')),
position: 'a1',
),
'cards/second.json': item(
'second',
encodeCardPayload(card('犬', 'dog')),
position: 'a1',
tags: ['JLPT-N5', 'Noun'],
),
'cards/first.json': item(
'first',
encodeCardPayload(card('猫', 'cat')),
tags: ['JLPT-N5'],
),
'kanji/cards/pictured.json': item(
'pictured',
encodeCardPayload(
CardContent(
front: RichContent({
'document': {
'type': 'page',
'children': [
{
'type': 'image',
'data': {'url': assetUrl(imageAsset)},
},
],
},
}),
back: RichContent.plain('絵'),
),
),
),
'notes/particles.json': item(
'particles',
encodeNotePayload(
NoteContent(title: 'Particles', body: RichContent.plain('は')),
),
tags: ['Grammar'],
),
'paths/hiragana/path.json': item(
'hiragana',
encodeLearningPathPayload(LearningPathContent.titled('Hiragana')),
),
'paths/hiragana/steps/two.json': item(
'two',
encodeStepPayload(
StepContent.empty(StepKind.info).withField(StepField.title, 'い'),
),
position: 'a1',
kind: 'info',
),
'paths/hiragana/steps/one.json': item(
'one',
encodeStepPayload(
StepContent.empty(StepKind.info).withField(StepField.title, 'あ'),
),
kind: 'info',
),
'kanji/pdfs/book.json': item(
'book',
encodePdfSourcePayload(
const PdfSourceContent(
title: 'A book',
fileName: 'book.pdf',
assetId: bookAsset,
pageCount: 12,
),
),
),
'files.json': {
imageAsset: {
'sha256': 'image-hash',
'mimeType': 'image/png',
'size': image.length,
'payload': {'schemaVersion': 1, 'fileName': 'cat.png'},
},
bookAsset: {
'sha256': 'book-hash',
'mimeType': 'application/pdf',
'size': book.length,
'payload': {'schemaVersion': 1, 'fileName': 'book.pdf'},
},
},
},
files: const ['image-hash', 'book-hash'],
);
Future<Uint8List> fileOf(String sha256) async =>
sha256 == 'image-hash' ? image : book;
test("a copy is the person's own studysets, each item knowing where it "
'came from', () async {
final progress = <double>[];
final id = await copier.copy(
japanese(),
location: 'halkyon.eu/@alice/japanese',
fileOf: fileOf,
onProgress: progress.add,
);
final all = await studysets.all();
final root = all.singleWhere((studyset) => studyset.id == id);
expect(root.parentId, isNull);
expect(root.content.title, 'Japanese');
expect(root.content.license, 'CC-BY-SA-4.0');
expect(
root.content.published,
const PublishedRef(
publication: 'publication-1',
item: 'japanese',
version: 3,
),
);
expect(root.content.publishedAt, 'halkyon.eu/@alice/japanese');
final kanji = all.singleWhere((studyset) => studyset.parentId == id);
expect(kanji.content.published?.item, 'kanji');
expect(kanji.content.publishedAt, isNull);
expect(progress.last, 1);
});
test(
"its exam plans come along, their topics the copy's own studysets",
() async {
final release = japanese();
final withPlans = PublishedRelease(
publicId: release.publicId,
version: release.version,
items: {
...release.items,
'studyset.json': item(
'japanese',
encodeStudysetPayload(
StudysetContent.titled('Japanese').withExamPlans(const [
ExamPlan(
id: 'kanji-test',
name: 'Kanji test',
topics: ['kanji'],
tags: ['JLPT-N5'],
),
]),
),
),
},
files: release.files,
);
final id = await copier.copy(
withPlans,
location: 'halkyon.eu/@alice/japanese',
fileOf: fileOf,
onProgress: (_) {},
);
final all = await studysets.all();
final root = all.singleWhere((studyset) => studyset.id == id);
final kanji = all.singleWhere((studyset) => studyset.parentId == id);
expect(root.content.examPlans, [
ExamPlan(
id: 'kanji-test',
name: 'Kanji test',
topics: [kanji.id],
tags: const ['JLPT-N5'],
),
]);
expect(root.content.exam, isNull);
},
);
test("cards come in their order with their tags by name, the person's "
'own tag of a name taken', () async {
final noun = await tags.create('noun');
final id = await copier.copy(
japanese(),
location: 'halkyon.eu/@alice/japanese',
fileOf: fileOf,
);
final copied = await cards.cardsOf(id);
expect(
[for (final card in copied) card.content.front.plainText],
[
'猫',
'犬',
],
);
expect(
copied.first.content.published,
const PublishedRef(
publication: 'publication-1',
item: 'first',
version: 3,
),
);
final tagging = await tags.read();
expect(
[for (final tag in tagging.of(copied.last.id)) tag.name]..sort(),
['JLPT-N5', 'noun'],
);
expect(tagging.of(copied.last.id).map((tag) => tag.id), contains(noun));
});
test("a release's tags are written with one read of the tags for each "
'studyset, not one for each tag: a language has dozens, and each read '
"takes in every card's tags", () async {
final release = PublishedRelease(
publicId: 'publication-1',
version: 1,
items: {
'studyset.json': item(
'root',
encodeStudysetPayload(StudysetContent.titled('Words')),
),
for (var index = 0; index < 40; index++)
'cards/card-$index.json': item(
'card-$index',
encodeCardPayload(card('$index', 'number $index')),
position: 'a${index.toString().padLeft(2, '0')}',
tags: ['Tag $index', 'Numbers'],
),
},
);
final before = tagGateway.cardTagReads;
final id = await copier.copy(
release,
location: 'halkyon.eu/@alice/words',
fileOf: fileOf,
);
expect(tagGateway.cardTagReads - before, lessThanOrEqualTo(4));
final tagging = await tags.read();
final copied = await cards.cardsOf(id);
expect(
{
for (final card in copied)
for (final tag in tagging.of(card.id)) tag.name,
},
hasLength(41),
);
});
test("the files its content names become the person's own", () async {
final id = await copier.copy(
japanese(),
location: 'halkyon.eu/@alice/japanese',
fileOf: fileOf,
);
final kanji = (await studysets.all()).singleWhere(
(studyset) => studyset.parentId == id,
);
final pictured = (await cards.cardsOf(kanji.id)).single;
final assetId = pictured.content.front.assetIds.single;
expect(assetId, isNot(imageAsset));
expect((await assets.fileOf(assetId))!.bytes, image);
final pdf = (await pdfs.sourcesOf(kanji.id)).single;
expect(pdf.content.title, 'A book');
expect(pdf.content.pageCount, 12);
expect(pdf.content.published?.item, 'book');
expect((await assets.fileOf(pdf.content.assetId))!.bytes, book);
});
test("a card or note tied to a page of the release's PDF is tied to the "
'PDF made of it', () async {
final release = japanese();
final id = await copier.copy(
PublishedRelease(
publicId: release.publicId,
version: release.version,
items: {
...release.items,
'kanji/cards/page.json': item(
'page',
encodeCardPayload(
CardContent(
front: RichContent.plain('本'),
back: RichContent.plain('book'),
source: (pdfId: 'book', page: 2),
),
),
position: 'a5',
),
'kanji/notes/reading.json': item(
'reading',
encodeNotePayload(
NoteContent(
body: RichContent.plain('Chapter 1'),
source: (pdfId: 'book', page: 2),
),
),
),
},
files: release.files,
),
location: 'halkyon.eu/@alice/japanese',
fileOf: fileOf,
);
final kanji = (await studysets.all()).singleWhere(
(studyset) => studyset.parentId == id,
);
final pdf = (await pdfs.sourcesOf(kanji.id)).single;
final page = (await cards.cardsOf(
kanji.id,
)).singleWhere((card) => card.content.front.plainText == '本');
expect(page.content.source, (pdfId: pdf.id, page: 2));
expect(
(await notes.notesOf(kanji.id)).single.content.source,
(pdfId: pdf.id, page: 2),
);
});
test('notes and learning paths, steps in their order', () async {
final id = await copier.copy(
japanese(),
location: 'halkyon.eu/@alice/japanese',
fileOf: fileOf,
);
final note = (await notes.notesOf(id)).single;
expect(note.content.title, 'Particles');
expect(note.content.published?.item, 'particles');
expect(
[
for (final tag in (await tags.read()).ofNote(note.id)) tag.name,
],
['Grammar'],
);
final path = (await paths.pathsOf(id)).single;
expect(path.content.published?.item, 'hiragana');
expect(
[
for (final step in await paths.stepsOf(path.id)) step.content.title,
],
['あ', 'い'],
);
});
test("a release this app can't read all of is not copied at all", () async {
final newer = japanese();
final items = {
...newer.items,
'cards/future.json': {
'id': 'future',
'position': 'a2',
'payload': {'schemaVersion': cardSchemaVersion + 1, 'type': 'hologram'},
},
};
await expectLater(
copier.copy(
PublishedRelease(
publicId: newer.publicId,
version: newer.version,
items: items,
files: newer.files,
),
location: 'halkyon.eu/@alice/japanese',
fileOf: fileOf,
),
throwsA(isA<ReleaseUnreadable>()),
);
expect(await studysets.all(), isEmpty);
});
}

View file

@ -0,0 +1,131 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_client/halkyon_learn_client.dart' as protocol;
import 'package:halkyon_learn_flutter/src/feature/explore/data/explore_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/explore/data/serverpod_explore_gateway.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
const publicId = '01a0fc5b-0168-775a-aca8-9a36cdb471a4';
/// Release 2 of [publicId]: [count] JSON files, `cards/0000.json` on.
List<protocol.ReleaseItem> items(int count) => [
for (var index = 0; index < count; index++)
protocol.ReleaseItem(
path: 'cards/${index.toString().padLeft(4, '0')}.json',
json: jsonEncode({'id': 'card-$index'}),
),
];
void main() {
/// What the instance was asked, by method and arguments, in order.
late List<(String, Map<String, Object?>)> asked;
ServerpodExploreGateway gateway(
Future<http.Response> Function(String method, Map<String, Object?> args)
answer,
) {
asked = [];
final client = protocol.Client(
'https://learn.example.org/',
httpClientOverride: MockClient((request) {
final method = request.url.pathSegments.last;
final args = jsonDecode(request.body) as Map<String, Object?>;
asked.add((method, args));
return answer(method, args);
}),
);
addTearDown(client.close);
return ServerpodExploreGateway(client);
}
http.Response ok(Object answer) =>
http.Response(protocol.SerializationManager.encode(answer), 200);
/// The parts of a release of [total] files, [size] to a part.
http.Response partOf(Map<String, Object?> args, int total, int size) {
final offset = args['offset']! as int;
return ok(
protocol.ReleasePart(
publicId: protocol.UuidValue.withValidation(publicId),
version: 2,
total: total,
items: items(total).skip(offset).take(size).toList(),
files: ['a' * 64],
),
);
}
test('a release comes in parts, each from where the last ended and of '
'the version the first named, saying how far it has come', () async {
final fetched = <(int, int)>[];
final release = await gateway(
(method, args) async => partOf(args, 1200, 500),
).release(publicId, onFetched: (done, total) => fetched.add((done, total)));
expect(release.version, 2);
expect(release.items, hasLength(1200));
expect(release.items['cards/1199.json'], {'id': 'card-1199'});
expect(release.files, ['a' * 64]);
expect([for (final (_, args) in asked) args['offset']], [0, 500, 1000]);
expect([for (final (_, args) in asked) args['version']], [null, 2, 2]);
expect(fetched, [(500, 1200), (1000, 1200), (1200, 1200)]);
});
test('a part whose connection drops is asked for again, alone', () async {
var drops = 1;
final release = await gateway((method, args) async {
if (args['offset'] == 500 && drops-- > 0) {
throw const SocketException('Connection reset by peer');
}
return partOf(args, 900, 500);
}).release(publicId);
expect(release.items, hasLength(900));
expect([for (final (_, args) in asked) args['offset']], [0, 500, 500]);
});
test('an instance from before parts gives the release whole', () async {
final release = await gateway(
(method, args) async => switch (method) {
'releasePart' => http.Response('Method not found in endpoint', 400),
_ => ok(
protocol.ReleaseContent(
publicId: protocol.UuidValue.withValidation(publicId),
version: 1,
items: items(3),
files: const [],
),
),
},
).release(publicId);
expect(release.version, 1);
expect(release.items, hasLength(3));
expect(
[for (final (method, _) in asked) method],
[
'releasePart',
'release',
],
);
});
test('says whether the instance could not be reached or answered with an '
'error', () async {
await expectLater(
gateway(
(method, args) async => throw const SocketException('No route'),
).release(publicId),
throwsA(isA<InstanceUnreachable>()),
);
await expectLater(
gateway(
(method, args) async => http.Response('Internal server error', 500),
).release(publicId),
throwsA(isA<InstanceFailed>()),
);
});
}