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>
444 lines
15 KiB
Dart
444 lines
15 KiB
Dart
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);
|
||
});
|
||
});
|
||
}
|