chore: Halkyon Learn moves into apps/learn
Its server, client, app and curator, and the tools that write its assets (maps, Anki fixtures, icons), as they are; paths are fixed in the commits that follow. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
11abd9afce
commit
649499be23
12 changed files with 0 additions and 0 deletions
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -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),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
@ -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],
|
||||
],
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
@ -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']);
|
||||
});
|
||||
}
|
||||
|
|
@ -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,
|
||||
'집',
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
@ -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),
|
||||
]);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -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']);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -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),
|
||||
],
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue