chore: Halkyon Learn moves into apps/learn
Its server, client, app and curator, and the tools that write its assets (maps, Anki fixtures, icons), as they are; paths are fixed in the commits that follow. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
11abd9afce
commit
649499be23
5 changed files with 0 additions and 0 deletions
|
|
@ -0,0 +1,199 @@
|
|||
import 'dart:async';
|
||||
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/content/rich_content.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/learning_path/data/learning_path_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/learning_path.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/studyset/data/studyset_deletions.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 '../../../support/eventually.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/memory_asset_file_cache.dart';
|
||||
|
||||
void main() {
|
||||
late FakeStudysetGateway studysetGateway;
|
||||
late StudysetRepository studysets;
|
||||
late FakeCardGateway cardGateway;
|
||||
late CardRepository cards;
|
||||
late FakeNoteGateway noteGateway;
|
||||
late NoteRepository notes;
|
||||
late FakeLearningPathGateway pathGateway;
|
||||
late LearningPathRepository paths;
|
||||
late FakePdfSourceGateway pdfGateway;
|
||||
late AssetRepository assets;
|
||||
late PdfSourceRepository pdfs;
|
||||
late StudysetDeletions deletions;
|
||||
|
||||
setUp(() {
|
||||
studysetGateway = FakeStudysetGateway();
|
||||
studysets = StudysetRepository(studysetGateway);
|
||||
cardGateway = FakeCardGateway();
|
||||
cards = CardRepository(cardGateway);
|
||||
noteGateway = FakeNoteGateway();
|
||||
notes = NoteRepository(noteGateway);
|
||||
pathGateway = FakeLearningPathGateway();
|
||||
paths = LearningPathRepository(pathGateway);
|
||||
pdfGateway = FakePdfSourceGateway();
|
||||
assets = AssetRepository(
|
||||
FakeAssetGateway(),
|
||||
FakeAssetServerGateway(),
|
||||
MemoryAssetFileCache(),
|
||||
);
|
||||
pdfs = PdfSourceRepository(pdfGateway, assets);
|
||||
});
|
||||
tearDown(() async {
|
||||
await deletions.dispose();
|
||||
await studysets.dispose();
|
||||
await cards.dispose();
|
||||
await notes.dispose();
|
||||
await paths.dispose();
|
||||
await pdfs.dispose();
|
||||
await assets.dispose();
|
||||
});
|
||||
|
||||
void open() =>
|
||||
deletions = StudysetDeletions(studysets, cards, notes, paths, pdfs);
|
||||
|
||||
Future<String> card(String studysetId) => cards.create(
|
||||
studysetId,
|
||||
CardContent(front: RichContent.empty(), back: RichContent.empty()),
|
||||
);
|
||||
|
||||
final deletedAt = DateTime.utc(2026, 10, 12, 9);
|
||||
|
||||
/// Deletes the studyset [id] at [deletedAt].
|
||||
Future<void> deleteAt(String id) =>
|
||||
withClock(Clock.fixed(deletedAt), () => deletions.delete(id));
|
||||
|
||||
/// Checks for expired deletions [after] the deletion.
|
||||
Future<void> checkAfter(Duration after) => withClock(
|
||||
Clock.fixed(deletedAt.add(after)),
|
||||
deletions.deleteExpired,
|
||||
);
|
||||
|
||||
test('a deleted studyset waits 14 days, restorable, and is then deleted '
|
||||
'for good with its whole subtree, what is inside first, each with its '
|
||||
'cards, notes, paths and PDFs blanked', () async {
|
||||
open();
|
||||
final japanese = await studysets.create(StudysetContent.titled('Japanese'));
|
||||
final kana = await studysets.create(
|
||||
StudysetContent.titled('Kana'),
|
||||
parentId: japanese,
|
||||
);
|
||||
final kanaCard = await card(kana);
|
||||
final kanaNote = await notes.create(
|
||||
kana,
|
||||
NoteContent(body: RichContent.empty()),
|
||||
);
|
||||
final kanaPath = await paths.createPath(
|
||||
kana,
|
||||
LearningPathContent.titled('Hiragana'),
|
||||
);
|
||||
final kanaPdf = (await pdfs.import(
|
||||
kana,
|
||||
bytes: Uint8List.fromList(utf8.encode('%PDF-1.7 kana')),
|
||||
fileName: 'kana.pdf',
|
||||
) as PdfImported).id;
|
||||
|
||||
await deleteAt(japanese);
|
||||
expect(await studysets.all(), isEmpty);
|
||||
await checkAfter(const Duration(days: 13, hours: 23));
|
||||
expect(studysetGateway.deleted, isEmpty);
|
||||
expect(cardGateway.deletions, isEmpty);
|
||||
|
||||
await checkAfter(const Duration(days: 14));
|
||||
expect(studysetGateway.deleted, [kana, japanese]);
|
||||
expect(cardGateway.deletions, ['blank $kanaCard', 'delete $kanaCard']);
|
||||
expect(noteGateway.deletions, [
|
||||
'blank ${kanaNote.noteId}',
|
||||
'delete ${kanaNote.noteId}',
|
||||
]);
|
||||
expect(pathGateway.deletions, [
|
||||
'blank path $kanaPath',
|
||||
'delete path $kanaPath',
|
||||
]);
|
||||
expect(pdfGateway.deletions, ['blank $kanaPdf', 'delete $kanaPdf']);
|
||||
expect(await studysets.withDeleted(), isEmpty);
|
||||
});
|
||||
|
||||
test('a studyset restored in time stays', () async {
|
||||
open();
|
||||
final japanese = await studysets.create(StudysetContent.titled('Japanese'));
|
||||
|
||||
await deleteAt(japanese);
|
||||
await deletions.restore(japanese);
|
||||
await checkAfter(const Duration(days: 30));
|
||||
|
||||
expect(studysetGateway.deleted, isEmpty);
|
||||
expect(await studysets.all(), hasLength(1));
|
||||
});
|
||||
|
||||
test('Delete now deletes for good at once', () async {
|
||||
open();
|
||||
final japanese = await studysets.create(StudysetContent.titled('Japanese'));
|
||||
final greek = await studysets.create(StudysetContent.titled('Greek'));
|
||||
|
||||
await deleteAt(japanese);
|
||||
await deletions.deleteForGood(japanese);
|
||||
|
||||
expect(studysetGateway.deleted, [japanese]);
|
||||
expect([for (final s in await studysets.withDeleted()) s.id], [greek]);
|
||||
});
|
||||
|
||||
test('a studyset deleted inside one deleted later goes on its own '
|
||||
'clock, and with the other at the latest', () async {
|
||||
open();
|
||||
final japanese = await studysets.create(StudysetContent.titled('Japanese'));
|
||||
final kana = await studysets.create(
|
||||
StudysetContent.titled('Kana'),
|
||||
parentId: japanese,
|
||||
);
|
||||
|
||||
await deleteAt(kana);
|
||||
await withClock(
|
||||
Clock.fixed(deletedAt.add(const Duration(days: 3))),
|
||||
() => deletions.delete(japanese),
|
||||
);
|
||||
await checkAfter(const Duration(days: 14));
|
||||
expect(studysetGateway.deleted, isEmpty);
|
||||
await checkAfter(const Duration(days: 17));
|
||||
expect(studysetGateway.deleted, [kana, japanese]);
|
||||
});
|
||||
|
||||
test('checks as it starts and whenever told to, as after a sync', () async {
|
||||
open();
|
||||
final japanese = await studysets.create(StudysetContent.titled('Japanese'));
|
||||
await deleteAt(japanese);
|
||||
final syncs = StreamController<void>();
|
||||
addTearDown(syncs.close);
|
||||
|
||||
var now = deletedAt.add(const Duration(days: 1));
|
||||
await withClock(Clock(() => now), () async {
|
||||
deletions.deleteExpiredOn(syncs.stream);
|
||||
await pumpEventQueue();
|
||||
expect(studysetGateway.deleted, isEmpty);
|
||||
|
||||
now = deletedAt.add(const Duration(days: 15));
|
||||
syncs.add(null);
|
||||
await eventually(() => studysetGateway.deleted.isNotEmpty);
|
||||
});
|
||||
expect(studysetGateway.deleted, [japanese]);
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,254 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/citation/model/source.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/content/payload.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/model/studyset_study_options.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_payload.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';
|
||||
|
||||
void main() {
|
||||
ByteData bytes(Object json) =>
|
||||
ByteData.sublistView(utf8.encode(jsonEncode(json)));
|
||||
|
||||
Map<String, Object?> json(ByteData payload) =>
|
||||
jsonDecode(utf8.decode(Uint8List.sublistView(payload)))
|
||||
as Map<String, Object?>;
|
||||
|
||||
final description = RichContent({
|
||||
'document': {
|
||||
'type': 'page',
|
||||
'children': [
|
||||
{
|
||||
'type': 'paragraph',
|
||||
'data': {
|
||||
'delta': [
|
||||
{'insert': 'ひらがな'},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
group('The studyset payload', () {
|
||||
test('survives a round trip with everything set, Japanese included', () {
|
||||
final content = StudysetContent(
|
||||
title: '日本語',
|
||||
description: description,
|
||||
secondaryTitle: 'JLPT N5',
|
||||
colorName: 'cobalt',
|
||||
license: 'CC-BY-SA-4.0',
|
||||
licenseHolder: 'Tae Kim',
|
||||
licenseYear: 2006,
|
||||
frontLanguage: 'ja',
|
||||
backLanguage: 'en',
|
||||
);
|
||||
|
||||
expect(decodeStudysetPayload(encodeStudysetPayload(content)), content);
|
||||
});
|
||||
|
||||
test('leaves out what is not given', () {
|
||||
final payload = encodeStudysetPayload(
|
||||
StudysetContent(title: 'Japanese', description: RichContent.empty()),
|
||||
);
|
||||
|
||||
expect(json(payload).keys, {'schemaVersion', 'title', 'description'});
|
||||
});
|
||||
|
||||
test('names its schema version: the seventh, which exam plans need', () {
|
||||
final payload = encodeStudysetPayload(
|
||||
StudysetContent(
|
||||
title: 'Japanese',
|
||||
description: RichContent.empty(),
|
||||
bibliography: const [Source(title: 'A Guide')],
|
||||
),
|
||||
);
|
||||
|
||||
expect(studysetSchemaVersion, 7);
|
||||
expect(json(payload)['schemaVersion'], studysetSchemaVersion);
|
||||
});
|
||||
|
||||
test('without study options, a bibliography or exam plans writes the '
|
||||
'seventh schema all the same', () {
|
||||
final payload = encodeStudysetPayload(
|
||||
StudysetContent(title: 'Japanese', description: RichContent.empty()),
|
||||
);
|
||||
|
||||
expect(json(payload)['schemaVersion'], 7);
|
||||
expect(json(payload).keys, isNot(contains('study')));
|
||||
expect(json(payload).keys, isNot(contains('bibliography')));
|
||||
expect(json(payload).keys, isNot(contains('examPlans')));
|
||||
expect(json(payload).keys, isNot(contains('exam')));
|
||||
});
|
||||
|
||||
test('keeps the exam plans in their order, and the exam chosen', () {
|
||||
final content = StudysetContent(
|
||||
title: 'Japanese',
|
||||
description: description,
|
||||
examPlans: const [
|
||||
ExamPlan(id: 'final', name: 'Final exam', topics: ['kana', 'verbs']),
|
||||
ExamPlan(id: 'mock', name: 'Mock test', tags: ['JLPT-N5']),
|
||||
],
|
||||
exam: ExamChoice(plan: 'final', date: DateTime(2026, 12, 6)),
|
||||
);
|
||||
final payload = encodeStudysetPayload(content);
|
||||
|
||||
expect(decodeStudysetPayload(payload), content);
|
||||
expect(json(payload)['exam'], {'plan': 'final', 'date': '2026-12-06'});
|
||||
});
|
||||
|
||||
test('leaves out exam plans and an exam it cannot read', () {
|
||||
final content = decodeStudysetPayload(
|
||||
bytes({
|
||||
'schemaVersion': 7,
|
||||
'title': 'Japanese',
|
||||
'description': description.toJson(),
|
||||
'examPlans': [
|
||||
{'name': 'No id'},
|
||||
{
|
||||
'id': 'mock',
|
||||
'name': 'Mock test',
|
||||
'tags': ['JLPT-N5', 3],
|
||||
},
|
||||
],
|
||||
'exam': {'plan': 'mock', 'date': 'soon'},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(content.examPlans, const [
|
||||
ExamPlan(id: 'mock', name: 'Mock test', tags: ['JLPT-N5']),
|
||||
]);
|
||||
expect(content.exam, isNull);
|
||||
});
|
||||
|
||||
test('keeps the bibliography in its order', () {
|
||||
final content = StudysetContent(
|
||||
title: 'Japanese',
|
||||
description: description,
|
||||
bibliography: const [
|
||||
Source(
|
||||
authors: 'Tae Kim',
|
||||
year: '2014',
|
||||
title: 'A Guide to Japanese Grammar',
|
||||
isbn: '978-1-4499-5422-3',
|
||||
),
|
||||
Source(raw: 'Banno, E. (2020). Genki I. The Japan Times.'),
|
||||
],
|
||||
);
|
||||
|
||||
expect(decodeStudysetPayload(encodeStudysetPayload(content)), content);
|
||||
});
|
||||
|
||||
test('refuses a malformed bibliography', () {
|
||||
for (final bibliography in [
|
||||
'Kim 2014',
|
||||
['Kim 2014'],
|
||||
[
|
||||
{'title': false},
|
||||
],
|
||||
]) {
|
||||
expect(
|
||||
() => decodeStudysetPayload(
|
||||
bytes({
|
||||
'schemaVersion': 4,
|
||||
'title': 'x',
|
||||
'description': RichContent.empty().toJson(),
|
||||
'bibliography': bibliography,
|
||||
}),
|
||||
),
|
||||
throwsFormatException,
|
||||
reason: '$bibliography',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('keeps the study options', () {
|
||||
final content = StudysetContent(
|
||||
title: 'Japanese',
|
||||
description: description,
|
||||
studyOptions: const StudysetStudyOptions(
|
||||
newPerDay: 0,
|
||||
reviewsPerDay: 150,
|
||||
order: NewCardOrder.mixed,
|
||||
),
|
||||
);
|
||||
|
||||
expect(decodeStudysetPayload(encodeStudysetPayload(content)), content);
|
||||
});
|
||||
|
||||
test('reads an order it does not know as reviews first', () {
|
||||
final payload = bytes({
|
||||
'schemaVersion': 3,
|
||||
'title': 'Japanese',
|
||||
'description': description.toJson(),
|
||||
'study': {'newPerDay': 5, 'reviewsPerDay': 0, 'order': 'sideways'},
|
||||
});
|
||||
|
||||
expect(
|
||||
decodeStudysetPayload(payload).studyOptions,
|
||||
const StudysetStudyOptions(newPerDay: 5),
|
||||
);
|
||||
});
|
||||
|
||||
test('refuses malformed study options', () {
|
||||
final payload = bytes({
|
||||
'schemaVersion': 3,
|
||||
'title': 'Japanese',
|
||||
'description': description.toJson(),
|
||||
'study': {'newPerDay': 'many'},
|
||||
});
|
||||
|
||||
expect(() => decodeStudysetPayload(payload), throwsFormatException);
|
||||
});
|
||||
|
||||
test('reads the first schema, which had only a title and a '
|
||||
'description', () {
|
||||
final payload = bytes({
|
||||
'schemaVersion': 1,
|
||||
'title': 'Japanese',
|
||||
'description': description.toJson(),
|
||||
});
|
||||
|
||||
expect(
|
||||
decodeStudysetPayload(payload),
|
||||
StudysetContent(title: 'Japanese', description: description),
|
||||
);
|
||||
});
|
||||
|
||||
test('refuses a field of the wrong kind', () {
|
||||
final payload = bytes({
|
||||
'schemaVersion': 2,
|
||||
'title': 'Japanese',
|
||||
'description': description.toJson(),
|
||||
'licenseYear': 'long ago',
|
||||
});
|
||||
|
||||
expect(() => decodeStudysetPayload(payload), throwsFormatException);
|
||||
});
|
||||
|
||||
test('refuses a schema newer than this app knows', () {
|
||||
final payload = bytes({
|
||||
'schemaVersion': studysetSchemaVersion + 1,
|
||||
'title': 'From the future',
|
||||
'description': RichContent.empty().toJson(),
|
||||
});
|
||||
|
||||
expect(() => decodeStudysetPayload(payload), throwsFormatException);
|
||||
});
|
||||
|
||||
test('refuses what is not a studyset payload', () {
|
||||
expect(
|
||||
() => decodeStudysetPayload(redactedPayload),
|
||||
throwsFormatException,
|
||||
);
|
||||
expect(
|
||||
() => decodeStudysetPayload(bytes({'title': 'no version'})),
|
||||
throwsFormatException,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,148 @@
|
|||
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/content/rich_content.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/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/settings/data/settings_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/study/model/study_progress.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_progress_repository.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 '../../../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_review_gateway.dart';
|
||||
import '../../../support/fake_settings_gateway.dart';
|
||||
import '../../../support/fake_studyset_gateway.dart';
|
||||
import '../../../support/memory_asset_file_cache.dart';
|
||||
|
||||
void main() {
|
||||
late StudysetRepository studysets;
|
||||
late CardRepository cards;
|
||||
late ReviewRepository reviews;
|
||||
late StudysetProgressRepository progress;
|
||||
|
||||
setUp(() {
|
||||
studysets = StudysetRepository(FakeStudysetGateway());
|
||||
cards = CardRepository(FakeCardGateway());
|
||||
reviews = ReviewRepository(FakeReviewGateway());
|
||||
final notes = NoteRepository(FakeNoteGateway());
|
||||
final paths = LearningPathRepository(FakeLearningPathGateway());
|
||||
final settings = SettingsRepository(FakeSettingsGateway());
|
||||
final pdfs = PdfSourceRepository(
|
||||
FakePdfSourceGateway(),
|
||||
AssetRepository(
|
||||
FakeAssetGateway(),
|
||||
FakeAssetServerGateway(),
|
||||
MemoryAssetFileCache(),
|
||||
),
|
||||
);
|
||||
progress = StudysetProgressRepository(
|
||||
studysets: studysets,
|
||||
cards: cards,
|
||||
notes: notes,
|
||||
paths: paths,
|
||||
pdfs: pdfs,
|
||||
reviews: reviews,
|
||||
settings: settings,
|
||||
);
|
||||
addTearDown(() async {
|
||||
await progress.dispose();
|
||||
await studysets.dispose();
|
||||
await cards.dispose();
|
||||
await reviews.dispose();
|
||||
await notes.dispose();
|
||||
await paths.dispose();
|
||||
await settings.dispose();
|
||||
await pdfs.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
Future<String> card(String studysetId) => cards.create(
|
||||
studysetId,
|
||||
CardContent(front: RichContent.plain('猫'), back: RichContent.plain('cat')),
|
||||
);
|
||||
|
||||
/// Everything [progress] hands one following it, as it comes.
|
||||
List<Map<String, StudyProgress>> follow() {
|
||||
final seen = <Map<String, StudyProgress>>[];
|
||||
final subscription = progress.watch().listen(seen.add);
|
||||
addTearDown(subscription.cancel);
|
||||
return seen;
|
||||
}
|
||||
|
||||
test('works out every studyset with what is inside it, once for all '
|
||||
'following it; one following later gets it at once, worked out '
|
||||
'no more', () async {
|
||||
final japanese = await studysets.create(StudysetContent.titled('Japanese'));
|
||||
final kana = await studysets.create(
|
||||
StudysetContent.titled('Kana'),
|
||||
parentId: japanese,
|
||||
);
|
||||
await card(japanese);
|
||||
await card(kana);
|
||||
|
||||
final first = follow();
|
||||
await pumpEventQueue();
|
||||
expect(first, hasLength(1));
|
||||
expect(first.single[japanese]?.cards, 2);
|
||||
expect(first.single[kana]?.cards, 1);
|
||||
|
||||
final second = follow();
|
||||
await pumpEventQueue();
|
||||
expect(second, [first.single]);
|
||||
expect(first, hasLength(1), reason: 'nothing changed');
|
||||
});
|
||||
|
||||
test('works it out again after a change of cards or reviews, but not when '
|
||||
'the same studysets are handed again', () async {
|
||||
final japanese = await studysets.create(StudysetContent.titled('Japanese'));
|
||||
final neko = await card(japanese);
|
||||
final seen = follow();
|
||||
await pumpEventQueue();
|
||||
expect(seen.last[japanese]?.due, 1);
|
||||
|
||||
await studysets.reread();
|
||||
await pumpEventQueue();
|
||||
expect(seen, hasLength(1));
|
||||
|
||||
await reviews.record(
|
||||
cardId: neko,
|
||||
rating: Rating.easy,
|
||||
reviewedAt: clock.now(),
|
||||
duration: const Duration(seconds: 2),
|
||||
);
|
||||
await pumpEventQueue();
|
||||
expect(seen.last[japanese]?.due, 0);
|
||||
|
||||
await card(japanese);
|
||||
await pumpEventQueue();
|
||||
expect(seen.last[japanese]?.cards, 2);
|
||||
});
|
||||
|
||||
test('one following it again after nobody did gets it as things are '
|
||||
'now', () async {
|
||||
final japanese = await studysets.create(StudysetContent.titled('Japanese'));
|
||||
final library = progress.watch().listen((_) {});
|
||||
await pumpEventQueue();
|
||||
await library.cancel();
|
||||
|
||||
await card(japanese);
|
||||
await pumpEventQueue();
|
||||
|
||||
final seen = follow();
|
||||
await pumpEventQueue();
|
||||
expect(seen, hasLength(1));
|
||||
expect(seen.single[japanese]?.cards, 1);
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,373 @@
|
|||
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/content/payload.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/content/rich_content.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.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 '../../../support/fake_studyset_gateway.dart';
|
||||
|
||||
void main() {
|
||||
late FakeStudysetGateway gateway;
|
||||
late StudysetRepository repository;
|
||||
|
||||
setUp(() {
|
||||
gateway = FakeStudysetGateway();
|
||||
repository = StudysetRepository(gateway);
|
||||
});
|
||||
tearDown(() => repository.dispose());
|
||||
|
||||
StudysetContent titled(String title) =>
|
||||
StudysetContent(title: title, description: RichContent.empty());
|
||||
|
||||
Iterable<String> titles(List<Studyset> studysets) =>
|
||||
studysets.map((studyset) => studyset.content.title);
|
||||
|
||||
test('lists what is stored, then every change', () async {
|
||||
await repository.create(titled('Japanese'));
|
||||
final lists = repository.watchAll();
|
||||
|
||||
final expectation = expectLater(
|
||||
lists.map(titles),
|
||||
emitsInOrder([
|
||||
['Japanese'],
|
||||
['Japanese', 'Kanji'],
|
||||
]),
|
||||
);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
await repository.create(titled('Kanji'));
|
||||
await expectation;
|
||||
});
|
||||
|
||||
test('one more following them gets the studysets as last read at once, '
|
||||
'read no more and handed to nobody else again', () async {
|
||||
await repository.create(titled('Japanese'));
|
||||
final first = <List<Studyset>>[];
|
||||
final library = repository.watchAll().listen(first.add);
|
||||
addTearDown(library.cancel);
|
||||
await pumpEventQueue();
|
||||
final reads = gateway.reads;
|
||||
|
||||
final second = <List<Studyset>>[];
|
||||
final screen = repository.watchAll().listen(second.add);
|
||||
addTearDown(screen.cancel);
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(second.map(titles), [
|
||||
['Japanese'],
|
||||
]);
|
||||
expect(first, hasLength(1));
|
||||
expect(gateway.reads, reads);
|
||||
});
|
||||
|
||||
test('reads the studysets on this device once', () async {
|
||||
await repository.create(titled('Japanese'));
|
||||
await repository.create(titled('Kanji'));
|
||||
|
||||
expect(titles(await repository.all()), ['Japanese', 'Kanji']);
|
||||
});
|
||||
|
||||
test('creates studysets at the top level, neither starred nor '
|
||||
'archived', () async {
|
||||
final content = StudysetContent(
|
||||
title: 'Japanese',
|
||||
description: RichContent.empty(),
|
||||
secondaryTitle: 'JLPT N5',
|
||||
colorName: 'cobalt',
|
||||
);
|
||||
|
||||
final createdAt = DateTime.utc(2026, 10, 1, 8);
|
||||
final id = await withClock(
|
||||
Clock.fixed(createdAt),
|
||||
() => repository.create(content),
|
||||
);
|
||||
|
||||
expect(await repository.watchAll().first, [
|
||||
Studyset(id: id, content: content, updatedAt: createdAt),
|
||||
]);
|
||||
});
|
||||
|
||||
test('stores changed content', () async {
|
||||
final changedAt = DateTime.utc(2026, 10, 1, 9);
|
||||
final id = await repository.create(titled('Japanese'));
|
||||
final changed = StudysetContent(
|
||||
title: '日本語',
|
||||
description: RichContent({
|
||||
'document': {'type': 'page'},
|
||||
'note': 'changed',
|
||||
}),
|
||||
licenseYear: 2026,
|
||||
);
|
||||
|
||||
await withClock(
|
||||
Clock.fixed(changedAt),
|
||||
() => repository.updateContent(id, changed),
|
||||
);
|
||||
|
||||
expect(await repository.watchAll().first, [
|
||||
Studyset(id: id, content: changed, updatedAt: changedAt),
|
||||
]);
|
||||
});
|
||||
|
||||
test('stars and unstars, writing nothing but the star', () async {
|
||||
final starredAt = DateTime.utc(2026, 10, 1, 9);
|
||||
final id = await repository.create(titled('Japanese'));
|
||||
|
||||
await withClock(Clock.fixed(starredAt), () => repository.star(id));
|
||||
expect((await repository.all()).single.starredAt, starredAt);
|
||||
|
||||
await repository.unstar(id);
|
||||
expect((await repository.all()).single.isStarred, isFalse);
|
||||
expect(gateway.writes, hasLength(1), reason: 'the payload only once');
|
||||
});
|
||||
|
||||
test('archives and unarchives, writing nothing but the archive '
|
||||
'date', () async {
|
||||
final archivedAt = DateTime.utc(2026, 10, 1, 9);
|
||||
final id = await repository.create(titled('Japanese'));
|
||||
|
||||
await withClock(Clock.fixed(archivedAt), () => repository.archive(id));
|
||||
expect((await repository.all()).single.archivedAt, archivedAt);
|
||||
|
||||
await repository.unarchive(id);
|
||||
expect((await repository.all()).single.isArchived, isFalse);
|
||||
expect(gateway.writes, hasLength(1), reason: 'the payload only once');
|
||||
});
|
||||
|
||||
test('every change stamps when it happened', () async {
|
||||
DateTime at(int hour) => DateTime.utc(2026, 10, 1, hour);
|
||||
Future<DateTime?> updatedAt() async =>
|
||||
(await repository.all()).single.updatedAt;
|
||||
|
||||
final id = await withClock(
|
||||
Clock.fixed(at(8)),
|
||||
() => repository.create(titled('Japanese')),
|
||||
);
|
||||
expect(await updatedAt(), at(8));
|
||||
|
||||
await withClock(
|
||||
Clock.fixed(at(9)),
|
||||
() => repository.updateContent(id, titled('日本語')),
|
||||
);
|
||||
expect(await updatedAt(), at(9));
|
||||
|
||||
await withClock(Clock.fixed(at(10)), () => repository.star(id));
|
||||
expect(await updatedAt(), at(10));
|
||||
|
||||
await withClock(Clock.fixed(at(11)), () => repository.archive(id));
|
||||
expect(await updatedAt(), at(11));
|
||||
});
|
||||
|
||||
group('in the tree', () {
|
||||
Future<List<String>> childrenTitles(String parentId) async => [
|
||||
for (final child in childrenOf(parentId, await repository.all()))
|
||||
child.content.title,
|
||||
];
|
||||
|
||||
test('creates a sub-studyset after its siblings', () async {
|
||||
final japanese = await repository.create(titled('Japanese'));
|
||||
await repository.create(titled('Kana'), parentId: japanese);
|
||||
await repository.create(titled('Kanji'), parentId: japanese);
|
||||
|
||||
expect(await childrenTitles(japanese), ['Kana', 'Kanji']);
|
||||
});
|
||||
|
||||
test('moves a studyset into another, after its children, writing only '
|
||||
'its place', () async {
|
||||
final japanese = await repository.create(titled('Japanese'));
|
||||
await repository.create(titled('Kana'), parentId: japanese);
|
||||
final kanji = await repository.create(titled('Kanji'));
|
||||
final payloadWrites = gateway.writes.length;
|
||||
|
||||
await repository.move(kanji, parentId: japanese);
|
||||
|
||||
expect(await childrenTitles(japanese), ['Kana', 'Kanji']);
|
||||
expect(gateway.writes, hasLength(payloadWrites));
|
||||
});
|
||||
|
||||
test('moves a studyset to the top level', () async {
|
||||
final japanese = await repository.create(titled('Japanese'));
|
||||
final kana = await repository.create(titled('Kana'), parentId: japanese);
|
||||
|
||||
await repository.move(kana, parentId: null);
|
||||
|
||||
expect(
|
||||
titles(topLevel(await repository.all())),
|
||||
unorderedEquals(['Japanese', 'Kana']),
|
||||
);
|
||||
});
|
||||
|
||||
test(
|
||||
'refuses to move a studyset into itself or anything inside it',
|
||||
() async {
|
||||
final japanese = await repository.create(titled('Japanese'));
|
||||
final kana = await repository.create(
|
||||
titled('Kana'),
|
||||
parentId: japanese,
|
||||
);
|
||||
|
||||
await expectLater(
|
||||
repository.move(japanese, parentId: japanese),
|
||||
throwsArgumentError,
|
||||
);
|
||||
await expectLater(
|
||||
repository.move(japanese, parentId: kana),
|
||||
throwsArgumentError,
|
||||
);
|
||||
expect(await childrenTitles(japanese), ['Kana']);
|
||||
},
|
||||
);
|
||||
|
||||
test('reorders a studyset among its siblings', () async {
|
||||
final japanese = await repository.create(titled('Japanese'));
|
||||
await repository.create(titled('Kana'), parentId: japanese);
|
||||
await repository.create(titled('Kanji'), parentId: japanese);
|
||||
final grammar = await repository.create(
|
||||
titled('Grammar'),
|
||||
parentId: japanese,
|
||||
);
|
||||
|
||||
await repository.reorder(grammar, toIndex: 0);
|
||||
|
||||
expect(await childrenTitles(japanese), ['Grammar', 'Kana', 'Kanji']);
|
||||
});
|
||||
|
||||
test('studysets from before they had an order, all at one position, '
|
||||
'make room for one moved among them; those whose deletion waits '
|
||||
'count for nothing', () async {
|
||||
final japanese = await repository.create(titled('Japanese'));
|
||||
for (final (id, title) in [
|
||||
('s1', 'A'),
|
||||
('s2', 'B'),
|
||||
('s3', 'C'),
|
||||
('s4', 'D'),
|
||||
]) {
|
||||
gateway.receiveRemoteChange(
|
||||
id,
|
||||
encodeStudysetPayload(titled(title)),
|
||||
parentId: japanese,
|
||||
);
|
||||
}
|
||||
|
||||
await repository.reorder('s4', toIndex: 1);
|
||||
expect(await childrenTitles(japanese), ['A', 'D', 'B', 'C']);
|
||||
|
||||
// B waits in Recently deleted: the list shows A, D, C, and A goes
|
||||
// after C; restored, B is back where it was.
|
||||
await repository.markDeleted('s2');
|
||||
await repository.reorder('s1', toIndex: 2);
|
||||
expect(await childrenTitles(japanese), ['D', 'C', 'A']);
|
||||
await repository.restore('s2');
|
||||
expect(await childrenTitles(japanese), ['D', 'B', 'C', 'A']);
|
||||
});
|
||||
});
|
||||
|
||||
test('a deleted studyset waits in Recently deleted with everything '
|
||||
'inside it, out of every other list, until restored', () async {
|
||||
final japanese = await repository.create(titled('Japanese'));
|
||||
final kana = await repository.create(titled('Kana'), parentId: japanese);
|
||||
final greek = await repository.create(titled('Greek'));
|
||||
final deleted = <List<String>>[];
|
||||
final live = <List<String>>[];
|
||||
final subscriptions = [
|
||||
repository.watchDeleted().listen(
|
||||
(studysets) => deleted.add([for (final s in studysets) s.id]),
|
||||
),
|
||||
repository.watchAll().listen(
|
||||
(studysets) => live.add([for (final s in studysets) s.id]),
|
||||
),
|
||||
];
|
||||
addTearDown(() => Future.wait([for (final s in subscriptions) s.cancel()]));
|
||||
|
||||
await withClock(Clock.fixed(DateTime.utc(2026, 10, 12)), () async {
|
||||
await repository.markDeleted(kana);
|
||||
await repository.markDeleted(japanese);
|
||||
});
|
||||
await pumpEventQueue();
|
||||
expect([for (final s in await repository.all()) s.id], [greek]);
|
||||
expect(live.last, [greek]);
|
||||
// Kana waits inside Japanese, which brings it back.
|
||||
expect(deleted.last, [japanese]);
|
||||
expect(
|
||||
(await repository.withDeleted())
|
||||
.firstWhere((s) => s.id == japanese)
|
||||
.deletedAt,
|
||||
DateTime.utc(2026, 10, 12),
|
||||
);
|
||||
|
||||
await repository.restore(japanese);
|
||||
await pumpEventQueue();
|
||||
expect(live.last, [japanese, greek]);
|
||||
expect(deleted.last, [kana]);
|
||||
});
|
||||
|
||||
test('blanks the content before deleting', () async {
|
||||
final id = await repository.create(titled('Secret'));
|
||||
|
||||
await repository.delete(id);
|
||||
|
||||
expect(gateway.writes.last, (id, redactedPayload));
|
||||
expect(gateway.deleted, [id]);
|
||||
expect(await repository.watchAll().first, isEmpty);
|
||||
});
|
||||
|
||||
test('shows studysets that arrive from other devices', () async {
|
||||
final lists = repository.watchAll();
|
||||
final expectation = expectLater(
|
||||
lists.map(titles),
|
||||
emitsInOrder([
|
||||
<String>[],
|
||||
['From the laptop'],
|
||||
]),
|
||||
);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
gateway.receiveRemoteChange(
|
||||
'laptop-1',
|
||||
encodeStudysetPayload(titled('From the laptop')),
|
||||
);
|
||||
await expectation;
|
||||
});
|
||||
|
||||
test('reports a failed read and recovers with the next change', () async {
|
||||
await repository.create(titled('Japanese'));
|
||||
gateway.failReads = Exception('Disk unavailable');
|
||||
final lists = repository.watchAll();
|
||||
final expectation = expectLater(
|
||||
lists.map(titles),
|
||||
emitsInOrder([
|
||||
emitsError(isException),
|
||||
['Japanese', 'Kanji'],
|
||||
]),
|
||||
);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
gateway.failReads = null;
|
||||
await repository.create(titled('Kanji'));
|
||||
await expectation;
|
||||
});
|
||||
|
||||
test('skips studysets this app version cannot read', () async {
|
||||
await repository.create(titled('Readable'));
|
||||
gateway.receiveRemoteChange(
|
||||
'future-1',
|
||||
ByteData.sublistView(
|
||||
utf8.encode(
|
||||
jsonEncode({
|
||||
'schemaVersion': studysetSchemaVersion + 1,
|
||||
'title': 'From a newer app',
|
||||
'description': RichContent.empty().toJson(),
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(titles(await repository.watchAll().first), ['Readable']);
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_tabs.dart';
|
||||
|
||||
import '../../../support/memory_studyset_tab_store.dart';
|
||||
|
||||
void main() {
|
||||
late Directory folder;
|
||||
|
||||
setUp(() async {
|
||||
folder = await Directory.systemTemp.createTemp('studyset_tabs');
|
||||
});
|
||||
tearDown(() => folder.delete(recursive: true));
|
||||
|
||||
test('keeps the tab each studyset was left on, in a file that a new '
|
||||
'session reads again', () async {
|
||||
final file = File('${folder.path}/studyset_tabs.json');
|
||||
final tabs = StudysetTabs(FileStudysetTabStore(file));
|
||||
await tabs.open();
|
||||
expect(tabs.of('japanese'), isNull);
|
||||
|
||||
await tabs.keep('japanese', 'paths');
|
||||
await tabs.keep('spanish', 'notes');
|
||||
await tabs.keep('japanese', 'flashcards');
|
||||
|
||||
final later = StudysetTabs(FileStudysetTabStore(file));
|
||||
await later.open();
|
||||
expect(later.of('japanese'), 'flashcards');
|
||||
expect(later.of('spanish'), 'notes');
|
||||
});
|
||||
|
||||
test('reads an unreadable file as no tabs kept', () async {
|
||||
final file = File('${folder.path}/studyset_tabs.json')
|
||||
..writeAsStringSync('{not json');
|
||||
final tabs = StudysetTabs(FileStudysetTabStore(file));
|
||||
await tabs.open();
|
||||
|
||||
expect(tabs.of('japanese'), isNull);
|
||||
});
|
||||
|
||||
test('writes only when the tab changes', () async {
|
||||
final kept = {'japanese': 'paths'};
|
||||
final store = MemoryStudysetTabStore(kept);
|
||||
final tabs = StudysetTabs(store);
|
||||
await tabs.open();
|
||||
|
||||
await tabs.keep('japanese', 'paths');
|
||||
expect(store.kept, same(kept));
|
||||
await tabs.keep('japanese', 'notes');
|
||||
expect(store.kept, {'japanese': 'notes'});
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue