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

View file

@ -0,0 +1,173 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/feature/dictionary/model/analysed_words.dart';
import 'package:halkyon_learn_flutter/src/feature/dictionary/model/mecab_tokens.dart';
import 'package:halkyon_learn_flutter/src/feature/dictionary/model/sentence_words.dart';
import 'package:halkyon_learn_flutter/src/feature/dictionary/model/ve_words.dart';
import '../../support/fake_dictionary_gateway.dart';
import '../../support/mecab_output.dart';
final words = [
word('1', '今日', reading: 'きょう', pos: ['n', 'adv'], labels: ['common']),
word('2', '今日は', reading: 'こんにちは', pos: ['int'], labels: ['common']),
word('3', 'は', pos: ['prt'], labels: ['common']),
word('4', 'いい', pos: ['adj-ix'], labels: ['common']),
word('5', '天気', reading: 'てんき', pos: ['n'], labels: ['common']),
word('6', 'です', pos: ['cop'], labels: ['common']),
word('7', 'ね', pos: ['prt'], labels: ['common']),
word('8', '食べる', reading: 'たべる', pos: ['v1', 'vt']),
word('9', '静か', reading: 'しずか', pos: ['adj-na']),
word('10', '部屋', reading: 'へや', pos: ['n']),
word('11', '二十', reading: 'にじゅう', pos: ['num']),
word('12', '五', reading: 'ご', pos: ['num']),
word('13', '歳', reading: 'さい', pos: ['ctr']),
word('14', '携帯', reading: 'けいたい', pos: ['n', 'vs']),
word('15', '電話', reading: 'でんわ', pos: ['n', 'vs']),
word('16', '携帯電話', reading: 'けいたいでんわ', pos: ['n']),
word('17', 'を', pos: ['prt']),
word('18', '買う', reading: 'かう', pos: ['v5u', 'vt']),
word('19', 'だ', pos: ['cop']),
word('20', 'よ', pos: ['prt']),
word('21', 'さん', pos: ['suf']),
word('22', 'が', pos: ['prt'], labels: ['common']),
word('23', '来る', reading: 'くる', pos: ['vk']),
word('24', 'オーケー', reading: 'ok', pos: ['int']),
word('25', '𠮟る', reading: 'しかる', pos: ['v5r', 'vt']),
word('26', '勉強', reading: 'べんきょう', pos: ['n', 'vs']),
word('27', '寝る', reading: 'ねる', pos: ['v1', 'vi']),
word('28', 'おはよう', pos: ['int']),
word('29', 'と', pos: ['prt']),
word('30', '言う', reading: 'いう', pos: ['v5u', 'vt']),
];
/// [sentence] cut into words on the device, with the dictionary [words]
/// as the fake gateway finds them by their keys.
Future<List<SentencePart>> partsOf(String sentence) async {
final grouped = veWords(mecabTokens(sentence, mecabOutput[sentence]!));
final keys = analysedSentenceKeys(sentence, grouped);
final found = await FakeDictionaryGateway(
words: words,
).withKeys(jmdict, keys.toList());
return analysedSentenceParts(sentence, grouped, found);
}
List<String> textsOf(List<SentencePart> parts) => [
for (final part in parts)
if (part.isWord) part.text else '(${part.text})',
];
void main() {
test('knows grammar the longest match doesn’t: 今日は is 今日 and は where '
'the sentence goes on', () async {
final parts = await partsOf('今日はいい天気ですね');
expect(textsOf(parts), ['今日', 'は', 'いい', '天気', 'です', 'ね']);
expect(parts.first.entries.first.headword, '今日');
expect(parts[1].entries.first.headword, 'は');
expect(
parts.first.wider.single.entries.single.reading,
'こんにちは',
reason: 'the longer word is still offered',
);
});
test('finds a conjugated word by its dictionary form, with how it is '
'conjugated', () async {
final [eat] = await partsOf('食べられなかった');
expect(eat.text, '食べられなかった');
expect(eat.entries.single.headword, '食べる');
expect(eat.steps, isNotEmpty);
});
test('finds a word Ve joined with its ending by its dictionary form, '
'where the dictionary lacks it as it stands', () async {
final [quiet, room] = await partsOf('静かな部屋');
expect(quiet.text, '静かな');
expect(quiet.entries.single.headword, '静か');
expect(room.entries.single.headword, '部屋');
final [study] = await partsOf('勉強しなければならない');
expect(study.text, '勉強しなければならない');
expect(study.entries.single.headword, '勉強');
});
test('cuts a group the dictionary lacks into the longest words it holds, '
'from token to token', () async {
final parts = await partsOf('二十五歳です');
expect(textsOf(parts), ['二十', '五', '歳', 'です']);
});
test(
'offers the longer words running over a word into its neighbours',
() async {
final [mobile, phone, _, buy] = await partsOf('携帯電話を買った');
expect(mobile.text, '携帯');
expect(mobile.wider.single.text, '携帯電話');
expect(phone.wider.single.text, '携帯電話');
expect(phone.wider.single.start, 0);
expect(buy.entries.single.headword, '買う');
},
);
test(
'leaves what is no word as text, never looking up romaji or meanings',
() async {
const sentence = 'OKだよ';
final grouped = veWords(mecabTokens(sentence, mecabOutput[sentence]!));
expect(analysedSentenceKeys(sentence, grouped), isNot(contains('OK')));
expect(analysedSentenceKeys(sentence, grouped), isNot(contains('ok')));
expect(textsOf(await partsOf(sentence)), ['(OK)', 'だ', 'よ']);
expect(textsOf(await partsOf('「おはよう」と言った')), [
'(「)',
'おはよう',
'(」)',
'と',
'言った',
]);
},
);
test('keeps a name the dictionary lacks as text, and the suffix after it '
'a word', () async {
final parts = await partsOf('田中さんが来た');
expect(textsOf(parts), ['(田中)', 'さん', 'が', '来た']);
expect(parts.last.entries.single.headword, '来る');
});
test('places each part where it stands, characters beyond the basic plane '
'counted as Dart counts them', () async {
final [scold] = await partsOf('𠮟られた');
expect(scold.text, '𠮟られた');
expect(scold.start, 0);
expect(scold.entries.single.headword, '𠮟る');
final parts = await partsOf('食べて 寝た');
expect(
[for (final part in parts) (part.text, part.start)],
[
('食べて', 0),
(' ', 3),
('寝た', 4),
],
);
});
test('asks for each stretch from token to token and each dictionary form, '
'with the words they may be forms of', () {
const sentence = '静かな部屋';
final keys = analysedSentenceKeys(
sentence,
veWords(mecabTokens(sentence, mecabOutput[sentence]!)),
);
expect(keys, containsAll(['静か', '静かな', '静かな部屋', '部屋', 'な', 'な部屋']));
expect(keys, isNot(contains('かな')), reason: 'no token starts there');
});
}

View file

@ -0,0 +1,130 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
import 'package:halkyon_learn_flutter/src/feature/dictionary/bloc/dictionary_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/dictionary/data/dictionary_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/dictionary/model/dictionary.dart';
import 'package:halkyon_learn_flutter/src/feature/dictionary/model/pinyin_ruby.dart';
import 'package:halkyon_learn_flutter/src/feature/dictionary/model/text_words.dart';
import 'package:halkyon_learn_flutter/src/feature/dictionary/model/word_levels.dart';
import '../../support/fake_device_dictionaries.dart';
import '../../support/fake_dictionary_gateway.dart';
/// CC-CEDICT in simplified characters, as an instance that loaded it lists
/// it.
const cedictHans = Dictionary(
source: 'cc-cedict-hans',
sourceVersion: '2026-10-13',
kind: DictionaryKind.words,
language: 'zh-Hans',
glossLanguage: 'en',
license: 'CC-BY-SA-4.0',
credits: [],
entryCount: 6,
);
DictionaryWord chinese(
String headword,
String reading, {
List<String> glosses = const [],
List<String> labels = const [],
}) => DictionaryWord(
ref: (source: 'cc-cedict-hans', id: headword),
headword: headword,
reading: reading,
labels: labels,
senses: [VocabularySense(glosses: glosses)],
language: 'zh-Hans',
);
final chineseWords = [
chinese('我', 'wǒ', glosses: ['I'], labels: ['hsk:1']),
chinese('学习', 'xué xí', glosses: ['to learn'], labels: ['hsk:1']),
chinese('学', 'xué', glosses: ['to learn'], labels: ['hsk:1']),
chinese('习', 'xí', glosses: ['to practise']),
chinese('中文', 'Zhōng wén', glosses: ['Chinese'], labels: ['hsk:1']),
chinese('文学', 'wén xué', glosses: ['literature'], labels: ['hsk:4']),
chinese('银行', 'yín háng', glosses: ['bank'], labels: ['hsk:7']),
];
void main() {
late DictionaryRepository repository;
setUp(
() => repository = DictionaryRepository(
FakeDictionaryGateway(
available: const [jmdict, cedictHans],
words: chineseWords,
),
noDeviceDictionaries(),
),
);
test('Chinese is cut by its longest words, what no word starts with kept '
'as text, each word knowing the longer words running over it', () async {
const sentence = '我学习中文学,OK。';
final parts = (await repository.wordsOf(cedictHans, [sentence])).single;
expect(
[for (final part in parts) part.text],
[
'我',
'学习',
'中文',
'学',
',OK。',
],
);
expect(parts[1].entries.single.headword, '学习');
expect(parts[1].start, sentence.indexOf('学'));
// 文学, running from 中文 into 学, is offered on both.
expect([for (final wider in parts[3].wider) wider.text], ['文学']);
expect(WordCutting.of('zh-Hant'), WordCutting.longestChinese);
});
test('a stretch chosen by hand is looked up as one word', () async {
expect(
(await repository.wordOfStretch(
cedictHans,
'文学',
)).entries.single.headword,
'文学',
);
expect(
(await repository.wordOfStretch(cedictHans, '学OK')).isWord,
isFalse,
);
});
test('pinyin stands over each character, a syllable each; the whole word '
"under the whole reading when they don't pair up", () {
expect(pinyinRuby('学习', 'xué xí'), [
(text: '学', reading: 'xué'),
(text: '习', reading: 'xí'),
]);
expect(pinyinRuby('学习', 'xuéxí'), [(text: '学习', reading: 'xuéxí')]);
expect(pinyinRuby('3D', null), [(text: '3D', reading: null)]);
});
test('HSK levels: to add whole, named, tagged, and filtered by', () {
expect(levelLabelsOf(cedictHans), [
'hsk:1',
'hsk:2',
'hsk:3',
'hsk:4',
'hsk:5',
'hsk:6',
'hsk:7',
'common',
]);
final yinhang = chineseWords.last;
expect(yinhang.hsk, 7);
expect(yinhang.tagNames, ['HSK-7-9']);
expect(isDictionaryTagName('hsk-1'), isTrue);
expect(levelOf(yinhang), 7);
expect(WordLevels.of('zh-Hans').nameOf(7), 'HSK 7–9');
expect(WordLevels.of('de'), WordLevels.none);
expect(levelOf(chineseWords[3]), isNull);
});
}

View file

@ -0,0 +1,332 @@
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:archive/archive.dart';
import 'package:clock/clock.dart';
import 'package:crypto/crypto.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/feature/dictionary/data/device_dictionaries.dart';
import 'package:halkyon_learn_flutter/src/feature/dictionary/data/dictionary_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/dictionary/model/device_dictionary.dart';
import 'package:halkyon_learn_flutter/src/feature/dictionary/model/sentence_words.dart';
import '../../support/device_export.dart';
import '../../support/fake_device_dictionaries.dart';
import '../../support/fake_dictionary_gateway.dart';
/// The curator's IPADIC pack, holding [names] (each its name as content).
List<int> ipadicPack({
Iterable<String> names = const [
'char.bin',
'dicrc',
'matrix.bin',
'sys.dic',
'unk.dic',
'mecabrc',
'COPYING',
],
}) {
final archive = Archive();
for (final name in names) {
archive.add(
ArchiveFile.bytes(
name,
Uint8List.fromList(utf8.encode('$name of IPADIC')),
),
);
}
return const GZipEncoder().encodeBytes(TarEncoder().encodeBytes(archive));
}
/// [bytes] offered as the file [name] at [url].
DeviceFile offered(String name, String url, List<int> bytes) => DeviceFile(
name: name,
url: Uri.parse(url),
sha256: '${sha256.convert(bytes)}',
size: bytes.length,
);
void main() {
late Directory folder;
late List<int> pack;
late List<int> export;
late FakeDeviceDictionaryGateway gateway;
late FakeJapaneseAnalyser analyser;
final ipadicUrl = Uri.parse(
'https://learn.example.org/device-dictionaries/i',
);
final exportUrl = Uri.parse(
'https://learn.example.org/device-dictionaries/j',
);
setUp(() {
folder = Directory.systemTemp.createTempSync('dictionaries');
pack = ipadicPack();
export = exportBytes([
exportEntry('1358280', '食べる', reading: 'たべる'),
]);
gateway = FakeDeviceDictionaryGateway(
offered: [
offered('mecab-ipadic', '$ipadicUrl', pack),
offered('jmdict-en', '$exportUrl', export),
],
bytes: {ipadicUrl: pack, exportUrl: export},
);
analyser = FakeJapaneseAnalyser(
cut: (sentence) => [SentencePart(text: '[$sentence]', start: 0)],
);
});
tearDown(() => folder.deleteSync(recursive: true));
DeviceDictionaries open() =>
DeviceDictionaries.open(gateway, analyser, folder: folder);
/// The names in [folder], but the manifest.
Set<String> held() => {
for (final entry in folder.listSync())
if (!entry.path.endsWith('manifest.json'))
entry.uri.pathSegments.lastWhere((segment) => segment.isNotEmpty),
};
test('lists what the instance offers devices; none when it offers none '
'or can’t be reached', () async {
expect(await open().offered(), gateway.offered);
gateway.offered = const [];
expect(await open().offered(), isEmpty);
gateway
..offered = [offered('jmdict-en', '$exportUrl', export)]
..fail = const SocketException('offline');
expect(await open().offered(), isEmpty);
});
test('downloads and installs them: IPADIC unpacked, the dictionary built, '
'the downloads gone, all kept for the next start', () async {
final dictionaries = open();
await dictionaries.download();
final installed = dictionaries.status.installed!;
expect(installed.source, 'jmdict');
expect(installed.glossLanguage, 'en');
expect(installed.files, {
'mecab-ipadic': '${sha256.convert(pack)}',
'jmdict-en': '${sha256.convert(export)}',
});
expect(installed.bytes, greaterThan(0));
expect(held(), {installed.ipadic, installed.database});
expect(
File('${folder.path}/${installed.ipadic}/sys.dic').readAsStringSync(),
'sys.dic of IPADIC',
);
expect(dictionaries.covers(jmdict), isTrue);
expect(dictionaries.covers(kanjidic), isFalse);
expect(dictionaries.covers(ccCedictHans), isFalse);
expect(open().status.installed, installed);
});
test(
'shows how far a download has come, then that it prepares them',
() async {
final dictionaries = open();
final states = <DeviceDictionaryStatus>[];
dictionaries.changes.listen(states.add);
await dictionaries.download();
await pumpEventQueue();
final progress = [for (final state in states) ?state.progress];
expect(progress.first.received, 0);
expect(
progress.where((step) => step.received > 0 && !step.preparing),
isNotEmpty,
);
expect(progress.last.preparing, isTrue);
expect(progress.last.total, pack.length + export.length);
expect(states.last.progress, isNull);
expect(states.last.installed, isNotNull);
},
);
test('refuses a file that isn’t the one the instance named, keeps nothing '
'of it, and says so', () async {
gateway.bytes[exportUrl] = [...export, 0];
final dictionaries = open();
await dictionaries.download();
expect(dictionaries.status.installed, isNull);
expect(dictionaries.status.failed, isTrue);
expect(dictionaries.status.progress, isNull);
expect(held(), isEmpty);
expect(dictionaries.covers(jmdict), isFalse);
});
test('refuses an IPADIC missing a file MeCab reads, and unpacks nothing but '
'what MeCab reads', () {
final bad = ipadicPack(names: ['char.bin', 'dicrc', 'matrix.bin']);
final into = Directory('${folder.path}/ipadic');
File('${folder.path}/bad.tar.gz').writeAsBytesSync(bad);
expect(
() => unpackIpadic(File('${folder.path}/bad.tar.gz'), into),
throwsFormatException,
);
expect(into.existsSync(), isFalse);
File('${folder.path}/odd.tar.gz').writeAsBytesSync(
ipadicPack(
names: [
'char.bin',
'dicrc',
'matrix.bin',
'sys.dic',
'unk.dic',
'mecabrc',
'../escaped',
'extra.txt',
],
),
);
unpackIpadic(File('${folder.path}/odd.tar.gz'), into);
expect(
{for (final file in into.listSync()) file.uri.pathSegments.last},
{'char.bin', 'dicrc', 'matrix.bin', 'sys.dic', 'unk.dic', 'mecabrc'},
);
expect(File('${folder.path}/escaped').existsSync(), isFalse);
});
test(
'asks once per device: the answer is kept, and agreeing downloads',
() async {
final declined = open();
expect(declined.status.asked, isFalse);
await declined.answer(agreed: false);
expect(declined.status.asked, isTrue);
expect(gateway.downloaded, isEmpty);
expect(open().status.asked, isTrue);
final agreed = open();
await agreed.answer(agreed: true);
expect(agreed.status.installed, isNotNull);
},
);
test('cuts lines with the files installed, and only lines of the '
'dictionary the device holds', () async {
final dictionaries = open();
expect(await dictionaries.wordsOf(jmdict, ['今日は']), isNull);
await dictionaries.download();
final installed = dictionaries.status.installed!;
expect(
(await dictionaries.wordsOf(jmdict, ['今日は']))!.single.single.text,
'[今日は]',
);
expect(analyser.read, [
'${folder.path}/${installed.ipadic}',
'${folder.path}/${installed.database}',
]);
expect(await dictionaries.wordsOf(wiktionaryDe, ['Haus']), isNull);
expect(await dictionaries.wordOfStretch(wiktionaryDe, 'Haus'), isNull);
});
test('asks the instance for newer ones once 30 days have passed, '
'downloading only what changed', () async {
final installedAt = DateTime.utc(2026, 10, 15);
final dictionaries = open();
await withClock(Clock.fixed(installedAt), dictionaries.download);
final before = dictionaries.status.installed!;
gateway.downloaded.clear();
final askedBefore = gateway.asked;
await withClock(
Clock.fixed(installedAt.add(const Duration(days: 29))),
() => dictionaries.wordsOf(jmdict, ['今日']),
);
expect(gateway.asked, askedBefore, reason: 'not due yet');
final newer = exportBytes([
exportEntry(
'1358280',
'食べる',
reading: 'たべる',
version: 'fedcba9876543210',
),
]);
gateway
..offered = [
offered('mecab-ipadic', '$ipadicUrl', pack),
offered('jmdict-en', '$exportUrl', newer),
]
..bytes[exportUrl] = newer;
final later = installedAt.add(const Duration(days: 31));
await withClock(Clock.fixed(later), () async {
final updated = dictionaries.changes.firstWhere(
(state) => state.installed?.database != before.database,
);
await dictionaries.wordsOf(jmdict, ['今日']);
await updated;
});
final after = dictionaries.status.installed!;
expect(gateway.downloaded, [exportUrl], reason: 'IPADIC is the same');
expect(after.ipadic, before.ipadic);
expect(after.checkedAt, later);
expect(held(), {after.ipadic, after.database});
});
test('removes them: lines then go through the instance again', () async {
final dictionaries = open();
await dictionaries.answer(agreed: true);
await dictionaries.remove();
expect(dictionaries.status.installed, isNull);
expect(dictionaries.covers(jmdict), isFalse);
expect(held(), isEmpty);
expect(open().status, const DeviceDictionaryStatus(asked: true));
});
group('DictionaryRepository', () {
test('cuts lines on the device where it holds the dictionary, never '
'sending them to the instance', () async {
final instance = FakeDictionaryGateway(words: [word('1', '今日')]);
final dictionaries = open();
await dictionaries.download();
final repository = DictionaryRepository(instance, dictionaries);
final [line] = await repository.wordsOf(jmdict, ['今日は']);
final stretch = await repository.wordOfStretch(jmdict, '今日', start: 2);
expect(line.single.text, '[今日は]');
expect(stretch.start, 2);
expect(instance.keysAsked, isEmpty);
expect(analyser.asked, [
['今日は'],
]);
await repository.wordsOf(jmdict, ['今日は']);
expect(analyser.asked, hasLength(1), reason: 'cut once');
});
test('asks the instance where cutting on the device fails', () async {
analyser = FakeJapaneseAnalyser(
cut: (sentence) => throw StateError('No MeCab'),
);
final instance = FakeDictionaryGateway(words: [word('1', '今日')]);
final dictionaries = open();
await dictionaries.download();
final repository = DictionaryRepository(instance, dictionaries);
final [line] = await repository.wordsOf(jmdict, ['今日']);
expect(line.single.entries.single.headword, '今日');
expect(instance.keysAsked, isNotEmpty);
});
});
}

View file

@ -0,0 +1,131 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/feature/dictionary/data/device_dictionary_store.dart';
import 'package:halkyon_learn_flutter/src/feature/dictionary/model/dictionary.dart';
import '../../support/device_export.dart';
void main() {
late Directory folder;
late File export;
late File database;
setUp(() {
folder = Directory.systemTemp.createTempSync('device-dictionary');
export = File('${folder.path}/jmdict-en.jsonl.gz');
database = File('${folder.path}/jmdict-en.sqlite');
});
tearDown(() => folder.deleteSync(recursive: true));
test('builds a dictionary from the instance’s export and finds its words '
'by their keys, in lower case, as the instance gives them', () async {
writeExport(export, [
exportEntry(
'1358280',
'食べる',
reading: 'たべる',
labels: ['common', 'jlpt:n5'],
keys: ['食べる', 'たべる', 'Taberu'],
glosses: ['to eat'],
pos: ['v1', 'vt'],
),
exportEntry('1080510', 'テレビ', keys: ['テレビ', 'てれび']),
]);
final info = await buildDeviceDictionary(export, database);
final store = DeviceDictionaryStore.open(database);
addTearDown(store.close);
expect(info.source, 'jmdict');
expect(info.kind, DictionaryKind.words);
expect(info.language, 'ja');
expect(store.info, info);
final found = store.withKeys(['たべる', 'TABERU', 'ない']);
expect(found.keys, {'たべる', 'taberu'});
final eat = found['たべる']!.single as DictionaryWord;
expect(eat.ref, (source: 'jmdict', id: '1358280'));
expect(eat.headword, '食べる');
expect(eat.reading, 'たべる');
expect(eat.labels, ['common', 'jlpt:n5']);
expect(eat.version, '0123456789abcdef');
expect(eat.senses.single.glosses, ['to eat']);
expect(eat.senses.single.pos, ['v1', 'vt']);
expect(found['taberu']!.single, same(eat), reason: 'made once');
});
test('gives each key its first eight entries, by rank, then id', () async {
writeExport(export, [
for (var i = 0; i < 12; i++)
exportEntry(
'${1000010 - i}',
'か$i',
rank: i < 2 ? 100 + i : 200000,
keys: ['か'],
),
]);
await buildDeviceDictionary(export, database);
final store = DeviceDictionaryStore.open(database);
addTearDown(store.close);
expect(
[
for (final item in store.withKeys(['か'])['か']!) item.ref.id,
],
[
'1000010',
'1000009',
// The rest share a rank: by id.
'1000000',
'1000001',
'1000002',
'1000003',
'1000004',
'1000005',
],
);
});
test('looks up no blank key, nor one with a bar', () async {
writeExport(export, [
exportEntry('1', 'ね', keys: ['ね', ' ', 'a|b']),
]);
await buildDeviceDictionary(export, database);
final store = DeviceDictionaryStore.open(database);
addTearDown(store.close);
expect(store.withKeys([' ', 'a|b', '']), isEmpty);
expect(store.withKeys(['ね']).keys, ['ね']);
});
test('refuses an export of another format, or one that ends early, and '
'leaves nothing behind', () async {
writeExport(export, [
exportEntry('1', 'ね'),
], header: exportHeader(format: 2));
await expectLater(
buildDeviceDictionary(export, database),
throwsFormatException,
);
final whole = exportBytes([
for (var i = 0; i < 200; i++) exportEntry('$i', 'ね$i'),
]);
export.writeAsBytesSync(whole.sublist(0, whole.length ~/ 2));
await expectLater(
buildDeviceDictionary(export, database),
throwsA(anything),
);
expect(folder.listSync().map((file) => file.path), [export.path]);
});
test('opens nothing but a dictionary it built', () {
File('${folder.path}/other.sqlite').writeAsStringSync('');
expect(
() => DeviceDictionaryStore.open(File('${folder.path}/other.sqlite')),
throwsA(anything),
);
});
}

View file

@ -0,0 +1,612 @@
import 'dart:typed_data';
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.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/data/entry_progress.dart';
import 'package:halkyon_learn_flutter/src/feature/dictionary/model/dictionary.dart';
import 'package:halkyon_learn_flutter/src/feature/settings/data/settings_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/study/data/review_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_payload.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
import 'package:halkyon_learn_flutter/src/feature/tag/data/tag_repository.dart';
import '../../support/fake_card_gateway.dart';
import '../../support/fake_device_dictionaries.dart';
import '../../support/fake_dictionary_gateway.dart';
import '../../support/fake_review_gateway.dart';
import '../../support/fake_settings_gateway.dart';
import '../../support/fake_studyset_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, noDeviceDictionaries()),
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, noDeviceDictionaries()),
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, noDeviceDictionaries());
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]),
noDeviceDictionaries(),
);
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),
noDeviceDictionaries(),
).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 StudysetRepository studysets;
late SettingsRepository settings;
late DictionaryAdder adder;
final omae = word(
'1002830',
'お前',
reading: 'おまえ',
glosses: ['you'],
labels: ['jlpt:n3'],
pos: ['pn'],
);
setUp(() {
cards = CardRepository(FakeCardGateway());
tags = TagRepository(FakeTagGateway());
// Japanese › Vocabulary › N5, N3 and an archived Old; Other beside.
ByteData titled(String title) =>
encodeStudysetPayload(StudysetContent.titled(title));
studysets = StudysetRepository(
FakeStudysetGateway()
..receiveRemoteChange('japanese', titled('Japanese'))
..receiveRemoteChange(
'vocabulary',
titled('Vocabulary'),
parentId: 'japanese',
)
..receiveRemoteChange(
'n5',
titled('N5'),
parentId: 'vocabulary',
)
..receiveRemoteChange(
'n3',
titled('N3'),
parentId: 'vocabulary',
position: 'a1',
)
..receiveRemoteChange(
'old',
titled('Old'),
parentId: 'vocabulary',
position: 'a2',
archivedAt: DateTime.utc(2026, 10),
)
..receiveRemoteChange('other', titled('Other'), position: 'a1'),
);
settings = SettingsRepository(FakeSettingsGateway());
adder = DictionaryAdder(cards, tags, studysets, settings);
});
tearDown(() async {
await cards.dispose();
await tags.dispose();
await studysets.dispose();
await settings.dispose();
});
List<String?> entryIds(List<Card> cards) => [
for (final card in cards) card.content.entry?.id,
];
test('a word held anywhere in the studyset or its topics, archived ones '
'too, is never added again; one held outside it is', () async {
await adder.add('n5', [taberu]);
await adder.add('old', [terebi]);
await adder.add('other', [omae]);
expect(
await adder.add('vocabulary', [taberu, terebi, omae]),
(added: 1, skipped: 2),
);
expect(entryIds(await cards.cardsOf('vocabulary')), ['1002830']);
expect(await adder.entriesIn('vocabulary'), {
taberu.ref,
terebi.ref,
omae.ref,
});
});
test('words go to the studyset or any topic inside it but archived '
'ones, in the order of the tree', () async {
expect(
[
for (final studyset in await adder.destinationsOf('vocabulary'))
studyset.id,
],
['vocabulary', 'n5', 'n3'],
);
expect(
[for (final studyset in await adder.destinationsOf('n5')) studyset.id],
['n5'],
);
});
test('new words go where chosen for the studyset, a choice that follows '
'the person; while none is, or once it is no longer inside the '
'studyset, to the studyset itself', () async {
expect(await adder.destinationOf('vocabulary'), 'vocabulary');
await adder.chooseDestination('vocabulary', 'n3');
expect(
(await settings.read()).addDestinationOf('vocabulary'),
'n3',
);
await adder.add('vocabulary', [taberu]);
expect(entryIds(await cards.cardsOf('n3')), ['1358280']);
expect(await cards.cardsOf('vocabulary'), isEmpty);
await studysets.move('n3', parentId: null);
expect(await adder.destinationOf('vocabulary'), 'vocabulary');
await adder.add('vocabulary', [terebi]);
expect(entryIds(await cards.cardsOf('vocabulary')), ['1080510']);
});
test(
"the cards of a video's words held anywhere inside get its tag",
() async {
await adder.add('n3', [taberu]);
await adder.add('n5', [terebi]);
final tag = await adder.tagHeld(
'vocabulary',
{
taberu.ref,
},
(name: 'あかね噺 S1 E1', id: null),
);
final tagging = await tags.read();
final [eat] = await cards.cardsOf('n3');
final [tv] = await cards.cardsOf('n5');
expect([
for (final each in tagging.of(eat.id)) each.id,
], contains(tag.id));
expect([
for (final each in tagging.of(tv.id)) each.id,
], isNot(contains(tag.id)));
},
);
test("a word's progress comes from its card anywhere in the studyset "
'and its topics, the cards read once for which words it holds and '
'how far each is learnt', () async {
await adder.add('n3', [taberu]);
await adder.add('other', [terebi]);
final reviews = ReviewRepository(FakeReviewGateway());
final held = await adder.heldIn('vocabulary');
final [eat] = await cards.cardsOf('n3');
expect(held, {taberu.ref: eat.id});
final progress = await EntryProgress(reviews).of(held);
expect(progress.keys, [taberu.ref]);
expect(progress[taberu.ref]?.isNew, isTrue);
// A word added since is held with its card, without reading again.
await adder.add('vocabulary', [omae]);
expect((await adder.heldIn('vocabulary')).keys, contains(omae.ref));
});
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);
});
});
}

View file

@ -0,0 +1,676 @@
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
import 'package:halkyon_learn_flutter/src/core/router/route_paths.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/kanji/data/stroke_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_payload.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
import 'package:material_ui/material_ui.dart';
import '../../support/fake_card_gateway.dart';
import '../../support/fake_dictionary_gateway.dart';
import '../../support/fake_studyset_gateway.dart';
import '../../support/loaded_strokes.dart';
import '../../support/pump_app.dart';
import '../../support/test_dependencies.dart';
final taberu = word(
'1358280',
'食べる',
reading: 'たべる',
glosses: ['to eat'],
labels: ['common', 'jlpt:n5'],
pos: ['v1'],
);
final terebi = word(
'1080510',
'テレビ',
glosses: ['television'],
labels: ['common', 'jlpt:n5'],
pos: ['n'],
);
final toukyou = word(
'1447690',
'東京',
reading: 'とうきょう',
glosses: ['Tokyo'],
labels: ['common'],
pos: ['n'],
);
final shoku = character(
'食',
meanings: ['eat', 'food'],
on: ['ショク'],
kun: ['た.べる'],
labels: ['jlpt:n5', 'grade:2'],
grade: 2,
strokes: 9,
);
final a = character('あ', labels: ['hiragana'], romaji: 'a');
/// An instance with JMdict and KANJIDIC2.
FakeDictionaryGateway withKanji() => FakeDictionaryGateway(
available: [jmdict, kanjidic],
words: [taberu, terebi, shoku, a],
);
void main() {
late FakeCardGateway cardGateway;
late CardRepository cards;
setUp(() {
cardGateway = FakeCardGateway();
cards = CardRepository(cardGateway);
});
tearDown(() => cards.dispose());
/// Lets the editors' timers run out and the screen settle.
Future<void> settle(WidgetTester tester) async {
await tester.pump(const Duration(seconds: 1));
await tester.pumpAndSettle();
}
/// Opens the studyset [id]: `japanese` (Japanese with English meanings),
/// `german`, `greek` or `chinese`, on an instance with JMdict
/// ([dictionaries]), at
/// [location] (the studyset's screen unless given).
Future<void> show(
WidgetTester tester, {
String id = 'japanese',
FakeDictionaryGateway? dictionaries,
StrokeRepository? strokes,
String? location,
/// Gives Japanese the topics Vocabulary, and N5 and N3 inside it.
bool topics = false,
}) async {
tester.view.physicalSize = const Size(1400, 1600);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
final studysets = FakeStudysetGateway()
..receiveRemoteChange(
'japanese',
encodeStudysetPayload(
StudysetContent(
title: 'Japanese',
description: RichContent.empty(),
frontLanguage: 'ja',
backLanguage: 'en',
),
),
)
..receiveRemoteChange(
'german',
encodeStudysetPayload(
StudysetContent(
title: 'German',
description: RichContent.empty(),
frontLanguage: 'de',
backLanguage: 'en',
),
),
)
..receiveRemoteChange(
'greek',
encodeStudysetPayload(
StudysetContent(
title: 'Greek',
description: RichContent.empty(),
frontLanguage: 'el',
backLanguage: 'en',
),
),
)
..receiveRemoteChange(
'chinese',
encodeStudysetPayload(
StudysetContent(
title: 'Chinese',
description: RichContent.empty(),
frontLanguage: 'zh-Hans',
backLanguage: 'en',
),
),
);
if (topics) {
ByteData titled(String title) =>
encodeStudysetPayload(StudysetContent.titled(title));
studysets
..receiveRemoteChange(
'vocabulary',
titled('Vocabulary'),
parentId: 'japanese',
)
..receiveRemoteChange('n5', titled('N5'), parentId: 'vocabulary')
..receiveRemoteChange(
'n3',
titled('N3'),
parentId: 'vocabulary',
position: 'a1',
);
}
await pumpApp(
tester,
testDependencies(
studysetGateway: studysets,
cardGateway: cardGateway,
dictionaryGateway:
dictionaries ??
FakeDictionaryGateway(words: [taberu, terebi, toukyou]),
strokes: strokes,
),
location: location ?? RoutePaths.studyset(id),
);
await settle(tester);
}
/// Chooses [item] under Add material.
Future<void> addMaterial(WidgetTester tester, String item) async {
await tester.tap(find.text('Add material'));
await tester.pumpAndSettle();
await tester.tap(find.text(item));
await tester.pump();
await tester.pump();
await settle(tester);
}
testWidgets('Add material offers the dictionary only where the '
"studyset's language has one", (tester) async {
await show(tester, id: 'german');
await tester.tap(find.text('Add material'));
await tester.pumpAndSettle();
expect(find.text('From the dictionary'), findsNothing);
});
testWidgets('a word found is added as a flashcard, and then shows as in '
'the studyset; the screen credits its sources', (tester) async {
await show(tester);
await addMaterial(tester, 'From the dictionary');
// Its sources by name in a line; who made them, under which licence,
// a tap away.
expect(find.textContaining('Words from JMdict'), findsOneWidget);
expect(find.textContaining('EDRDG'), findsNothing);
await tester.tap(find.byTooltip('Sources'));
await tester.pumpAndSettle();
expect(find.textContaining('EDRDG'), findsOneWidget);
await tester.tap(find.byTooltip('Close'));
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField), 'たべ');
await tester.pump(const Duration(milliseconds: 300));
await tester.pumpAndSettle();
expect(find.textContaining('食べる'), findsOneWidget);
await tester.tap(find.text('Add'));
await tester.pumpAndSettle();
expect(find.text('Added “食べる”.'), findsOneWidget);
expect(find.byTooltip('In this studyset'), findsOneWidget);
final card = (await cards.cardsOf('japanese')).single;
expect(card.content.entry, (source: 'jmdict', id: '1358280'));
expect(card.content.reading, 'たべる');
});
testWidgets('from a studyset with topics, a word a topic holds shows as '
'held, and new words go where Adds to says: the studyset itself, '
'until a topic is chosen in its menu', (tester) async {
await cards.createAll('n5', [toukyou.card], origin: 'jmdict');
await show(
tester,
topics: true,
location: RoutePaths.dictionary('vocabulary'),
);
await tester.enterText(find.byType(TextField), 'たべ');
await tester.pump(const Duration(milliseconds: 300));
await tester.pumpAndSettle();
await tester.tap(find.text('Add'));
await tester.pumpAndSettle();
expect(find.text('Adds to Vocabulary'), findsOneWidget);
expect(
[
for (final card in await cards.cardsOf('vocabulary'))
card.content.entry?.id,
],
['1358280'],
);
await tester.tap(find.text('Adds to Vocabulary'));
await tester.pumpAndSettle();
await tester.tap(find.text('N3').last);
await tester.pump();
await tester.pump();
await tester.pumpAndSettle();
expect(find.text('Adds to N3'), findsOneWidget);
await tester.enterText(find.byType(TextField), 'テレビ');
await tester.pump(const Duration(milliseconds: 300));
await tester.pumpAndSettle();
await tester.tap(find.text('Add'));
await tester.pumpAndSettle();
expect(
[for (final card in await cards.cardsOf('n3')) card.content.entry?.id],
['1080510'],
);
// N5's word, seen from Vocabulary: held, nothing to add.
await tester.enterText(find.byType(TextField), '東京');
await tester.pump(const Duration(milliseconds: 300));
await tester.pumpAndSettle();
expect(find.byTooltip('In this studyset'), findsOneWidget);
expect(find.text('Add'), findsNothing);
});
testWidgets('a studyset without topics shows no Adds to', (tester) async {
await show(tester, location: RoutePaths.dictionary('japanese'));
expect(find.textContaining('Adds to'), findsNothing);
});
testWidgets('tapping Add twice makes one card', (tester) async {
await show(tester);
await addMaterial(tester, 'From the dictionary');
await tester.enterText(find.byType(TextField), 'たべ');
await tester.pump(const Duration(milliseconds: 300));
await tester.pumpAndSettle();
await tester.tap(find.text('Add'));
await tester.tap(find.text('Add'), warnIfMissed: false);
await tester.pumpAndSettle();
expect(await cards.cardsOf('japanese'), hasLength(1));
});
testWidgets('without the instance, the dictionary says so and offers to '
'try again, which finds it once the instance answers', (tester) async {
final dictionaries = FakeDictionaryGateway(words: [taberu])
..fail = Exception('offline');
await show(
tester,
dictionaries: dictionaries,
location: RoutePaths.dictionary('japanese'),
);
expect(find.text("The dictionary can't be reached right now."), findsOne);
dictionaries.fail = null;
await tester.tap(find.text('Retry'));
await settle(tester);
expect(find.text('N5'), findsOneWidget);
});
testWidgets('a studyset whose language has no dictionary says so', (
tester,
) async {
await show(tester, id: 'german', location: RoutePaths.dictionary('german'));
expect(
find.text("This studyset's language has no dictionary on this instance."),
findsOneWidget,
);
});
testWidgets('a whole level becomes flashcards, its words in its order', (
tester,
) async {
await show(tester);
await addMaterial(tester, 'From the dictionary');
expect(find.text('N5'), findsOneWidget);
await tester.tap(find.text('Add 2 words'));
await tester.pumpAndSettle();
expect(find.text('Added 2 words.'), findsOneWidget);
expect(
[
for (final card in await cards.cardsOf('japanese'))
card.content.front.plainText.trim(),
],
['食べる', 'テレビ'],
);
});
testWidgets('beside the words, kanji and kana: their levels in groups, '
'one added as character cards tagged with level and grade', (
tester,
) async {
await show(tester, dictionaries: withKanji());
await addMaterial(tester, 'From the dictionary');
expect(find.text('Words'), findsOneWidget);
await tester.tap(find.text('Kanji & kana'));
await settle(tester);
expect(find.text('Kana'), findsOneWidget);
expect(find.text('JLPT levels'), findsOneWidget);
expect(find.text('School grades'), findsOneWidget);
expect(find.text('Grade 2'), findsOneWidget);
await tester.tap(find.text('N5').hitTestable());
await tester.pumpAndSettle();
await tester.tap(find.text('Add 1 character'));
await tester.pumpAndSettle();
expect(find.text('Added 1 character.'), findsOneWidget);
final made = (await cards.cardsOf('japanese')).single;
expect(made.content.type, CardType.character);
expect(made.content.onReadings, ['ショク']);
expect(made.content.entry, (source: 'kanjidic', id: '食'));
});
testWidgets("Greek's letters in a tab of their own, without radicals, "
'added whole as character cards', (tester) async {
await show(
tester,
id: 'greek',
dictionaries: FakeDictionaryGateway(
available: [wiktionaryEl, greekAlphabet],
words: [
character(
'Β β',
labels: ['greek'],
romaji: 'v',
name: 'βήτα (beta)',
of: greekAlphabet,
),
],
),
);
await addMaterial(tester, 'From the dictionary');
expect(find.text('Radicals'), findsNothing);
await tester.tap(find.text('Alphabet'));
await settle(tester);
expect(find.text('Letters'), findsOneWidget);
await tester.tap(find.text('Greek alphabet').hitTestable());
await tester.pumpAndSettle();
await tester.tap(find.text('Add 1 character'));
await tester.pumpAndSettle();
final made = (await cards.cardsOf('greek')).single;
expect(made.content.front.plainText.trim(), 'Β β');
expect(made.content.meanings, ['v', 'βήτα (beta)']);
});
testWidgets('Chinese characters by HSK level and in common use, beside '
'the radicals in their simplified forms', (tester) async {
await show(
tester,
id: 'chinese',
dictionaries: FakeDictionaryGateway(
available: [ccCedictHans, unihanHans],
words: [
character(
'说',
meanings: ['speak'],
on: ['shuō'],
labels: ['hsk:1', 'common', 'radical:149'],
of: unihanHans,
),
],
),
);
await addMaterial(tester, 'From the dictionary');
await tester.tap(find.text('Hanzi'));
await settle(tester);
expect(find.text('HSK levels'), findsOneWidget);
expect(find.text('In common use'), findsOneWidget);
expect(find.text('HSK 1'), findsOneWidget);
await tester.tap(find.text('Radicals'));
await settle(tester);
// 言 as simplified Chinese writes it at a character's side.
await tester.scrollUntilVisible(
find.text('讠'),
400,
scrollable: find.byType(Scrollable).last,
);
expect(find.text('讠'), findsOneWidget);
});
testWidgets('a character found shows its readings and strokes, and is '
'added as a card', (tester) async {
await show(tester, dictionaries: withKanji());
await addMaterial(tester, 'From the dictionary');
await tester.tap(find.text('Kanji & kana'));
await settle(tester);
await tester.enterText(find.byType(TextField).hitTestable(), 'ショク');
await tester.pump(const Duration(milliseconds: 300));
await tester.pumpAndSettle();
expect(find.text('ショク · た.べる'), findsOneWidget);
expect(find.text('9 strokes'), findsOneWidget);
await tester.tap(find.text('Add').hitTestable());
await tester.pumpAndSettle();
expect(find.text('Added “食”.'), findsOneWidget);
});
testWidgets('a character found shows how it is written when tapped', (
tester,
) async {
await show(
tester,
dictionaries: withKanji(),
strokes: await loadedStrokes(tester, ['食']),
);
await addMaterial(tester, 'From the dictionary');
await tester.tap(find.text('Kanji & kana'));
await settle(tester);
await tester.enterText(find.byType(TextField).hitTestable(), 'eat');
await tester.pump(const Duration(milliseconds: 300));
await tester.pumpAndSettle();
await tester.tap(find.text('eat; food'));
await tester.pumpAndSettle();
expect(find.text('Stroke order'), findsNothing);
expect(find.text('Play again'), findsOneWidget);
expect(find.text('eat, food'), findsOneWidget);
expect(find.text('N5 · Grade 2'), findsOneWidget);
});
testWidgets('a Greek letter found opens no sheet of its strokes: none are '
'drawn', (tester) async {
await show(
tester,
id: 'greek',
dictionaries: FakeDictionaryGateway(
available: [wiktionaryEl, greekAlphabet],
words: [
character(
'Α α',
labels: ['greek'],
romaji: 'a',
name: 'άλφα (alpha)',
of: greekAlphabet,
),
],
),
);
await addMaterial(tester, 'From the dictionary');
await tester.tap(find.text('Alphabet'));
await settle(tester);
await tester.enterText(find.byType(TextField).hitTestable(), 'α');
await tester.pump(const Duration(milliseconds: 300));
await tester.pumpAndSettle();
await tester.tap(find.text('Α α').hitTestable());
await tester.pumpAndSettle();
expect(
find.text("There's no stroke order for this character."),
findsNothing,
);
expect(find.text('Play again'), findsNothing);
});
testWidgets('Look up fills a character card with its meanings, readings '
'and tags', (tester) async {
await show(tester, dictionaries: withKanji());
await addMaterial(tester, 'Create flashcards');
await tester.tap(find.text('Character'));
await settle(tester);
await tester.tap(find.byTooltip('Look up in the dictionary'));
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField).last, 'eat');
await tester.pump(const Duration(milliseconds: 300));
await tester.pumpAndSettle();
await tester.tap(find.text('eat; food'));
await settle(tester);
expect(find.widgetWithText(TextField, 'eat'), findsOneWidget);
expect(find.widgetWithText(TextField, 'ショク'), findsOneWidget);
expect(find.widgetWithText(TextField, 'た.べる'), findsOneWidget);
expect(find.text('JLPT-N5'), findsOneWidget);
expect(find.text('Grade 2'), findsOneWidget);
});
testWidgets('Look up fills a vocabulary card with a word of the '
'dictionary', (tester) async {
await show(tester);
await addMaterial(tester, 'Create flashcards');
await tester.tap(find.text('Vocabulary'));
await settle(tester);
await tester.tap(find.byTooltip('Look up in the dictionary'));
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField).last, 'たべる');
await tester.pump(const Duration(milliseconds: 300));
await tester.pumpAndSettle();
await tester.tap(find.textContaining('食べる').last);
await settle(tester);
expect(find.text('食べる', findRichText: true), findsWidgets);
expect(find.widgetWithText(TextField, 'たべる'), findsOneWidget);
expect(find.text('to eat'), findsWidgets);
// Tagged as words from the dictionary screen are.
expect(find.text('JLPT-N5'), findsOneWidget);
expect(find.text('Verb'), findsOneWidget);
});
group('Radicals', () {
final hito = character(
'人',
meanings: ['person'],
labels: ['jlpt:n5', 'grade:1', 'radical:9'],
);
final yasumu = character(
'休',
meanings: ['rest'],
labels: ['jlpt:n5', 'grade:1', 'radical:9'],
);
/// An instance whose KANJIDIC2 files 人 and 休 under the radical 人.
FakeDictionaryGateway withRadicals() => FakeDictionaryGateway(
available: [jmdict, kanjidic],
words: [taberu, hito, yasumu, shoku],
);
Future<void> openRadicals(
WidgetTester tester,
FakeDictionaryGateway dictionaries,
) async {
await show(
tester,
dictionaries: dictionaries,
location: RoutePaths.dictionary('japanese'),
);
await tester.tap(find.text('Radicals'));
await settle(tester);
}
testWidgets('the radicals by their strokes; one lists the kanji written '
'with it, to add, and leads back to them all', (tester) async {
await openRadicals(tester, withRadicals());
expect(find.text('1 stroke'), findsOneWidget);
expect(find.text('2 strokes'), findsOneWidget);
await tester.tap(find.text('9'));
await settle(tester);
expect(find.text('man'), findsOneWidget);
expect(find.text('Radical 9 · 2 strokes · 2 kanji'), findsOneWidget);
expect(find.text('食'), findsNothing);
await tester.tap(find.text('Add').first);
await tester.pumpAndSettle();
expect(find.text('Added “人”.'), findsOneWidget);
expect(
(await cards.cardsOf('japanese')).single.content.entry,
(source: 'kanjidic', id: '人'),
);
await tester.tap(find.byTooltip('All radicals'));
await settle(tester);
expect(find.text('2 strokes'), findsOneWidget);
});
testWidgets("a radical's kanji the instance can't give now are asked "
'for again', (tester) async {
final dictionaries = withRadicals();
await openRadicals(tester, dictionaries);
dictionaries.fail = Exception('offline');
await tester.tap(find.text('9'));
await settle(tester);
expect(
find.text("The dictionary can't be reached right now."),
findsOneWidget,
);
dictionaries.fail = null;
await tester.tap(find.text('Retry'));
await settle(tester);
expect(find.text('Radical 9 · 2 strokes · 2 kanji'), findsOneWidget);
});
testWidgets('a radical KANJIDIC2 files nothing under on this instance '
'says so', (tester) async {
await openRadicals(tester, withKanji());
await tester.tap(find.text('9'));
await settle(tester);
expect(
find.text(
"The instance's dictionary has no kanji under this radical "
'yet.',
),
findsOneWidget,
);
});
});
testWidgets("About names whose work the instance's dictionaries and the "
"app's own data are", (
tester,
) async {
await pumpApp(
tester,
testDependencies(dictionaryGateway: FakeDictionaryGateway()),
location: RoutePaths.settings,
);
await tester.scrollUntilVisible(
find.text('About Halkyon Learn'),
200,
scrollable: find.byType(Scrollable).first,
);
// Wholly in view: scrolling stops once any of it shows.
await tester.ensureVisible(find.text('About Halkyon Learn'));
await tester.pumpAndSettle();
await tester.tap(find.text('About Halkyon Learn'));
await tester.pumpAndSettle();
expect(find.text('Dictionaries on this instance'), findsOneWidget);
expect(
find.text(
'JMdict: Electronic Dictionary Research and Development Group '
'(EDRDG), CC-BY-SA-4.0',
),
findsOneWidget,
);
// The app's own data, KanjiVG's strokes, as its licence asks.
expect(find.text('Data in the app'), findsOneWidget);
expect(find.text('KanjiVG: Ulrich Apel, CC-BY-SA-3.0'), findsOneWidget);
expect(find.text('https://kanjivg.tagaini.net'), findsOneWidget);
});
}

View file

@ -0,0 +1,200 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/feature/dictionary/model/dictionary.dart';
import 'package:halkyon_learn_flutter/src/feature/dictionary/model/german_words.dart';
import '../../support/fake_dictionary_gateway.dart';
void main() {
final anrufen = germanWord(
'anrufen',
pos: 'verb',
glosses: ['to call'],
forms: ['anrufe', 'anruft', 'anrief'],
);
final rufen = germanWord('rufen', pos: 'verb', glosses: ['to shout']);
final an = germanWord('an', pos: 'preposition', glosses: ['at']);
/// The entries of each longer word the part [text] of German [sentence]
/// offers beside itself, cut as [found] has its keys.
List<List<DictionaryItem>> widerAt(
String sentence,
String text,
Map<String, List<DictionaryItem>> found,
) => [
for (final wider in germanSentenceParts(
sentence,
found,
).singleWhere((part) => part.text == text).wider)
wider.entries,
];
test('a clause ending in a separable particle asks for each word before '
'it with the particle joined on, as subordinate clauses write it', () {
final keys = germanSentenceKeys('Ich rufe dich morgen an.');
expect(keys, containsAll(['ich', 'rufe', 'an', 'anrufe', 'anich']));
// Only where a particle ends the clause.
expect(germanSentenceKeys('Ich rufe dich.'), isNot(contains('anrufe')));
});
test('the separated verb is offered beside its stem and its particle; '
'clauses each on their own', () {
final found = <String, List<DictionaryItem>>{
'rufe': [rufen],
'an': [an],
'anrufe': [anrufen],
};
final parts = germanSentenceParts(
'Ich rufe dich morgen an, dann gehe ich.',
found,
);
final stem = parts.singleWhere((part) => part.text == 'rufe');
expect(stem.entries, [rufen]);
expect(stem.wider.single.entries, [anrufen]);
expect(stem.wider.single.text, 'rufe … an');
final particle = parts.singleWhere((part) => part.text == 'an');
expect(particle.wider.single.entries, [anrufen]);
// The particle's place in the line is where it stands.
expect(particle.start, 'Ich rufe dich morgen '.length);
});
test('only a verb is offered: a word before the particle joined to it '
'into a word of another kind is no separated verb', () {
final abholen = germanWord(
'abholen',
pos: 'verb',
glosses: ['to pick up'],
forms: ['abholt'],
);
final found = <String, List<DictionaryItem>>{
'er': [
germanWord('er', pos: 'pronoun', glosses: ['he']),
],
'holt': [
germanWord('holen', pos: 'verb', glosses: ['to fetch']),
],
'ab': [
germanWord('ab', pos: 'adverb', glosses: ['off']),
],
'aber': [
germanWord('aber', pos: 'conjunction', glosses: ['but']),
],
'abholt': [abholen],
'ein': [
germanWord('ein', pos: 'article', glosses: ['a']),
],
'einer': [
germanWord('einer', pos: 'pronoun', glosses: ['one']),
],
'dem': [
germanWord('der', pos: 'article', glosses: ['the']),
],
'zu': [
germanWord('zu', pos: 'preposition', glosses: ['to']),
],
'zudem': [
germanWord('zudem', pos: 'adverb', glosses: ['moreover']),
],
};
// ab and er are no aber, ein and er no einer, zu and dem no zudem.
expect(widerAt('Er holt mich ab.', 'ab', found), [
[abholen],
]);
expect(widerAt('Er holt mich ab.', 'Er', found), isEmpty);
expect(widerAt('Er schläft ein.', 'ein', found), isEmpty);
expect(widerAt('Er hört dem Lehrer zu.', 'zu', found), isEmpty);
});
test('a capitalised word is a noun unless it opens the clause, and is '
'never taken for the verb', () {
final abstellen = germanWord(
'abstellen',
pos: 'verb',
glosses: ['to put down'],
forms: ['abstellt'],
);
final anrufenAsked = germanWord(
'anrufen',
pos: 'verb',
glosses: ['to call'],
forms: ['anrufst'],
);
expect(
germanSentenceKeys('Er stellt das Essen ab.'),
allOf(contains('abstellt'), isNot(contains('abessen'))),
);
expect(
widerAt('Er stellt das Essen ab.', 'ab', {
'stellt': [
germanWord('stellen', pos: 'verb', glosses: ['to put']),
],
'essen': [
germanWord('Essen', pos: 'noun', glosses: ['food']),
],
'ab': [
germanWord('ab', pos: 'adverb', glosses: ['off']),
],
'abstellt': [abstellen],
'abessen': [
germanWord('abessen', pos: 'verb', glosses: ['to eat up']),
],
}),
[
[abstellen],
],
);
// A question opens with its verb.
expect(
widerAt('Rufst du mich an?', 'Rufst', {
'rufst': [rufen],
'an': [an],
'anrufst': [anrufenAsked],
}),
[
[anrufenAsked],
],
);
});
test('fern, hoch, durch, unter, über, bereit and the spoken rein, raus '
'and runter separate too', () {
for (final (sentence, key) in [
('Ich sehe heute Abend fern.', 'fernsehe'),
('Er hebt die Kiste hoch.', 'hochhebt'),
('Sie liest den Text durch.', 'durchliest'),
('Wir bringen die Gäste unter.', 'unterbringen'),
('Die Fähre setzt uns über.', 'übersetzt'),
('Sie stellt das Essen bereit.', 'bereitstellt'),
('Komm doch rein!', 'reinkomm'),
('Er geht raus.', 'rausgeht'),
('Sie kommt runter.', 'runterkommt'),
]) {
expect(germanSentenceKeys(sentence), contains(key), reason: sentence);
}
final fernsehen = germanWord(
'fernsehen',
pos: 'verb',
glosses: ['to watch television'],
forms: ['fernsehe'],
);
expect(
widerAt('Ich sehe heute Abend fern.', 'sehe', {
'sehe': [
germanWord('sehen', pos: 'verb', glosses: ['to see']),
],
'fern': [
germanWord('fern', pos: 'adjective', glosses: ['far']),
],
'fernsehe': [fernsehen],
}),
[
[fernsehen],
],
);
});
}

View file

@ -0,0 +1,69 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/feature/dictionary/model/japanese_deinflection.dart';
/// The words [text] deinflects to that a dictionary entry of [pos] fits.
Set<String> wordsOf(String text, List<String> pos) => {
for (final each in deinflect(text))
if (each.fits(pos)) each.text,
};
void main() {
test('the text as it stands comes first, and fits any word', () {
final first = deinflect('天気').first;
expect(first.text, '天気');
expect(first.steps, isEmpty);
expect(first.form, isNull);
expect(first.fits(const ['n']), isTrue);
});
test('verbs come back to their dictionary form', () {
expect(wordsOf('会いました', ['v5u']), contains('会う'));
expect(wordsOf('読んだ', ['v5m']), contains('読む'));
expect(wordsOf('読んでいない', ['v5m']), contains('読む'));
expect(wordsOf('食べられなかった', ['v1']), contains('食べる'));
expect(wordsOf('出ていった', ['v1']), contains('出る'));
expect(wordsOf('残っている', ['v5r']), contains('残る'));
expect(wordsOf('続けよう', ['v1']), contains('続ける'));
expect(wordsOf('行かなければ', ['v5k-s']), contains('行く'));
expect(wordsOf('なりたい', ['v5r']), contains('なる'));
expect(wordsOf('使える', ['v5u']), contains('使う'));
expect(wordsOf('食べちゃった', ['v1']), contains('食べる'));
expect(wordsOf('読まれた', ['v5m']), contains('読む'));
expect(wordsOf('来ない', ['vk']), contains('来る'));
expect(wordsOf('きました', ['vk']), contains('くる'));
});
test('adjectives and nouns taking する come back too', () {
expect(wordsOf('面白そう', ['adj-i']), contains('面白い'));
expect(wordsOf('早く', ['adj-i']), contains('早い'));
expect(wordsOf('高くなかった', ['adj-i']), contains('高い'));
expect(wordsOf('高すぎた', ['adj-i']), contains('高い'));
expect(wordsOf('勉強した', ['n', 'vs']), contains('勉強'));
});
test('a form only counts for a word of its class', () {
// 読んだ as the past of an ichidan verb 読んる doesn't exist; as the
// past of a godan verb in ぬ or ぶ it would, were there such a word.
expect(wordsOf('読んだ', ['v1']), isNot(contains('読む')));
expect(wordsOf('食べた', ['v5r']), isNot(contains('食べる')));
expect(wordsOf('早く', ['v1']), isNot(contains('早い')));
});
test('stems and forms that only lead on are never offered as words', () {
expect(
deinflect('食べて').map((each) => each.form),
isNot(contains(JapaneseForm.te)),
);
expect(
deinflect('会いました').map((each) => each.form),
isNot(contains(JapaneseForm.masuStem)),
);
});
test('says how it got there, the last step first', () {
final cake = deinflect('食べられなかった').firstWhere(
(each) => each.text == '食べる' && each.form == JapaneseForm.ichidan,
);
expect(cake.steps, ['passive or potential', 'negative', 'past']);
});
}

View file

@ -0,0 +1,103 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
import 'package:halkyon_learn_flutter/src/feature/dictionary/data/dictionary_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/dictionary/model/dictionary.dart';
import 'package:halkyon_learn_flutter/src/feature/dictionary/model/text_words.dart';
import '../../support/fake_device_dictionaries.dart';
import '../../support/fake_dictionary_gateway.dart';
/// Korean words with English meanings from Wiktionary, as an instance that
/// loaded them lists them.
const wiktionaryKo = Dictionary(
source: 'wiktionary-ko',
sourceVersion: '2026-10-03',
kind: DictionaryKind.words,
language: 'ko',
glossLanguage: 'en',
license: 'CC-BY-SA-4.0',
credits: [],
entryCount: 4,
);
DictionaryWord korean(
String headword,
String pos, {
List<String> forms = const [],
}) => DictionaryWord(
ref: (source: 'wiktionary-ko', id: '$headword/$pos'),
headword: headword,
reading: null,
labels: const [],
senses: [
VocabularySense(glosses: const ['…'], pos: [pos]),
],
partOfSpeech: pos,
inflections: [for (final form in forms) WordInflection(form, const [])],
language: 'ko',
);
final koreanWords = [
korean('집', 'noun'),
korean('학생', 'noun'),
korean('아이', 'noun'),
korean('먹다', 'verb', forms: ['먹어요', '먹었어요']),
];
void main() {
late DictionaryRepository repository;
setUp(
() => repository = DictionaryRepository(
FakeDictionaryGateway(
available: const [jmdict, wiktionaryKo],
words: koreanWords,
),
noDeviceDictionaries(),
),
);
test('Korean is cut at its spaces, a word the dictionary lacks as it '
'stands taken without its particle, which stays text', () async {
const sentence = '학생이 집에서 먹었어요.';
final parts = (await repository.wordsOf(wiktionaryKo, [sentence])).single;
expect(
[for (final part in parts) part.text],
[
'학생',
'이 ',
'집',
'에서 ',
'먹었어요',
'.',
],
);
expect(parts[2].entries.single.headword, '집');
expect(parts[2].start, sentence.indexOf('집'));
expect(parts[4].entries.single.headword, '먹다');
expect(WordCutting.of('ko'), WordCutting.spacesKorean);
});
test('a word that is one as it stands keeps what looks like a particle: '
'아이, a child, not 아 and 이', () async {
final parts = (await repository.wordsOf(wiktionaryKo, ['아이'])).single;
expect(parts.single.text, '아이');
expect(parts.single.entries.single.headword, '아이');
});
test(
'a stretch chosen by hand is looked up with its particles taken off',
() async {
expect(
(await repository.wordOfStretch(
wiktionaryKo,
'집에서',
)).entries.single.headword,
'집',
);
},
);
}

View file

@ -0,0 +1,312 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
import 'package:halkyon_learn_flutter/src/feature/dictionary/data/dictionary_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/dictionary/model/dictionary.dart';
import 'package:halkyon_learn_flutter/src/feature/dictionary/model/furigana.dart';
import 'package:halkyon_learn_flutter/src/feature/dictionary/model/sentence_words.dart';
import '../../support/fake_device_dictionaries.dart';
import '../../support/fake_dictionary_gateway.dart';
final words = [
word('1', '昨日', reading: 'きのう', pos: ['n', 'adv']),
word('2', '駅', reading: 'えき', pos: ['n']),
word('3', 'で', pos: ['prt']),
word('4', '友達', reading: 'ともだち', pos: ['n']),
word('5', 'に', pos: ['prt']),
word('6', '会う', reading: 'あう', pos: ['v5u', 'vi']),
word('7', '食べる', reading: 'たべる', pos: ['v1', 'vt']),
word('8', 'ケーキ', pos: ['n']),
word('9', '食べ物', reading: 'たべもの', pos: ['n']),
// Kana that is also the stem of a verb no one meant: 会い as ある's?
word('10', 'ある', pos: ['v5r-i']),
];
void main() {
group('sentenceParts', () {
test('cuts a sentence into its words, conjugated ones too, and keeps '
'what is between them as text', () async {
const sentence = '昨日、駅で友達に会いました。';
final repository = DictionaryRepository(
FakeDictionaryGateway(words: words),
noDeviceDictionaries(),
);
final parts = (await repository.wordsOf(jmdict, [sentence])).single;
expect(
[for (final part in parts) part.text],
[
'昨日',
'、',
'駅',
'で',
'友達',
'に',
'会いました',
'。',
],
);
final meet = parts[6];
expect(meet.entries.single.headword, '会う');
expect(meet.steps, ['polite past']);
expect(meet.start, sentence.indexOf('会'));
expect(parts[1].isWord, isFalse);
});
test(
'takes the longest word, and a form only for a word of its class',
() async {
final repository = DictionaryRepository(
FakeDictionaryGateway(words: words),
noDeviceDictionaries(),
);
final [food, cake] = await repository.wordsOf(jmdict, [
'食べ物',
'食べられなかったケーキ',
]);
expect(food.single.entries.single.headword, '食べ物');
expect(cake.first.text, '食べられなかった');
expect(cake.first.entries.single.headword, '食べる');
expect(cake.last.text, 'ケーキ');
},
);
test('reads kana as the words written so, a common one before an '
'uncommon longer one', () async {
final repository = DictionaryRepository(
FakeDictionaryGateway(
words: [
word('20', '値', reading: 'ね', pos: ['n'], labels: ['common']),
word('21', 'ね', pos: ['prt'], labels: ['common']),
word('22', 'が', pos: ['prt'], labels: ['common']),
word('23', 'がま', pos: ['n']),
const DictionaryWord(
ref: (source: 'jmdict', id: '24'),
headword: '未だ',
reading: 'まだ',
labels: ['common'],
senses: [
VocabularySense(glosses: ['still'], pos: ['adv'], misc: ['uk']),
],
),
word('25', 'だ', pos: ['cop'], labels: ['common']),
word('26', '値段', reading: 'ねだん', pos: ['n'], labels: ['common']),
],
),
noDeviceDictionaries(),
);
final [particle, still, price] = await repository.wordsOf(jmdict, [
'だね',
'がまだ',
'値段',
]);
expect(
[for (final part in particle) part.entries.first.headword],
[
'だ',
'ね',
],
);
expect([for (final part in still) part.text], ['が', 'まだ']);
expect(price.single.entries.single.headword, '値段');
});
test('a kana word of four or more is one word even if uncommon: '
'もうろく, not もう and ろく; が and まだ stay apart', () async {
final repository = DictionaryRepository(
FakeDictionaryGateway(
words: [
word('30', 'お前', reading: 'おまえ', pos: ['pn'], labels: ['common']),
word('31', 'が', pos: ['prt'], labels: ['common']),
word('32', 'もう', pos: ['adv'], labels: ['common']),
word('33', 'ろく', pos: ['adj-na']),
word('34', '耄碌', reading: 'もうろく', pos: ['n', 'vs']),
word('35', 'だ', pos: ['cop'], labels: ['common']),
word('36', 'よ', pos: ['prt'], labels: ['common']),
word('37', 'がま', pos: ['n']),
const DictionaryWord(
ref: (source: 'jmdict', id: '38'),
headword: '未だ',
reading: 'まだ',
labels: ['common'],
senses: [
VocabularySense(glosses: ['still'], pos: ['adv'], misc: ['uk']),
],
),
],
),
noDeviceDictionaries(),
);
final [senile, still] = await repository.wordsOf(jmdict, [
'お前がもうろくだよ',
'がまだ',
]);
expect(
[for (final part in senile) part.text],
['お前', 'が', 'もうろく', 'だ', 'よ'],
);
expect(senile[2].entries.first.headword, '耄碌');
expect([for (final part in still) part.text], ['が', 'まだ']);
});
test('a word knows the longer words running over it into its '
'neighbours, the longest first: が in がまだ is also がま', () async {
final repository = DictionaryRepository(
FakeDictionaryGateway(
words: [
word('31', 'が', pos: ['prt'], labels: ['common']),
word('37', 'がま', pos: ['n']),
const DictionaryWord(
ref: (source: 'jmdict', id: '38'),
headword: '未だ',
reading: 'まだ',
labels: ['common'],
senses: [
VocabularySense(glosses: ['still'], pos: ['adv'], misc: ['uk']),
],
),
],
),
noDeviceDictionaries(),
);
final [still] = await repository.wordsOf(jmdict, ['がまだ']);
expect([for (final wider in still.first.wider) wider.text], ['がま']);
expect(still.first.wider.single.entries.single.headword, 'がま');
expect(still.last.wider, isEmpty);
});
test(
'asks once for a sentence, and for several sentences at once',
() async {
final gateway = FakeDictionaryGateway(words: words);
final repository = DictionaryRepository(
gateway,
noDeviceDictionaries(),
);
await repository.wordsOf(jmdict, ['駅で', '友達に']);
await repository.wordsOf(jmdict, ['駅で']);
expect(gateway.keysAsked, hasLength(1));
expect(
gateway.keysAsked.single,
lessThan(DictionaryRepository.keysAtOnce),
);
},
);
test('asks for no Latin text, digits or punctuation', () {
final keys = sentenceKeys('OK, 2回!');
expect(keys, contains('回'));
expect(
keys.where((key) => key.contains(RegExp('[A-Za-z0-9,! ]'))),
isEmpty,
);
});
test('a line of subtitles asks for a few thousand keys at most', () {
expect(
sentenceKeys('食べられなかったケーキがまだ残っている。').length,
lessThan(DictionaryRepository.keysAtOnce),
);
});
});
group('a stretch chosen by hand', () {
final greetings = [
word('30', '今日', reading: 'きょう', pos: ['n', 'adv']),
word('31', '今日は', reading: 'こんにちは', pos: ['int']),
word('32', 'は', pos: ['prt']),
...words,
];
test('is looked up whole, also inside a word the cut took longer: 今日 '
'out of 今日は', () async {
final repository = DictionaryRepository(
FakeDictionaryGateway(words: greetings),
noDeviceDictionaries(),
);
final [line] = await repository.wordsOf(jmdict, ['今日は']);
expect(line.single.entries.first.headword, '今日は');
final chosen = await repository.wordOfStretch(jmdict, '今日');
expect(chosen.text, '今日');
expect(chosen.start, 0);
expect(chosen.entries.single.headword, '今日');
});
test('as it stands or as a form of a word: 会い of 会う', () async {
final repository = DictionaryRepository(
FakeDictionaryGateway(words: greetings),
noDeviceDictionaries(),
);
final chosen = await repository.wordOfStretch(jmdict, '会い', start: 8);
// A stem goes back to its word in no step of its own.
expect(chosen.entries.first.headword, '会う');
expect(chosen.start, 8);
});
test('is nothing the dictionary has when it is no word, or holds '
'punctuation', () async {
final repository = DictionaryRepository(
FakeDictionaryGateway(words: greetings),
noDeviceDictionaries(),
);
expect((await repository.wordOfStretch(jmdict, '駅で')).isWord, isFalse);
expect((await repository.wordOfStretch(jmdict, '昨日、駅')).isWord, isFalse);
});
test('is asked for once', () async {
final gateway = FakeDictionaryGateway(words: greetings);
final repository = DictionaryRepository(gateway, noDeviceDictionaries());
await repository.wordOfStretch(jmdict, '今日');
await repository.wordOfStretch(jmdict, '今日');
expect(gateway.keysAsked, hasLength(1));
});
});
group('furigana', () {
test('reads the kanji of a word as its dictionary form gives them', () {
expect(furigana('食べられなかった', '食べる', 'たべる'), [
(text: '食', reading: 'た'),
(text: 'べられなかった', reading: null),
]);
expect(furigana('取り消す', '取り消す', 'とりけす'), [
(text: '取', reading: 'と'),
(text: 'り', reading: null),
(text: '消', reading: 'け'),
(text: 'す', reading: null),
]);
expect(furigana('友達', '友達', 'ともだち'), [
(text: '友達', reading: 'ともだち'),
]);
});
test("kana and readings that don't fit stay as they are", () {
expect(furigana('ケーキ', 'ケーキ', null), [
(text: 'ケーキ', reading: null),
]);
expect(furigana('たべる', '食べる', 'たべる'), [
(text: 'たべる', reading: null),
]);
expect(furigana('解る', '分かる', 'わかる'), [
(text: '解る', reading: null),
]);
});
});
}

View file

@ -0,0 +1,242 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/feature/dictionary/data/dictionary_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/dictionary/model/dictionary.dart';
import 'package:halkyon_learn_flutter/src/feature/dictionary/model/spaced_words.dart';
import 'package:halkyon_learn_flutter/src/feature/dictionary/model/text_words.dart';
import 'package:halkyon_learn_flutter/src/feature/dictionary/model/word_links.dart';
import '../../support/fake_device_dictionaries.dart';
import '../../support/fake_dictionary_gateway.dart';
final germanWords = [
germanWord('wir', pos: 'pronoun', glosses: ['we']),
germanWord(
'essen',
pos: 'verb',
glosses: ['to eat'],
forms: ['isst', 'aß', 'aßen', 'gegessen'],
),
germanWord(
'Essen',
pos: 'noun',
glosses: ['food', 'meal'],
genders: ['n'],
),
germanWord(
'Haus',
pos: 'noun',
glosses: ['house, building'],
genders: ['n'],
forms: ['Hauses', 'Häuser', 'Häusern'],
),
];
void main() {
late DictionaryRepository repository;
setUp(
() => repository = DictionaryRepository(
FakeDictionaryGateway(
available: const [jmdict, wiktionaryDe],
words: germanWords,
),
noDeviceDictionaries(),
),
);
group('a language written with spaces', () {
test('is cut at its spaces, each word as it stands or as the form of '
'one, what lies between them and words without entries kept as '
'text', () async {
const sentence = 'Wir aßen im Haus, gestern.';
final parts = (await repository.wordsOf(wiktionaryDe, [
sentence,
])).single;
expect(
[for (final part in parts) part.text],
[
'Wir',
' ',
'aßen',
' im ',
'Haus',
', gestern.',
],
);
expect(parts[2].entries.single.headword, 'essen');
expect(parts[2].start, sentence.indexOf('aßen'));
expect(parts[4].entries.single.headword, 'Haus');
expect(parts[3].isWord, isFalse);
});
test('a word written as it stands comes before one written so in '
'another case: the noun Essen, the verb essen', () async {
final [noun, verb] = await repository.wordsOf(wiktionaryDe, [
'Das Essen ist gut.',
'Wir essen.',
]);
expect(noun.firstWhere((part) => part.text == 'Essen').entries, [
germanWords[2],
germanWords[1],
]);
expect(verb.firstWhere((part) => part.text == 'essen').entries, [
germanWords[1],
germanWords[2],
]);
});
test('asks for each word once, in lower case', () {
expect(spacedSentenceKeys('Haus und haus, don’t E-Mail'), {
'haus',
'und',
"don't",
'e-mail',
});
});
test('a stretch chosen by hand is looked up as one word; more than one '
'finds nothing', () async {
expect(
(await repository.wordOfStretch(
wiktionaryDe,
'Häusern',
start: 3,
)).entries.single.headword,
'Haus',
);
expect(
(await repository.wordOfStretch(wiktionaryDe, 'im Haus')).isWord,
isFalse,
);
});
});
test('Japanese is cut by its longest words, the rest at their spaces', () {
expect(WordCutting.of('ja'), WordCutting.longestJapanese);
expect(WordCutting.of('de'), WordCutting.spacesGerman);
expect(WordCutting.of('el'), WordCutting.spaces);
expect(WordCutting.of('en-GB'), WordCutting.spaces);
});
test('words are looked up on Jisho in Japanese, on Wiktionary at their '
"language's section otherwise", () {
expect(wordSiteOf('ja').name, 'Jisho');
expect(
wordSiteOf('ja').entry('食べる'),
Uri.https('jisho.org', '/search/食べる'),
);
final german = wordSiteOf('de');
expect(german.name, 'Wiktionary');
expect(
german.entry('Haus').toString(),
'https://en.wiktionary.org/wiki/Haus#German',
);
expect(
german.search('im Haus').toString(),
'https://en.wiktionary.org/w/index.php?search=im+Haus',
);
});
group('the dictionary of a studyset', () {
test("is of its front's language, with meanings in its back's, else in "
'English', () async {
expect(
await repository.dictionaryFor((front: 'de', back: 'en')),
wiktionaryDe,
);
expect(
await repository.dictionaryFor((front: 'de', back: 'fr')),
wiktionaryDe,
);
expect(await repository.dictionaryFor((front: 'el', back: 'en')), isNull);
});
test("is of its front's language alone where none is of its tag "
'(zh for zh-Hans)', () async {
const chinese = Dictionary(
source: 'cc-cedict',
sourceVersion: '2026-10-13',
kind: DictionaryKind.words,
language: 'zh',
glossLanguage: 'en',
license: 'CC-BY-SA-4.0',
credits: [],
entryCount: 1,
);
final chineseRepository = DictionaryRepository(
FakeDictionaryGateway(available: const [jmdict, chinese]),
noDeviceDictionaries(),
);
expect(
await chineseRepository.dictionaryFor((front: 'zh-Hans', back: 'en')),
chinese,
);
});
});
group('a word of Wiktionary', () {
final haus = DictionaryWord.fromEntry(
ref: (source: 'wiktionary-de', id: 'Haus/noun'),
headword: 'Haus',
reading: null,
labels: const ['common'],
language: 'de',
entry:
'{"pos":"noun","genders":["n"],"ipa":"[haʊ̯s]",'
'"inflections":[{"text":"Hauses","tags":["genitive"]},'
'{"text":"Häuser","tags":["plural"]}],'
'"senses":[{"glosses":["house, building"],"examples":[{"text":'
'"Haus","de":"In dem Haus haben wir mal gewohnt.","en":"We used '
'to live in that house."}]},{"glosses":["theatre"],'
'"tags":["colloquial"]}]}',
);
test('reads its part of speech, gender, forms and pronunciation', () {
expect(haus.partOfSpeech, 'noun');
expect(haus.genders, ['n']);
expect(haus.inflections, const [
WordInflection('Hauses', ['genitive']),
WordInflection('Häuser', ['plural']),
]);
expect(haus.ipa, '[haʊ̯s]');
expect(haus.senses.first.pos, ['noun']);
expect(haus.senses.last.misc, ['colloquial']);
});
test('becomes a card with its article, its word class as a tag, and its '
'examples in its language as hints', () {
final card = haus.card;
expect(card.front.plainText.trim(), 'das Haus');
expect(card.meanings, ['house, building', 'theatre']);
expect(haus.tagNames, ['Noun']);
expect(
card.hints.single.plainText,
'In dem Haus haben wir mal gewohnt.\nWe used to live in that house.',
);
});
test('an English word with German translations shows the meaning each '
'is of as its label', () {
final house = DictionaryWord.fromEntry(
ref: (source: 'wiktionary-en', id: 'house/noun/1'),
headword: 'house',
reading: null,
labels: const [],
language: 'en',
glossLanguage: 'de',
entry:
'{"pos":"noun","senses":[{"glosses":["Haus"],"info":["human '
'abode"]},{"glosses":["Haus","Kammer"],"info":["debating '
'chamber"]}]}',
);
expect(house.senses.first.label, 'human abode');
expect(house.card.front.plainText.trim(), 'house');
expect(house.card.meanings, ['Haus', 'Haus', 'Kammer']);
});
});
}

View file

@ -0,0 +1,170 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/feature/dictionary/model/mecab_tokens.dart';
import 'package:halkyon_learn_flutter/src/feature/dictionary/model/ve_words.dart';
import '../../support/mecab_output.dart';
/// [sentence] grouped into words, from MeCab's output for it.
List<VeWord> wordsOf(String sentence) =>
veWords(mecabTokens(sentence, mecabOutput[sentence]!));
/// Each word as it stands, then its dictionary form where that differs.
List<String> grouped(String sentence) => [
for (final word in wordsOf(sentence))
if (word.lemma == word.surface)
word.surface
else
'${word.surface}=${word.lemma}',
];
void main() {
group('mecabTokens', () {
test('reads each token with its part of speech, conjugation, dictionary '
'form and reading, where it stands in the text', () {
const sentence = '食べられなかった';
final tokens = mecabTokens(sentence, mecabOutput[sentence]!);
expect(
[for (final token in tokens) token.surface],
[
'食べ',
'られ',
'なかっ',
'た',
],
);
expect(
tokens.first,
const MecabToken(
surface: '食べ',
start: 0,
pos: '動詞',
pos2: '自立',
conjugationType: '一段',
conjugationForm: '未然形',
lemma: '食べる',
reading: 'タベ',
),
);
expect([for (final token in tokens) token.start], [0, 2, 4, 7]);
expect(tokens.last.end, sentence.length);
});
test('places tokens after spaces MeCab skipped, and characters beyond '
'the basic plane as Dart counts them', () {
const spaced = '食べて 寝た';
expect(
[
for (final token in mecabTokens(spaced, mecabOutput[spaced]!))
token.start,
],
[0, 2, 4, 5],
);
const scolded = '𠮟られた';
final tokens = mecabTokens(scolded, mecabOutput[scolded]!);
expect([for (final token in tokens) token.start], [0, 2, 4]);
expect(tokens.first.end, 2);
});
test('takes a word IPADIC lacks as its own dictionary form, without a '
'reading', () {
const sentence = 'OKだよ';
final ok = mecabTokens(sentence, mecabOutput[sentence]!).first;
expect(ok.surface, 'OK');
expect(ok.lemma, 'OK');
expect(ok.reading, isNull);
});
test('leaves out what is no token: the end, and empty lines', () {
expect(mecabTokens('', 'EOS\n'), isEmpty);
expect(mecabTokens('今日', '\n今日\t名詞,副詞可能,*,*,*,*,今日\nEOS'), hasLength(1));
});
});
group('veWords', () {
test('joins a verb with its endings and auxiliaries, as its dictionary '
'form', () {
expect(grouped('食べられなかった'), ['食べられなかった=食べる']);
expect(grouped('見ていなかった'), ['見ていなかった=見る']);
expect(grouped('学校に行きます'), ['学校', 'に', '行きます=行く']);
expect(grouped('読めば分かる'), ['読めば=読む', '分かる']);
expect(grouped('食べて、寝た'), ['食べて=食べる', '、', '寝た=寝る']);
});
test('joins a noun with the する it takes, as a verb', () {
final [study] = wordsOf('勉強している');
expect(study.surface, '勉強している');
expect(study.lemma, '勉強する');
expect(study.pos, VePos.verb);
expect(grouped('勉強しなければならない'), ['勉強しなければならない=勉強する']);
});
test('joins a na-adjective with its な, as the adjective, and a ない '
'adjective with its ない', () {
final [quiet, room] = wordsOf('静かな部屋');
expect(quiet.surface, '静かな');
expect(quiet.lemma, '静か');
expect(quiet.pos, VePos.adjective);
expect(room.surface, '部屋');
expect(grouped('申し訳ない'), ['申し訳ない']);
});
test('keeps particles, the copula and です words of their own, but for '
'て, で and ば after a verb', () {
expect(grouped('今日はいい天気ですね'), ['今日', 'は', 'いい', '天気', 'です', 'ね']);
expect(grouped('お前がもうろくだよ'), ['お前', 'が', 'もうろく', 'だ', 'よ']);
expect(grouped('彼は先生だ'), ['彼', 'は', '先生', 'だ']);
expect(grouped('東京都に住んでいます'), ['東京都', 'に', '住んでいます=住む']);
expect(grouped('がまだ'), ['が', 'まだ']);
expect(grouped('綺麗じゃない'), ['綺麗', 'じゃない=じゃ']);
});
test('joins a suffix to its noun, but keeps one after a name apart', () {
expect(grouped('子供たちが遊んでいる'), ['子供たち', 'が', '遊んでいる=遊ぶ']);
expect(grouped('田中さんが来た'), ['田中', 'さん', 'が', '来た=来る']);
});
test('joins numbers with each other and their counter', () {
expect(grouped('三人で行った'), ['三人', 'で', '行った=行う']);
expect(grouped('二十五歳です'), ['二十五歳', 'です']);
});
test('joins ため with its に, and keeps symbols apart', () {
expect(grouped('私のために'), ['私', 'の', 'ために']);
final words = wordsOf('「おはよう」と言った');
expect(
[for (final word in words) word.surface],
[
'「',
'おはよう',
'」',
'と',
'言った',
],
);
expect(words.first.pos, VePos.symbol);
expect(words[1].pos, VePos.interjection);
});
test('keeps two nouns MeCab cut apart as two words', () {
expect(grouped('携帯電話を買った'), ['携帯', '電話', 'を', '買った=買う']);
});
test('places each word where it stands', () {
final words = wordsOf('学校に行きます');
expect(
[for (final word in words) (word.start, word.end)],
[
(0, 2),
(2, 3),
(3, 7),
],
);
});
});
}