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
2 changed files with 0 additions and 0 deletions
|
|
@ -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,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
@ -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'),
|
||||
);
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue