halkyon/halkyon_learn_flutter/test/feature/dictionary/dictionary_data_test.dart
Tizian 「ティツィアーン」 5479b973fc
feat(dictionary): characters of Chinese, Korean and Greek, written by hand; pinyin typed
Parity with Japanese's kanji and kana:

- The characters' tab is named by its language (Hanzi, Hangul & Hanja,
  Alphabet), its groups too: HSK levels and the characters in common use
  (Hong Kong's grades for traditional ones); Hangul and the Hanja taught
  in schools; Greek's letters. Radicals for Chinese and Korean in their
  own forms (讠 for 言 in simplified Chinese), none for Greek.
- A character's readings are named as its language names them: On and
  Kun, Pinyin, Eum and Hun (음, 훈); a letter means its sound and name.
- How characters are written comes by language: Chinese from Make Me a
  Hanzi (its medians in KanjiVG's square, Arphic Public License, a data
  file beside the app, never in cards), the Hangul letters drawn by the
  curator; a Korean word is written letter by letter (닭: ㄷ ㅏ ㄺ). Words
  of alphabets (German, Greek) aren't written by hand.
- A Chinese word's pinyin is typed in a session, tone numbers turning
  into marks (ni3 → nǐ); KRDict's levels (초급, 중급, 고급) tag Korean
  words; a Korean word's pronunciation shows in brackets ([가치]).
- Smaller: Look up in the composer keeps a noun's article; the Watch
  filter names its level list; hints and placeholders by language.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-14 14:35:34 +02:00

444 lines
15 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'package:flutter_test/flutter_test.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/dictionary/data/dictionary_adder.dart';
import 'package:halkyon_learn_flutter/src/feature/dictionary/data/dictionary_repository.dart';
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, '학꾜');
});
});
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);
});
});
}