halkyon/halkyon_learn_curator/test/jmdict_xml_test.dart
Tizian 「ティツィアーン」 2d0ba20eb7
feat(curator): read JMdict as EDRDG publishes it, the XML of every day
EDRDG makes JMdict_e_examp.gz anew every day: JMdict in English with
Tatoeba's sentences on the meanings, the file jmdict-simplified's examples
edition is made from once a week. The curator now reads it directly (a
file not named .json is read as XML), streaming 72 MB of XML in some 30
seconds and 300 MB, each entry put into the shape jmdict-simplified gives
it and made an entry by the same code: against jmdict-simplified's
conversion of four days before, the entries differ only by JMdict's edits
since. JMdict's entities stay codes (`&v1;` is `v1`), text cut between two
chunks is joined before its entities are read (the xml package's
normalizeEvents), and a sense without parts of speech has the previous
sense's, as JMdict's DTD says. Its version is the day it was made.

So an instance can keep its dictionaries current from the source alone,
as EDRDG's licence asks of servers (at least monthly).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-09 18:09:36 +02:00

124 lines
3.9 KiB
Dart

import 'dart:convert';
import 'dart:io';
import 'package:halkyon_learn_curator/src/sources/jlpt.dart';
import 'package:halkyon_learn_curator/src/sources/jmdict_xml.dart';
import 'package:test/test.dart';
import 'package:xml/xml.dart';
/// [text] as a stream of pieces [size] characters long.
Stream<String> pieces(String text, int size) => Stream.fromIterable([
for (var at = 0; at < text.length; at += size)
text.substring(at, (at + size).clamp(0, text.length)),
]);
void main() {
late String xml;
late List<Map<String, Object?>> lines;
setUpAll(() async {
xml = await File('test/fixtures/jmdict-e-examp-sample.xml').readAsString();
final jlpt = await readJlptLevels(Directory('test/fixtures/jlpt'));
lines = [
for (final line in await jmdictXmlDictionary(
Stream.value(xml),
jlpt: jlpt,
examples: true,
).toList())
jsonDecode(line) as Map<String, Object?>,
];
});
Map<String, Object?> entry(String id) =>
lines.skip(1).firstWhere((line) => line['id'] == id);
List<Map<String, Object?>> senses(String id) => [
for (final sense in (entry(id)['entry']! as Map)['senses'] as List)
sense as Map<String, Object?>,
];
test("the header names EDRDG's JMdict and the day it was made, and "
'credits the sentences and the levels', () {
final header = lines.first;
expect(header['source'], 'jmdict');
expect(header['kind'], 'words');
expect(header['sourceVersion'], '2026-10-09');
expect(
[
for (final credit in header['credits']! as List)
(credit as Map)['name'],
],
['JMdict', 'JLPT vocabulary lists', 'Tatoeba'],
);
expect(lines, hasLength(6));
});
test('a word as from the JSON: its headword and reading, labels, keys, '
"and its senses with JMdict's codes as codes", () {
final taberu = entry('1358280');
expect((taberu['headword'], taberu['reading']), ('食べる', 'たべる'));
expect(taberu['labels'], ['common', 'jlpt:n5']);
expect(taberu['keys'], containsAll(['食べる', 'たべる', 'taberu', 'eat']));
expect(senses('1358280').first['pos'], ['v1', 'vt']);
});
test("a sense's sentences from Tatoeba, the word as it is in each", () {
expect((senses('1358280').first['examples']! as List).first, {
'text': '食べる',
'ja': 'もっと果物を食べるべきです。',
'en': 'You should eat more fruit.',
'tatoeba': '193344',
});
});
test('a word usually written in kana has a kana headword; a sense kept to '
'one reading says so', () {
final sono = entry('1006830');
expect(sono['headword'], 'その');
expect(senses('1006830').first['misc'], ['uk']);
expect(senses('1006830').last['readings'], ['その']);
});
test('the reading is the one restricted to the headword', () {
expect(entry('1000110')['reading'], 'シーディープレーヤー');
});
test(
'entities cut between two pieces of the stream are read whole',
() async {
final again = [
for (final line in await jmdictXmlDictionary(
pieces(xml, 7),
examples: true,
).toList())
jsonDecode(line) as Map<String, Object?>,
];
expect(
[for (final line in again.skip(1)) line['entry']],
[for (final line in lines.skip(1)) line['entry']],
);
},
);
test('a sense without parts of speech has those of the sense before', () {
final word = jmdictWordOf(
XmlDocument.parse('''
<entry><ent_seq>1</ent_seq><r_ele><reb>あ</reb></r_ele>
<sense><pos>n</pos><gloss>one</gloss></sense>
<sense><gloss>two</gloss></sense>
<sense><pos>adv</pos><gloss>three</gloss></sense></entry>''').rootElement,
);
expect(
[
for (final sense in word['sense']! as List)
(sense as Map)['partOfSpeech'],
],
[
['n'],
['n'],
['adv'],
],
);
});
}