halkyon/halkyon_learn_flutter/test/feature/dictionary/dictionary_data_test.dart

444 lines
15 KiB
Dart
Raw Normal View History

import 'package:flutter_test/flutter_test.dart';
feat: a dictionary's corrections reach the cards made from it, by offer Every dictionary entry now has a version: 16 hex digits of the SHA-256 of what cards take of it (headword, reading, entry), made by the loader and carried on every hit. A card keeps the version it took (card schema 8). DictionaryEndpoint.changed returns, of the entries named with the versions cards keep (1,000 at most a call; the app asks 500 at a time), those whose version is another now. The card browser says how many cards are behind their dictionary and takes the corrections at a tap (decided 2026-10-09: by offer, never by themselves). Taking one writes the entry as it is now over the revision in which the card last took it, beside whatever came after, so the revisions merge per side as two devices' edits do (ADR 0003): a side only the dictionary changed takes the correction, a side the person changed too is a conflict settled in the composer, whose words now name that case. Cards made before versions are never offered one. The server rereads its dictionary folder hourly now, not daily: the dictionaries service fills its volume after an instance's first start, and an hour with nothing changed costs a hash of each file. Migration 20261009145434785-dictionary-entry-version: adds dictionary_entry.version, text NOT NULL DEFAULT '' (rows loaded before have none and are never reported as corrected), and by hand empties dictionary_source."fileHash", so every source loads once more and its entries get their versions; the schema stays as definition.sql says. The old server goes on working until the switch. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-09 17:32:00 +02:00
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/dictionary/data/dictionary_adder.dart';
import 'package:halkyon_learn_flutter/src/feature/dictionary/data/dictionary_repository.dart';
feat: a dictionary's corrections reach the cards made from it, by offer Every dictionary entry now has a version: 16 hex digits of the SHA-256 of what cards take of it (headword, reading, entry), made by the loader and carried on every hit. A card keeps the version it took (card schema 8). DictionaryEndpoint.changed returns, of the entries named with the versions cards keep (1,000 at most a call; the app asks 500 at a time), those whose version is another now. The card browser says how many cards are behind their dictionary and takes the corrections at a tap (decided 2026-10-09: by offer, never by themselves). Taking one writes the entry as it is now over the revision in which the card last took it, beside whatever came after, so the revisions merge per side as two devices' edits do (ADR 0003): a side only the dictionary changed takes the correction, a side the person changed too is a conflict settled in the composer, whose words now name that case. Cards made before versions are never offered one. The server rereads its dictionary folder hourly now, not daily: the dictionaries service fills its volume after an instance's first start, and an hour with nothing changed costs a hash of each file. Migration 20261009145434785-dictionary-entry-version: adds dictionary_entry.version, text NOT NULL DEFAULT '' (rows loaded before have none and are never reported as corrected), and by hand empties dictionary_source."fileHash", so every source loads once more and its entries get their versions; the schema stays as definition.sql says. The old server goes on working until the switch. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-09 17:32:00 +02:00
import 'package:halkyon_learn_flutter/src/feature/dictionary/data/dictionary_updates.dart';
import 'package:halkyon_learn_flutter/src/feature/dictionary/model/dictionary.dart';
import 'package:halkyon_learn_flutter/src/feature/tag/data/tag_repository.dart';
import '../../support/fake_card_gateway.dart';
import '../../support/fake_dictionary_gateway.dart';
import '../../support/fake_tag_gateway.dart';
final taberu = word(
'1358280',
'食べる',
reading: 'たべる',
glosses: ['to eat'],
labels: ['common', 'jlpt:n5'],
pos: ['v1', 'vt'],
);
final terebi = word(
'1080510',
'テレビ',
glosses: ['television', 'TV'],
labels: ['common', 'jlpt:n5'],
pos: ['n'],
);
void main() {
group('a character', () {
test('reads its meanings, readings, grade, strokes and frequency from '
'the entry', () {
final read = DictionaryCharacter.fromEntry(
ref: (source: 'kanjidic', id: '食'),
headword: '食',
labels: ['jlpt:n5', 'grade:2'],
entry:
'{"meanings":["eat","food"],"on":["ショク","ジキ"],'
'"kun":["く.う","た.べる"],"grade":2,"strokes":9,'
'"frequency":328,"jlpt":5}',
);
expect(read.meanings, ['eat', 'food']);
expect(read.onReadings, ['ショク', 'ジキ']);
expect(read.kunReadings, ['く.う', 'た.べる']);
expect((read.grade, read.strokes, read.frequency), (2, 9, 328));
expect(read.jlpt, 5);
expect(read.isKana, isFalse);
});
test('becomes a character card that keeps where it is from; a kana '
'means its romaji', () {
final eat = character(
'食',
meanings: ['eat'],
on: ['ショク'],
kun: ['た.べる'],
).card;
expect(eat.type, CardType.character);
expect(eat.front.plainText.trim(), '食');
expect(eat.meanings, ['eat']);
expect(eat.onReadings, ['ショク']);
expect(eat.kunReadings, ['た.べる']);
expect(eat.entry, (source: 'kanjidic', id: '食'));
expect(eat.isComplete, isTrue);
final a = character('あ', labels: ['hiragana'], romaji: 'a').card;
expect(a.meanings, ['a']);
expect(a.isComplete, isTrue);
});
test('names the tags by JLPT level, school grade and script', () {
expect(
character('食', labels: ['jlpt:n5', 'grade:2'], grade: 2).tagNames,
['JLPT-N5', 'Grade 2'],
);
expect(
character(
'亜',
labels: ['jlpt:n1', 'grade:secondary'],
grade: 8,
).tagNames,
['JLPT-N1', 'Secondary school'],
);
expect(character('ア', labels: ['katakana']).tagNames, ['Katakana']);
expect(isDictionaryTagName('grade 2'), isTrue);
expect(isDictionaryTagName('Hiragana'), isTrue);
expect(isDictionaryTagName('Kanji I like'), isFalse);
});
});
group('a character of another language', () {
test("Chinese: its pinyin as its readings, its HSK level and Hong Kong's "
'grade from its labels', () {
final say = DictionaryCharacter.fromEntry(
ref: (source: 'unihan-hant', id: '說'),
headword: '說',
labels: const ['hsk:1', 'common', 'grade:1', 'radical:149'],
entry:
'{"meanings": ["speak", "say"], "pinyin": ["shuō", "shuì"], '
'"strokes": 14, "simplified": ["说"]}',
language: 'zh-Hant',
);
expect(say.onReadings, ['shuō', 'shuì']);
expect(say.kunReadings, isEmpty);
expect((say.hsk, say.grade, say.strokes), (1, 1, 14));
expect(say.tagNames, ['HSK-1', 'Grade 1']);
expect(say.card.onReadings, ['shuō', 'shuì']);
});
test('Korean: a Hanja its sounds and native meanings, the 1,800 of '
'education tagged; a letter its sound and name', () {
final water = DictionaryCharacter.fromEntry(
ref: (source: 'unihan-ko', id: '水'),
headword: '水',
labels: const ['education', 'radical:85'],
entry:
'{"meanings": ["water"], "eum": ["수"], "hun": ["물"], '
'"eumhun": ["물 수"], "strokes": 4}',
language: 'ko',
);
expect(water.onReadings, ['수']);
expect(water.kunReadings, ['물']);
expect(water.isEducationHanja, isTrue);
expect(water.tagNames, ['Education Hanja']);
final giyeok = DictionaryCharacter.fromEntry(
ref: (source: 'unihan-ko', id: 'ㄱ'),
headword: 'ㄱ',
labels: const ['hangul'],
entry: '{"romaji": "g, k", "script": "hangul", "name": "기역"}',
language: 'ko',
);
expect(giyeok.isKana, isTrue);
expect(giyeok.meaningsShown, ['g, k', '기역']);
expect(giyeok.tagNames, ['Hangul']);
});
test('Greek: a letter with its capital, its transliteration and its '
'names', () {
final beta = DictionaryCharacter.fromEntry(
ref: (source: 'greek-alphabet', id: 'β'),
headword: 'Β β',
labels: const ['greek'],
entry:
'{"romaji": "v", "script": "greek", "name": "βήτα", '
'"englishName": "beta", "sound": "v"}',
language: 'el',
);
expect(beta.card.front.plainText.trim(), 'Β β');
expect(beta.card.meanings, ['v', 'βήτα (beta)']);
expect(beta.tagNames, ['Greek alphabet']);
expect(isDictionaryTagName('Greek alphabet'), isTrue);
});
test("a Korean word's KRDict level names its tag as KRDict does", () {
final school = DictionaryWord.fromEntry(
ref: (source: 'krdict', id: '73276'),
headword: '학교',
reading: '학꾜',
labels: const ['level:beginner'],
entry:
'{"pos": "noun", "hanja": ["學校"], '
'"senses": [{"glosses": ["school"]}]}',
language: 'ko',
);
expect(school.koreanLevel, 1);
expect(school.tagNames, ['초급', 'Noun']);
expect(isDictionaryTagName('초급'), isTrue);
expect(school.card.reading, '학꾜');
});
});
feat: a dictionary's corrections reach the cards made from it, by offer Every dictionary entry now has a version: 16 hex digits of the SHA-256 of what cards take of it (headword, reading, entry), made by the loader and carried on every hit. A card keeps the version it took (card schema 8). DictionaryEndpoint.changed returns, of the entries named with the versions cards keep (1,000 at most a call; the app asks 500 at a time), those whose version is another now. The card browser says how many cards are behind their dictionary and takes the corrections at a tap (decided 2026-10-09: by offer, never by themselves). Taking one writes the entry as it is now over the revision in which the card last took it, beside whatever came after, so the revisions merge per side as two devices' edits do (ADR 0003): a side only the dictionary changed takes the correction, a side the person changed too is a conflict settled in the composer, whose words now name that case. Cards made before versions are never offered one. The server rereads its dictionary folder hourly now, not daily: the dictionaries service fills its volume after an instance's first start, and an hour with nothing changed costs a hash of each file. Migration 20261009145434785-dictionary-entry-version: adds dictionary_entry.version, text NOT NULL DEFAULT '' (rows loaded before have none and are never reported as corrected), and by hand empties dictionary_source."fileHash", so every source loads once more and its entries get their versions; the schema stays as definition.sql says. The old server goes on working until the switch. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-09 17:32:00 +02:00
group('Cards behind the dictionary', () {
late FakeCardGateway cardGateway;
late CardRepository cards;
setUp(() {
cardGateway = FakeCardGateway();
cards = CardRepository(cardGateway);
});
tearDown(() => cards.dispose());
test('are those whose entry the source corrected since, with the entry '
'as it is now; cards of their own and made before versions are '
'never asked about', () async {
final gateway = FakeDictionaryGateway(
words: [
word('1358280', '食べる', glosses: ['to eat'], version: 'v2'),
word('1080510', 'テレビ', glosses: ['TV'], version: 'v1'),
],
);
final updates = DictionaryUpdates(DictionaryRepository(gateway), cards);
CardContent made(String id, String? version) => CardContent(
type: CardType.vocabulary,
front: RichContent.plain(id),
back: RichContent.empty(),
meanings: const ['x'],
entry: (source: 'jmdict', id: id),
entryVersion: version,
);
final behind = await cards.create('japanese', made('1358280', 'v1'));
await cards.create('japanese', made('1080510', 'v1'));
await cards.create('japanese', made('1080510', null));
await cards.create('japanese', CardContent.empty());
final found = await updates.behind(
await cards.cardsOf('japanese'),
(front: 'ja', back: 'en'),
);
expect(found.keys, [behind]);
expect(found[behind]!.version, 'v2');
expect(gateway.changedAsked, [2]);
});
test('of one entry keeping different versions are each compared', () async {
final gateway = FakeDictionaryGateway(
words: [
word('1358280', '食べる', glosses: ['to eat'], version: 'v2'),
],
);
final updates = DictionaryUpdates(DictionaryRepository(gateway), cards);
CardContent made(String version) => CardContent(
type: CardType.vocabulary,
front: RichContent.plain('食べる'),
back: RichContent.empty(),
meanings: const ['to eat'],
entry: (source: 'jmdict', id: '1358280'),
entryVersion: version,
);
await cards.create('japanese', made('v2'));
final old = await cards.create('japanese', made('v1'));
final found = await updates.behind(
await cards.cardsOf('japanese'),
(front: 'ja', back: 'en'),
);
expect(found.keys, [old]);
});
});
group('a word', () {
test('reads its senses from the entry, the codes with them', () {
final read = DictionaryWord.fromEntry(
ref: (source: 'jmdict', id: '1358280'),
headword: '食べる',
reading: 'たべる',
labels: ['common', 'jlpt:n5'],
entry:
'{"forms":[{"text":"食べる"}],"senses":[{"glosses":["to eat"],'
'"pos":["v1","vt"]},{"glosses":["to live on"],"misc":["uk"]}]}',
);
expect(read.senses, const [
VocabularySense(glosses: ['to eat'], pos: ['v1', 'vt']),
VocabularySense(glosses: ['to live on'], misc: ['uk']),
]);
expect(read.jlpt, 5);
});
test('becomes a vocabulary card that keeps where it is from; a word in '
'kana has no reading', () {
final card = taberu.card;
expect(card.type, CardType.vocabulary);
expect(card.front.plainText.trim(), '食べる');
expect(card.reading, 'たべる');
expect(card.meanings, ['to eat']);
expect(card.entry, (source: 'jmdict', id: '1358280'));
expect(terebi.card.reading, '');
});
test('its first three example sentences become hints, the word in bold '
'and the English below', () {
String example(int n) =>
'{"text":"食べ","ja":"$n: 魚を食べた。","en":"Sentence $n."}';
final word = DictionaryWord.fromEntry(
ref: (source: 'jmdict', id: '1358280'),
headword: '食べる',
reading: 'たべる',
labels: const [],
entry:
'{"senses":[{"glosses":["to eat"],"examples":[${example(1)},'
'${example(2)}]},{"glosses":["to live on"],"examples":['
'${example(3)},${example(4)}]}]}',
);
final hints = word.card.hints;
expect(hints, hasLength(3));
expect(hints.first.plainText, '1: 魚を食べた。\nSentence 1.');
final document = hints.first.editorJson['document']! as Map;
final first = (document['children']! as List).first as Map;
expect((first['data']! as Map)['delta'], [
{'insert': '1: 魚を'},
{
'insert': '食べ',
'attributes': {'bold': true},
},
{'insert': 'た。'},
]);
});
});
group('the repository', () {
test('asks for the dictionaries once, again after a failure', () async {
final gateway = FakeDictionaryGateway()..fail = Exception('offline');
final repository = DictionaryRepository(gateway);
await expectLater(repository.dictionaries(), throwsException);
gateway.fail = null;
expect(await repository.dictionaries(), [jmdict]);
await repository.dictionaries();
expect(gateway.asked, 2);
});
test("finds a studyset's dictionary by its front's language, with "
"meanings in its back's, else in English; one of words unless a "
'kind is asked for', () async {
const german = Dictionary(
source: 'jmdict',
sourceVersion: '1',
kind: DictionaryKind.words,
language: 'ja',
glossLanguage: 'de',
license: 'CC-BY-SA-4.0',
credits: [],
entryCount: 1,
);
final repository = DictionaryRepository(
FakeDictionaryGateway(available: [kanjidic, jmdict, german]),
);
expect(await repository.dictionaryFor((front: 'ja', back: 'de')), german);
expect(await repository.dictionaryFor((front: 'ja', back: 'fr')), jmdict);
expect(await repository.dictionaryFor((front: 'ja', back: null)), jmdict);
expect(await repository.dictionaryFor((front: 'ko', back: 'en')), isNull);
expect(await repository.dictionaryFor((front: null, back: 'en')), isNull);
expect(
await repository.dictionaryFor((
front: 'ja',
back: 'en',
), kind: DictionaryKind.characters),
kanjidic,
);
});
test('brings every word of a level, a page at a time', () async {
final words = [
for (var i = 0; i < DictionaryRepository.page + 3; i++)
word('$i', 'w$i', labels: ['jlpt:n5']),
];
final counts = <int>[];
final all = await DictionaryRepository(
FakeDictionaryGateway(words: words),
).allLabelled(jmdict, 'jlpt:n5', onProgress: counts.add);
expect(all, words);
expect(counts, [
DictionaryRepository.page,
DictionaryRepository.page + 3,
]);
});
});
group('adding words', () {
late CardRepository cards;
late TagRepository tags;
late DictionaryAdder adder;
setUp(() {
cards = CardRepository(FakeCardGateway());
tags = TagRepository(FakeTagGateway());
adder = DictionaryAdder(cards, tags);
});
tearDown(() async {
await cards.dispose();
await tags.dispose();
});
test('makes each word a card once, tagged with its level and word '
'class, marked as from the dictionary', () async {
expect(await adder.add('japanese', [taberu]), (added: 1, skipped: 0));
expect(
await adder.add('japanese', [taberu, terebi]),
(added: 1, skipped: 1),
);
final made = await cards.cardsOf('japanese');
expect(
[for (final card in made) card.content.entry?.id],
[
'1358280',
'1080510',
],
);
final tagging = await tags.read();
expect(
[for (final tag in tagging.of(made.first.id)) tag.name],
[
'JLPT-N5',
'Verb',
],
);
expect(
[for (final tag in tagging.of(made.last.id)) tag.name],
[
'JLPT-N5',
'Noun',
],
);
expect((await cards.allCards()).every((card) => card.imported), isTrue);
});
test('makes one card of a word added twice at once, or twice in one '
'list', () async {
final both = await Future.wait([
adder.add('japanese', [taberu]),
adder.add('japanese', [taberu]),
]);
expect(both, [(added: 1, skipped: 0), (added: 0, skipped: 1)]);
expect(
await adder.add('japanese', [terebi, terebi]),
(added: 1, skipped: 1),
);
expect(await cards.cardsOf('japanese'), hasLength(2));
});
test('names the tags by level and word class', () {
expect(taberu.tagNames, ['JLPT-N5', 'Verb']);
expect(
word('1', 'x', pos: ['adj-na', 'n']).tagNames,
['na-Adjective', 'Noun'],
);
expect(word('2', 'y', pos: ['vt']).tagNames, isEmpty);
});
});
}