tests/furigana.test.ts
Rama main · réplica de lectura del repositorio original.
// Furigana (shared/furigana.ts): the `{漢字|かんじ}` syntax is the Obsidian
// plugin's, so what parses here must be what parses there; the readings a
// popover suggests are ordered by what a reader is likeliest to want; and the
// table the generator writes has the one shape the popover reads.
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
findFurigana,
furiganaSpanAt,
hasKanji,
rubySegments,
serialiseFurigana,
stripFurigana,
} from "../shared/furigana.ts";
import {
autoFurigana,
kanjiRuns,
suggestReadings,
toHiragana,
type ReadingsTable,
} from "../shared/furiganaReadings.ts";
import { spellcheckLang } from "../shared/script.ts";
// The generator is a build script, not product code, so it ships no types;
// its one pure function is imported here to hold its output shape.
// @ts-expect-error — scripts/*.mjs carries no declaration file
import { readingsFromXml } from "../scripts/gen-kanji-readings.mjs";
describe("findFurigana()", () => {
it("reads a whole-word span", () => {
assert.deepEqual(findFurigana("これは{漢字|かんじ}です"), [
{ start: 3, end: 11, base: "漢字", readings: ["かんじ"] },
]);
});
it("reads a per-character span", () => {
assert.deepEqual(findFurigana("{漢字|かん|じ}")[0].readings, ["かん", "じ"]);
});
it("keeps kana inside the base", () => {
const [span] = findFurigana("{食べ物|た|もの}");
assert.equal(span.base, "食べ物");
assert.deepEqual(span.readings, ["た", "もの"]);
});
it("leaves an escaped brace alone", () => {
assert.deepEqual(findFurigana("\\{漢字|かんじ}"), []);
});
it("is not fooled by a brace group with no bar", () => {
assert.deepEqual(findFurigana("{漢字} and {.center}"), []);
});
it("takes the innermost group when braces nest", () => {
const spans = findFurigana("{{漢字|かんじ}}");
assert.equal(spans.length, 1);
assert.equal(spans[0].start, 1);
assert.equal(spans[0].base, "漢字");
});
it("wants a Japanese base — {x|y} in an English note is not a ruby", () => {
assert.deepEqual(findFurigana("the set {x|y}"), []);
});
it("refuses an empty reading and a span across lines", () => {
assert.deepEqual(findFurigana("{漢字|}"), []);
assert.deepEqual(findFurigana("{漢字|かん||じ}"), []);
assert.deepEqual(findFurigana("{漢字\n|かんじ}"), []);
});
it("finds several on one line, in order", () => {
const spans = findFurigana("{日本|にほん}の{漢字|かんじ}");
assert.deepEqual(spans.map((s) => s.base), ["日本", "漢字"]);
});
});
describe("furiganaSpanAt()", () => {
const text = "これは{漢字|かんじ}のテストです。";
it("hands a selection inside a span the whole span, so a second visit edits it", () => {
// 漢 alone, the reading, the closing brace: all the one span.
for (const [from, to] of [[4, 5], [7, 10], [10, 11], [3, 11]]) {
const span = furiganaSpanAt(text, from, to);
assert.equal(span?.start, 3);
assert.equal(span?.end, 11);
assert.deepEqual(span?.readings, ["かんじ"]);
}
});
it("answers null beside a span and on a line without one", () => {
assert.equal(furiganaSpanAt(text, 0, 3), null);
assert.equal(furiganaSpanAt(text, 11, 14), null);
assert.equal(furiganaSpanAt("漢字のテスト", 0, 2), null);
});
});
describe("serialiseFurigana() and stripFurigana()", () => {
it("writes the plugin's spelling", () => {
assert.equal(serialiseFurigana("漢字", ["かんじ"]), "{漢字|かんじ}");
assert.equal(serialiseFurigana("漢字", ["かん", "じ"]), "{漢字|かん|じ}");
});
it("round-trips", () => {
const text = serialiseFurigana("食べ物", ["た", "もの"]);
const [span] = findFurigana(text);
assert.equal(serialiseFurigana(span.base, span.readings), text);
});
it("strips to the base for outlines and search", () => {
assert.equal(stripFurigana("{日本|にほん}の{漢字|かんじ}を読む"), "日本の漢字を読む");
assert.equal(stripFurigana("no furigana here"), "no furigana here");
});
});
describe("rubySegments()", () => {
it("puts one reading over the whole base", () => {
assert.deepEqual(rubySegments("漢字", ["かんじ"]), [{ text: "漢字", rt: "かんじ" }]);
});
it("puts one reading over each kanji and skips the kana", () => {
assert.deepEqual(rubySegments("食べ物", ["た", "もの"]), [
{ text: "食", rt: "た" },
{ text: "べ", rt: null },
{ text: "物", rt: "もの" },
]);
});
it("falls back to the joined reading when the count is off", () => {
assert.deepEqual(rubySegments("漢字", ["か", "ん", "じ"]), [{ text: "漢字", rt: "かんじ" }]);
});
});
// A small table in the generator's shape: enough to hold the ordering rules.
const TABLE: ReadingsTable = {
漢: { k: [], o: ["かん"] },
字: { k: ["あざ", "あざな", "な"], o: ["じ"] },
食: { k: ["く", "た", "は"], o: ["しょく", "じき"], x: [["う", "らう"], ["べる"], ["む"]] },
山: { k: ["やま"], o: ["さん", "せん"] },
物: { k: ["もの"], o: ["ぶつ", "もつ"] },
};
describe("suggestReadings()", () => {
it("puts on'yomi first in a compound", () => {
assert.deepEqual(suggestReadings("漢字", 1, TABLE), ["じ", "あざ", "あざな", "な"]);
assert.deepEqual(suggestReadings("漢字", 0, TABLE), ["かん"]);
});
it("puts kun'yomi first for a lone kanji", () => {
assert.deepEqual(suggestReadings("山", 0, TABLE), ["やま", "さん", "せん"]);
});
it("puts the kun reading whose okurigana follow at the very front", () => {
assert.equal(suggestReadings("食", 0, TABLE, "べる")[0], "た");
assert.equal(suggestReadings("食", 0, TABLE, "べた")[0], "た"); // inflected
assert.equal(suggestReadings("食", 0, TABLE, "う")[0], "く");
assert.equal(suggestReadings("食", 0, TABLE, "む")[0], "は");
// No kana after it: KANJIDIC's own order stands.
assert.equal(suggestReadings("食", 0, TABLE)[0], "く");
});
it("answers nothing for a kanji the table does not know", () => {
assert.deepEqual(suggestReadings("龍", 0, TABLE), []);
});
});
describe("kanjiRuns() and autoFurigana()", () => {
it("splits on kana and carries what follows", () => {
const runs = kanjiRuns("食べ物を漢字で");
assert.deepEqual(
runs.map((r) => [r.text, r.start, r.end, r.after]),
[["食", 0, 1, "べ物を漢"], ["物", 2, 3, "を漢字で"], ["漢字", 4, 6, "で"]],
);
});
it("writes the first suggestion over every known run, one reading per word", () => {
const { text, count } = autoFurigana("食べ物を漢字で", TABLE);
assert.equal(text, "{食|た}べ{物|もの}を{漢字|かんじ}で");
assert.equal(count, 3);
});
it("wraps only the runs inside the range but reads past its end", () => {
// The selection ends right after 食; the べ after it still picks た.
const { text, count } = autoFurigana("食べ物", TABLE, { from: 0, to: 1 });
assert.equal(text, "{食|た}べ物");
assert.equal(count, 1);
});
it("leaves a run the table does not fully know, and one already annotated", () => {
const { text, count } = autoFurigana("{漢字|かんじ}と龍山", TABLE);
assert.equal(text, "{漢字|かんじ}と龍山");
assert.equal(count, 0);
});
it("hasKanji() is what gates the menu row", () => {
assert.equal(hasKanji("ひらがな only"), false);
assert.equal(hasKanji("漢"), true);
});
});
describe("toHiragana()", () => {
it("shifts katakana and keeps the long-vowel mark", () => {
assert.equal(toHiragana("カンジ"), "かんじ");
assert.equal(toHiragana("コーヒー"), "こーひー");
assert.equal(toHiragana("すでに"), "すでに");
});
});
describe("spellcheckLang() names Japanese", () => {
it("after the RTL scripts, and not for Latin", () => {
assert.equal(spellcheckLang("これは漢字です"), "ja");
assert.equal(spellcheckLang("カタカナ"), "ja");
assert.equal(spellcheckLang("カタカナ(半角)"), "ja");
assert.equal(spellcheckLang("An English line"), null);
// An Arabic line quoting a Japanese word is an Arabic line.
assert.equal(spellcheckLang("كلمة 漢字"), "ar");
// An English sentence quoting one kanji keeps its own face; a Japanese
// line naming a Latin thing is still Japanese.
assert.equal(spellcheckLang("A plain 漢 written by hand"), null);
assert.equal(spellcheckLang("これはCSSのテストです"), "ja");
assert.equal(spellcheckLang("{漢字|かんじ} = kanji"), "ja");
});
});
describe("gen-kanji-readings", () => {
const FIXTURE = `<?xml version="1.0"?>
<kanjidic2>
<header><file_version>4</file_version><database_version>2026-258</database_version></header>
<character>
<literal>食</literal>
<misc><grade>2</grade></misc>
<reading_meaning><rmgroup>
<reading r_type="pinyin">shi2</reading>
<reading r_type="ja_on">ショク</reading>
<reading r_type="ja_on">ジキ</reading>
<reading r_type="ja_kun">く.う</reading>
<reading r_type="ja_kun">く.らう</reading>
<reading r_type="ja_kun">た.べる</reading>
<reading r_type="ja_kun">は.む</reading>
</rmgroup></reading_meaning>
</character>
<character>
<literal>亜</literal>
<misc><grade>8</grade></misc>
<reading_meaning><rmgroup>
<reading r_type="ja_on">ア</reading>
<reading r_type="ja_kun">つ.ぐ</reading>
</rmgroup></reading_meaning>
</character>
<character>
<literal>方</literal>
<misc><grade>2</grade></misc>
<reading_meaning><rmgroup>
<reading r_type="ja_on">ホウ</reading>
<reading r_type="ja_kun">かた</reading>
<reading r_type="ja_kun">-かた</reading>
<reading r_type="ja_kun">-がた</reading>
</rmgroup></reading_meaning>
</character>
<character>
<literal>凪</literal>
<misc><grade>9</grade></misc>
<reading_meaning><rmgroup>
<reading r_type="ja_kun">なぎ</reading>
</rmgroup></reading_meaning>
</character>
<character>
<literal>丂</literal>
<misc></misc>
</character>
</kanjidic2>`;
const table = readingsFromXml(FIXTURE) as {
source: string;
license: string;
version: string;
count: number;
kanji: ReadingsTable;
};
it("keeps jōyō grades only and counts them", () => {
assert.deepEqual(Object.keys(table.kanji).sort(), ["亜", "方", "食"]);
assert.equal(table.count, 3);
assert.equal(table.version, "2026-258");
});
it("writes hiragana on'yomi and okurigana-free kun'yomi, in KANJIDIC order", () => {
assert.deepEqual(table.kanji["食"], {
k: ["く", "た", "は"],
o: ["しょく", "じき"],
x: [["う", "らう"], ["べる"], ["む"]],
});
assert.deepEqual(table.kanji["亜"], { k: ["つ"], o: ["あ"], x: [["ぐ"]] });
});
it("drops the prefix/suffix dashes and folds the duplicates they leave", () => {
assert.deepEqual(table.kanji["方"], { k: ["かた", "がた"], o: ["ほう"] });
});
it("carries the EDRDG attribution the licence requires", () => {
assert.match(table.source, /KANJIDIC2/);
assert.match(table.source, /EDRDG/);
assert.match(table.license, /CC BY-SA 4\.0/);
});
});