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
2 changed files with 0 additions and 0 deletions

View file

@ -0,0 +1,287 @@
import 'dart:convert';
import 'dart:typed_data';
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/card/data/card_payload.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
void main() {
ByteData bytes(Object json) =>
ByteData.sublistView(utf8.encode(jsonEncode(json)));
RichContent text(String text) => RichContent({
'document': {'type': 'page', 'note': text},
});
test('a card payload survives a round trip, Japanese included', () {
final content = CardContent(front: text('猫'), back: text('cat'));
expect(decodeCardPayload(encodeCardPayload(content)), content);
});
test('names where an imported card came from, beside its content', () {
final content = CardContent(front: text('猫'), back: text('cat'));
final imported = encodeCardPayload(content, origin: 'anki');
expect(cardOriginOf(imported), 'anki');
expect(decodeCardPayload(imported), content);
expect(cardOriginOf(encodeCardPayload(content)), isNull);
expect(cardOriginOf(redactedPayload), isNull);
});
test('refuses a schema newer than this app knows', () {
expect(
() => decodeCardPayload(
bytes({
'schemaVersion': cardSchemaVersion + 1,
'front': RichContent.empty().toJson(),
'back': RichContent.empty().toJson(),
}),
),
throwsFormatException,
);
});
test('refuses what is not a card payload, a blanked one included', () {
expect(() => decodeCardPayload(redactedPayload), throwsFormatException);
expect(
() => decodeCardPayload(
bytes({'schemaVersion': cardSchemaVersion, 'front': 'no'}),
),
throwsFormatException,
);
});
test('every type survives a round trip with its fields', () {
final contents = [
CardContent(
type: CardType.multipleChoice,
front: text('Capital of Japan?'),
back: RichContent.empty(),
answers: [
CardAnswer(content: text('Tokyo'), correct: true),
CardAnswer(content: text('Kyoto')),
],
),
CardContent(
type: CardType.multipleChoice,
front: text('Where is Tokyo?'),
back: RichContent.empty(),
answers: [
CardAnswer(content: text('Tokyo'), correct: true, region: 'JP-13'),
],
map: 'japan',
),
CardContent(
type: CardType.vocabulary,
front: text('食べる'),
back: RichContent.empty(),
reading: 'たべる',
meanings: const ['to eat', 'to live on'],
),
CardContent(
type: CardType.matching,
front: text('Sort the animals'),
back: RichContent.empty(),
matching: const Matching(
targets: ['mammal', 'bird'],
items: [
MatchingItem(text: 'cat', target: 0),
MatchingItem(text: 'crow', target: 1),
],
),
),
];
for (final content in contents) {
expect(decodeCardPayload(encodeCardPayload(content)), content);
}
});
test('leaves out the fields a card leaves empty', () {
final payload = encodeCardPayload(
CardContent(front: text('猫'), back: text('cat')),
);
expect(decodePayload(payload, newestSchema: cardSchemaVersion).keys, {
'schemaVersion',
'type',
'front',
'back',
});
});
test('reads a card of the first schema as a front/back card', () {
final content = decodeCardPayload(
bytes({
'schemaVersion': 1,
'front': text('猫').toJson(),
'back': text('cat').toJson(),
}),
);
expect(content, CardContent(front: text('猫'), back: text('cat')));
});
test('a card tied to a page of a PDF keeps it, whatever its type', () {
final content = CardContent(
front: text('猫'),
back: text('cat'),
source: (pdfId: 'grammar-pdf', page: 12),
);
final decoded = decodeCardPayload(encodeCardPayload(content));
expect(decoded, content);
expect(decoded.source, (pdfId: 'grammar-pdf', page: 12));
expect(content.normalized.source, (pdfId: 'grammar-pdf', page: 12));
});
test('hints, an explanation and a report go with any type of card, and '
'a word keeps its senses', () {
final content = CardContent(
type: CardType.vocabulary,
front: text('食べる'),
back: RichContent.empty(),
reading: 'たべる',
meanings: const ['to eat', 'to live on'],
senses: const [
VocabularySense(glosses: ['to eat'], pos: ['v1', 'vt']),
VocabularySense(
glosses: ['to live on'],
label: 'figurative',
misc: ['uk'],
field: ['food'],
dialect: ['ksb'],
),
],
hints: [text('a verb'), text('ichidan')],
explanation: text('食 means food.'),
report: CardReport(
category: ReportCategory.typo,
note: 'Reading is off',
at: DateTime.utc(2026, 10, 7, 12),
),
);
expect(cardSchemaVersion, greaterThanOrEqualTo(5));
expect(decodeCardPayload(encodeCardPayload(content)), content);
});
test('a word keeps the dictionary entry it was made from (schema 6); one '
'from before has none', () {
final content = CardContent(
type: CardType.vocabulary,
front: text('食べる'),
back: RichContent.empty(),
reading: 'たべる',
meanings: const ['to eat'],
entry: (source: 'jmdict', id: '1358280'),
);
expect(cardSchemaVersion, greaterThanOrEqualTo(6));
expect(decodeCardPayload(encodeCardPayload(content)), content);
expect(
decodeCardPayload(
bytes({
'schemaVersion': 5,
'type': 'vocabulary',
'front': text('食べる').toJson(),
'back': RichContent.empty().toJson(),
}),
).entry,
isNull,
);
});
test('a character keeps its meanings and its on and kun readings '
'(schema 7); a card from before has no readings', () {
final content = CardContent(
type: CardType.character,
front: text('食'),
back: RichContent.empty(),
meanings: const ['eat', 'food'],
onReadings: const ['ショク', 'ジキ'],
kunReadings: const ['く.う', 'た.べる'],
entry: (source: 'kanjidic', id: '食'),
);
expect(cardSchemaVersion, greaterThanOrEqualTo(7));
expect(decodeCardPayload(encodeCardPayload(content)), content);
expect(
decodePayload(
encodeCardPayload(content),
newestSchema: cardSchemaVersion,
),
containsPair('on', ['ショク', 'ジキ']),
);
final before = decodeCardPayload(
bytes({
'schemaVersion': 6,
'type': 'frontBack',
'front': text('猫').toJson(),
'back': text('cat').toJson(),
}),
);
expect(before.onReadings, isEmpty);
expect(before.kunReadings, isEmpty);
});
test('a card keeps the version of the entry it took (schema 8); one from '
'before has none', () {
final content = CardContent(
type: CardType.vocabulary,
front: text('食べる'),
back: RichContent.empty(),
meanings: const ['to eat'],
entry: (source: 'jmdict', id: '1358280'),
entryVersion: '0123456789abcdef',
);
expect(cardSchemaVersion, greaterThanOrEqualTo(8));
expect(decodeCardPayload(encodeCardPayload(content)), content);
expect(
decodeCardPayload(
bytes({
'schemaVersion': 7,
'type': 'vocabulary',
'front': text('食べる').toJson(),
'back': RichContent.empty().toJson(),
'dictionary': {'source': 'jmdict', 'id': '1358280'},
}),
).entryVersion,
isNull,
);
});
test('reads a report of a category it does not know as "other"', () {
final content = decodeCardPayload(
bytes({
'schemaVersion': 5,
'type': 'frontBack',
'front': text('猫').toJson(),
'back': text('cat').toJson(),
'report': {'category': 'offensive', 'at': '2026-10-07T12:00:00Z'},
}),
);
expect(content.report?.category, ReportCategory.other);
expect(content.report?.note, '');
});
test('refuses a type this app does not know', () {
expect(
() => decodeCardPayload(
bytes({
'schemaVersion': cardSchemaVersion,
'type': 'hologram',
'front': RichContent.empty().toJson(),
'back': RichContent.empty().toJson(),
}),
),
throwsFormatException,
);
});
}

View file

@ -0,0 +1,444 @@
import 'dart:convert';
import 'dart:typed_data';
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/revision_merge.dart';
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_gateway.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 '../../../support/fake_card_gateway.dart';
/// A [FakeCardGateway] counting how often it reads a studyset's cards.
class _CountingCardGateway() extends FakeCardGateway {
int reads = 0;
int wholeReads = 0;
@override
Future<List<CardRow>> cardsOf(String studysetId) {
reads++;
return super.cardsOf(studysetId);
}
@override
Future<List<CardRevisionRow>> allRevisions() {
wholeReads++;
return super.allRevisions();
}
}
RichContent text(String text) => RichContent({
'document': {'type': 'page', 'note': text},
});
CardContent card(String front, String back) =>
CardContent(front: text(front), back: text(back));
void main() {
late FakeCardGateway gateway;
late CardRepository repository;
setUp(() {
gateway = FakeCardGateway();
repository = CardRepository(gateway);
});
tearDown(() => repository.dispose());
Iterable<String> fronts(List<Card> cards) => [
for (final card in cards)
(card.content.front.editorJson['document']! as Map)['note']! as String,
];
test('tells of every change, made here or synced from elsewhere', () async {
var changes = 0;
final subscription = repository.changes.listen((_) => changes++);
addTearDown(subscription.cancel);
final id = await repository.create('japanese', card('猫', 'cat'));
gateway.receiveRemoteRevision(
id: 'rev-9000',
cardId: id,
parents: (await repository.cardsOf('japanese')).single.heads,
payload: encodeCardPayload(card('猫', 'a cat')),
);
await Future<void>.delayed(Duration.zero);
expect(changes, 2);
});
test('creates cards at the end of their studyset, in order', () async {
await repository.create('japanese', card('猫', 'cat'));
await repository.create('japanese', card('犬', 'dog'));
await repository.create('kanji', card('日', 'sun'));
expect(fronts(await repository.cardsOf('japanese')), ['猫', '犬']);
expect(fronts(await repository.cardsOf('kanji')), ['日']);
});
test('creates many cards at once, after the others, in order', () async {
await repository.create('japanese', card('猫', 'cat'));
final ids = await repository.createAll('japanese', [
card('犬', 'dog'),
card('鳥', 'bird'),
]);
final cards = await repository.cardsOf('japanese');
expect(fronts(cards), ['猫', '犬', '鳥']);
expect([for (final card in cards.skip(1)) card.id], ids);
});
test('a card buried is set aside from then until the next local day; '
'unburied, no longer', () async {
final id = await repository.create('japanese', card('猫', 'cat'));
final at = DateTime.utc(2026, 10, 7, 9);
var changes = 0;
final subscription = repository.changes.listen((_) => changes++);
addTearDown(subscription.cancel);
await repository.bury(id, at);
final buried = (await repository.cardsOf('japanese')).single;
expect(buried.buriedAt, at);
expect(buried.isBuried(DateTime.utc(2026, 10, 7)), isTrue);
expect(buried.isBuried(DateTime.utc(2026, 10, 8)), isFalse);
await repository.unbury(id);
final unburied = (await repository.cardsOf('japanese')).single;
expect(unburied.buriedAt, isNull);
expect(unburied.isBuried(DateTime.utc(2026, 10, 7)), isFalse);
expect(changes, 2);
});
test('tells imported cards from written ones', () async {
final written = await repository.create('japanese', card('猫', 'cat'));
final [imported] = await repository.createAll('japanese', [
card('犬', 'dog'),
], origin: 'anki');
// An edit keeps a card imported: its first revision says so.
final importedCard = (await repository.cardsOf('japanese')).last;
await repository.save(importedCard, card('犬', 'a dog'));
expect(
{
for (final stamp in await repository.allCards())
stamp.id: stamp.imported,
},
{written: false, imported: true},
);
});
test('saving appends a revision that replaces the current one', () async {
final id = await repository.create('japanese', card('猫', 'cat'));
final created = (await repository.cardsOf('japanese')).single;
await repository.save(created, card('猫', 'a cat'));
final saved = (await repository.cardsOf('japanese')).single;
expect(saved.id, id);
expect(saved.content, card('猫', 'a cat'));
expect(gateway.written.last.parents, created.heads);
});
test('saving what is already stored writes nothing', () async {
await repository.create('japanese', card('猫', 'cat'));
final created = (await repository.cardsOf('japanese')).single;
await repository.save(created, card('猫', 'cat'));
expect(gateway.written, hasLength(1));
});
group('with an edit from another device', () {
late Card created;
setUp(() async {
await repository.create('japanese', card('猫', 'cat'));
created = (await repository.cardsOf('japanese')).single;
});
void remoteEdit(CardContent content) => gateway.receiveRemoteRevision(
id: 'rev-9000',
cardId: created.id,
parents: created.heads,
payload: encodeCardPayload(content),
);
test('different sides merge', () async {
await repository.save(created, card('ねこ', 'cat'));
remoteEdit(card('猫', 'a cat'));
final merged = (await repository.cardsOf('japanese')).single;
expect(merged.content, card('ねこ', 'a cat'));
expect(merged.hasConflict, isFalse);
});
test('the same side changed differently is a conflict; saving resolves '
'it', () async {
await repository.save(created, card('猫', 'a cat'));
remoteEdit(card('猫', 'the cat'));
final conflicted = (await repository.cardsOf('japanese')).single;
expect(conflicted.conflicts.keys, [CardField.back]);
await repository.save(conflicted, card('猫', 'cat'));
final resolved = (await repository.cardsOf('japanese')).single;
expect(resolved.hasConflict, isFalse);
expect(resolved.content, card('猫', 'cat'));
expect(gateway.written.last.parents, conflicted.heads);
expect(conflicted.heads, hasLength(2));
});
test('an edit saved on what it started from stands beside what came '
'meanwhile, and merges with it', () async {
remoteEdit(card('猫', 'a cat'));
final id = await repository.saveEdit(
created.id,
card('ねこ', 'cat'),
basedOn: created.heads,
);
expect(gateway.written.last.id, id);
expect(gateway.written.last.parents, created.heads);
final merged = (await repository.cardsOf('japanese')).single;
expect(merged.content, card('ねこ', 'a cat'));
expect(merged.heads, {id, 'rev-9000'});
expect(await repository.headsOf(created.id), {id, 'rev-9000'});
});
});
group("A dictionary's correction", () {
/// The word 食べる, made from JMdict's entry at [version], meaning
/// [glosses].
CardContent taberu(String version, List<String> glosses) => CardContent(
type: CardType.vocabulary,
front: RichContent.plain('食べる'),
back: RichContent.empty(),
reading: 'たべる',
senses: [VocabularySense(glosses: glosses)],
entry: (source: 'jmdict', id: '1358280'),
entryVersion: version,
).normalized;
/// The correction: the senses and version of [taberu].
CardContent Function(CardContent) corrected(List<String> glosses) =>
(base) => base
.copyWith(
senses: [VocabularySense(glosses: glosses)],
entryVersion: 'v2',
)
.normalized;
test('goes whole into a card nobody changed since', () async {
final id = await repository.create('japanese', taberu('v1', ['to eat']));
expect(
await repository.takeDictionaryUpdate(
id,
corrected(['to eat', 'to consume']),
),
UpdateTaken.taken,
);
final now = (await repository.cardsOf('japanese')).single;
expect(now.content.meanings, ['to eat', 'to consume']);
expect(now.content.entryVersion, 'v2');
expect(now.heads, hasLength(1));
});
test('merges with what the person changed on another side', () async {
final id = await repository.create('japanese', taberu('v1', ['to eat']));
final made = (await repository.cardsOf('japanese')).single;
await repository.save(
made,
made.content.copyWith(hints: [RichContent.plain('ichidan')]),
);
expect(
await repository.takeDictionaryUpdate(
id,
corrected(['to eat', 'to consume']),
),
UpdateTaken.taken,
);
final now = (await repository.cardsOf('japanese')).single;
expect(now.content.meanings, ['to eat', 'to consume']);
expect(now.content.hints, [RichContent.plain('ichidan')]);
expect(now.hasConflict, isFalse);
});
test(
'is a conflict to settle where the person changed the same side',
() async {
final id = await repository.create(
'japanese',
taberu('v1', ['to eat']),
);
final made = (await repository.cardsOf('japanese')).single;
await repository.save(
made,
made.content
.copyWith(
senses: [
const VocabularySense(glosses: ['to dine']),
],
)
.normalized,
);
expect(
await repository.takeDictionaryUpdate(
id,
corrected(['to eat', 'to consume']),
),
UpdateTaken.toSettle,
);
expect(
(await repository.cardsOf('japanese')).single.conflicts.keys,
contains(CardField.senses),
);
},
);
test('is nothing to a card made before versions', () async {
final id = await repository.create(
'japanese',
taberu('v1', ['to eat']).withField(CardField.entryVersion, null),
);
expect(
await repository.takeDictionaryUpdate(id, corrected(['to consume'])),
UpdateTaken.none,
);
expect(gateway.written.where((row) => row.cardId == id), hasLength(1));
});
});
test('a card not on this device has no heads', () async {
expect(await repository.headsOf('card-9999'), isEmpty);
});
test('deleting blanks every revision before the card goes', () async {
final id = await repository.create('japanese', card('秘密', 'secret'));
await repository.save(
(await repository.cardsOf('japanese')).single,
card('秘密', 'a secret'),
);
await repository.delete((await repository.cardsOf('japanese')).single);
expect(gateway.deletions, ['blank $id', 'delete $id']);
expect(gateway.payloads.values, everyElement(redactedPayload));
expect(await repository.cardsOf('japanese'), isEmpty);
});
test("deletes a studyset's cards, each blanked first", () async {
final first = await repository.create('japanese', card('猫', 'cat'));
final second = await repository.create('japanese', card('犬', 'dog'));
await repository.create('kanji', card('日', 'sun'));
await repository.deleteAllOf('japanese');
expect(gateway.deletions, [
'blank $first',
'delete $first',
'blank $second',
'delete $second',
]);
expect(await repository.cardsOf('kanji'), hasLength(1));
});
test("reads a studyset's cards once until something changes, here or "
'synced from elsewhere', () async {
final counting = _CountingCardGateway();
final cards = CardRepository(counting);
addTearDown(cards.dispose);
final id = await cards.create('japanese', card('猫', 'cat'));
await cards.create('french', card('chat', 'cat'));
final reads = counting.reads;
expect(fronts(await cards.cardsOf('japanese')), ['猫']);
expect(fronts(await cards.cardsIn(['japanese', 'french'])), ['猫', 'chat']);
expect(fronts(await cards.cardsOf('japanese')), ['猫']);
expect(counting.reads, reads + 2, reason: 'each studyset read once');
await cards.create('japanese', card('犬', 'dog'));
expect(fronts(await cards.cardsOf('japanese')), ['猫', '犬']);
counting.receiveRemoteRevision(
id: 'rev-9000',
cardId: id,
parents: (await cards.cardsOf('japanese')).first.heads,
payload: encodeCardPayload(card('ねこ', 'cat')),
);
await Future<void>.delayed(Duration.zero);
expect(fronts(await cards.cardsOf('japanese')), ['ねこ', '犬']);
});
test('reads every card once until one changes', () async {
final counting = _CountingCardGateway();
final cards = CardRepository(counting);
addTearDown(cards.dispose);
await cards.create('japanese', card('猫', 'cat'));
expect(await cards.allCards(), hasLength(1));
expect(await cards.allCards(), hasLength(1));
expect(counting.wholeReads, 1);
await cards.create('french', card('chat', 'cat'));
expect(await cards.allCards(), hasLength(2));
expect(counting.wholeReads, 2);
});
test('a read begun before a change keeps nothing of it for later', () async {
await repository.create('japanese', card('猫', 'cat'));
final reading = repository.cardsOf('japanese');
await repository.create('japanese', card('犬', 'dog'));
await reading;
expect(fronts(await repository.cardsOf('japanese')), ['猫', '犬']);
});
test('follows edits from other devices', () async {
await repository.create('japanese', card('猫', 'cat'));
final created = (await repository.cardsOf('japanese')).single;
final watched = repository.watch('japanese');
final expectation = expectLater(
watched.map((cards) => cards.single.content),
emitsInOrder([card('猫', 'cat'), card('猫', 'a cat')]),
);
await Future<void>.delayed(Duration.zero);
gateway.receiveRemoteRevision(
id: 'rev-9000',
cardId: created.id,
parents: created.heads,
payload: encodeCardPayload(card('猫', 'a cat')),
);
await expectation;
});
test('counts revisions this app version cannot read as not there', () async {
await repository.create('japanese', card('猫', 'cat'));
final created = (await repository.cardsOf('japanese')).single;
gateway.receiveRemoteRevision(
id: 'rev-9000',
cardId: created.id,
parents: created.heads,
payload: ByteData.sublistView(
utf8.encode(jsonEncode({'schemaVersion': cardSchemaVersion + 1})),
),
);
expect(
(await repository.cardsOf('japanese')).single.content,
card('猫', 'cat'),
);
});
}