395 lines
18 KiB
Go
395 lines
18 KiB
Go
package pipeline
|
||
|
||
import (
|
||
"os"
|
||
"path/filepath"
|
||
"testing"
|
||
|
||
"textmachine/backend/internal/store"
|
||
)
|
||
|
||
func writeSeed(t *testing.T, content string) string {
|
||
t.Helper()
|
||
p := filepath.Join(t.TempDir(), "glossary.yaml")
|
||
if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
return p
|
||
}
|
||
|
||
func TestLoadGlossarySeed(t *testing.T) {
|
||
seed := writeSeed(t, `
|
||
terms:
|
||
- src: 阿Q
|
||
dst: А-кью
|
||
type: name
|
||
decl: { invariant: true, forms: ["А-кью"] }
|
||
aliases:
|
||
- { alias: 阿桂, type: прозвище }
|
||
- src: 王胡
|
||
dst: Бородатый Ван
|
||
status: draft
|
||
decl: { forms: ["Бородатый Ван", "Бородатого Вана"] }
|
||
`)
|
||
entries, err := loadGlossarySeed(seed)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(entries) != 2 {
|
||
t.Fatalf("want 2 entries, got %d", len(entries))
|
||
}
|
||
// Empty status defaults to approved (a manual seed is curated).
|
||
if entries[0].Status != "approved" {
|
||
t.Errorf("default status = %q, want approved", entries[0].Status)
|
||
}
|
||
if entries[0].Source != "seed" {
|
||
t.Errorf("source = %q, want seed", entries[0].Source)
|
||
}
|
||
if len(entries[0].Aliases) != 1 || entries[0].Aliases[0].Alias != "阿桂" {
|
||
t.Errorf("alias not parsed: %+v", entries[0].Aliases)
|
||
}
|
||
if entries[0].Decl == "" {
|
||
t.Error("decl JSON not marshaled")
|
||
}
|
||
if entries[1].Status != "draft" {
|
||
t.Errorf("explicit status not honored: %q", entries[1].Status)
|
||
}
|
||
}
|
||
|
||
func TestLoadGlossarySeedRejectsBad(t *testing.T) {
|
||
// Missing src → fail loud (a silently-dropped term is the A-class hole).
|
||
if _, err := loadGlossarySeed(writeSeed(t, "terms:\n - dst: без ключа\n")); err == nil {
|
||
t.Error("expected error for a term without src")
|
||
}
|
||
// Unknown status → fail loud.
|
||
if _, err := loadGlossarySeed(writeSeed(t, "terms:\n - src: 甲\n status: bogus\n")); err == nil {
|
||
t.Error("expected error for an unknown status")
|
||
}
|
||
}
|
||
|
||
// TestSeedAliasDedupAndDuplicateTerm covers self-review #5: a duplicate alias within a
|
||
// term must be deduped (not crash the run on UNIQUE), and a duplicate (src,sense,window)
|
||
// term must fail loud (not crash mid-run).
|
||
func TestSeedAliasDedupAndDuplicateTerm(t *testing.T) {
|
||
// Duplicate alias surface (annotated with two types) → deduped, no error.
|
||
entries, err := loadGlossarySeed(writeSeed(t, `
|
||
terms:
|
||
- src: 四叔
|
||
dst: Четвёртый дядюшка
|
||
aliases:
|
||
- { alias: 鲁四老爷, type: имя }
|
||
- { alias: 鲁四老爷, type: титул }
|
||
`))
|
||
if err != nil {
|
||
t.Fatalf("duplicate alias should be deduped, not error: %v", err)
|
||
}
|
||
if len(entries) != 1 || len(entries[0].Aliases) != 1 {
|
||
t.Fatalf("alias not deduped: %+v", entries)
|
||
}
|
||
// Duplicate (src, sense, window) → fail loud.
|
||
if _, err := loadGlossarySeed(writeSeed(t, `
|
||
terms:
|
||
- { src: 道, dst: путь }
|
||
- { src: 道, dst: дао }
|
||
`)); err == nil {
|
||
t.Error("duplicate (src,sense,window) term must fail loud")
|
||
}
|
||
// Same src, different SENSE on a SINGLE-CHAR Han src (道): the src is A3-banned
|
||
// (significantLen 1 < min-key 2), so it never enters the automaton → its polysemy is
|
||
// inert and cannot livelock → still allowed (D16.1 fail-loud is scoped to a matchable src).
|
||
if _, err := loadGlossarySeed(writeSeed(t, `
|
||
terms:
|
||
- { src: 道, dst: путь, sense: way }
|
||
- { src: 道, dst: дао, sense: dao }
|
||
`)); err != nil {
|
||
t.Errorf("polysemy on a single-key-banned src is inert and must stay allowed: %v", err)
|
||
}
|
||
}
|
||
|
||
// TestSeedPolysemyOverlapFailsLoud covers D16.1: two approved rows with the same MATCHABLE
|
||
// src, DIFFERENT senses, DIFFERENT dst and OVERLAPPING spoiler windows both inject as
|
||
// CONFIRMED (the matcher keys only on src) and the post-check is guaranteed to miss one —
|
||
// with the gate ON a deterministic chunk livelock. Fail loud. Reverting the different-sense
|
||
// branch in loadGlossarySeed passes the contradiction through and fails here.
|
||
func TestSeedPolysemyOverlapFailsLoud(t *testing.T) {
|
||
// Matchable src (神通, 2 Han ≥ min-key), different senses, default (overlapping) windows,
|
||
// different dst → livelock → fail loud.
|
||
if _, err := loadGlossarySeed(writeSeed(t, `
|
||
terms:
|
||
- { src: 神通, dst: сила, sense: power }
|
||
- { src: 神通, dst: чудо, sense: miracle }
|
||
`)); err == nil {
|
||
t.Error("D16.1: polysemy on a matchable src with overlapping windows must fail loud")
|
||
}
|
||
// Same src+senses but NON-overlapping windows is a legitimate spoiler-boundary rendering
|
||
// change (only one sense can fire per chapter) → allowed.
|
||
if _, err := loadGlossarySeed(writeSeed(t, `
|
||
terms:
|
||
- { src: 神通, dst: сила, sense: power, until_ch: 5 }
|
||
- { src: 神通, dst: чудо, sense: miracle, since_ch: 6 }
|
||
`)); err != nil {
|
||
t.Errorf("polysemy with non-overlapping windows should be allowed: %v", err)
|
||
}
|
||
// Same MATCHABLE src+sense with the SAME dst and overlapping windows → NOT a contradiction
|
||
// (both render identically), allowed.
|
||
if _, err := loadGlossarySeed(writeSeed(t, `
|
||
terms:
|
||
- { src: 神通, dst: сила, sense: power, until_ch: 5 }
|
||
- { src: 神通, dst: сила, sense: might, since_ch: 3 }
|
||
`)); err != nil {
|
||
t.Errorf("same-dst overlap is not a contradiction: %v", err)
|
||
}
|
||
}
|
||
|
||
// TestSeedApprovedEmptyDstFailsLoud covers external-review minor #5.
|
||
func TestSeedApprovedEmptyDstFailsLoud(t *testing.T) {
|
||
for _, status := range []string{"approved", "draft"} {
|
||
if _, err := loadGlossarySeed(writeSeed(t, "terms:\n - { src: 甲, status: "+status+" }\n")); err == nil {
|
||
t.Errorf("a %s term with an empty dst must fail loud (silently inert otherwise)", status)
|
||
}
|
||
}
|
||
// Only status=auto (a raw candidate — e.g. a ruby reading) may have an empty dst.
|
||
if _, err := loadGlossarySeed(writeSeed(t, "terms:\n - { src: 甲, status: auto }\n")); err != nil {
|
||
t.Errorf("an auto candidate may have an empty dst: %v", err)
|
||
}
|
||
}
|
||
|
||
// TestSeedOverlappingSpoilerWindowsFailLoud covers external-review minor #4.
|
||
func TestSeedOverlappingSpoilerWindowsFailLoud(t *testing.T) {
|
||
// Same src+sense, overlapping windows [0,5] and [3,10], DIFFERENT dst → contradiction.
|
||
if _, err := loadGlossarySeed(writeSeed(t, `
|
||
terms:
|
||
- { src: 影卫, dst: страж, until_ch: 5 }
|
||
- { src: 影卫, dst: тень, since_ch: 3, until_ch: 10 }
|
||
`)); err == nil {
|
||
t.Error("overlapping spoiler windows with different dst must fail loud")
|
||
}
|
||
// Non-overlapping windows (a rendering that changes after a spoiler boundary) → allowed.
|
||
if _, err := loadGlossarySeed(writeSeed(t, `
|
||
terms:
|
||
- { src: 影卫, dst: страж, until_ch: 5 }
|
||
- { src: 影卫, dst: предатель, since_ch: 6 }
|
||
`)); err != nil {
|
||
t.Errorf("non-overlapping windows should be allowed: %v", err)
|
||
}
|
||
}
|
||
|
||
func TestClassifyRubyReading(t *testing.T) {
|
||
cases := []struct {
|
||
base, reading, want string
|
||
}{
|
||
{"鈴木", "すずき", rubyClassName}, // all-Han base + all-kana reading → name candidate
|
||
{"強敵", "とも", rubyClassName}, // SAME shape (a double-reading!) — deliberately NOT distinguishable offline → still a CANDIDATE, never auto-locked
|
||
{"強敵", "きょうてき", rubyClassName}, // real reading, same class
|
||
{"泣蟲", "泣き虫", rubyClassGloss}, // reading carries kanji → a semantic gloss/double-reading → footnote, not a lock
|
||
{"ゲーム", "gēmu", rubyClassAmbiguous}, // base is kana → not a plain furigana-name
|
||
}
|
||
for _, c := range cases {
|
||
if got := classifyRubyReading(c.base, c.reading); got != c.want {
|
||
t.Errorf("classifyRubyReading(%q,%q) = %q; want %q", c.base, c.reading, got, c.want)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestRubyToCandidates(t *testing.T) {
|
||
readings := []store.RubyReading{
|
||
{BookID: "b", Base: "鈴木", Reading: "すずき", FirstChapter: 2, Occurrences: 9},
|
||
{BookID: "b", Base: "鈴木", Reading: "スズキ", FirstChapter: 5, Occurrences: 3}, // variant → deduped by base
|
||
{BookID: "b", Base: "田中", Reading: "たなか", FirstChapter: 1, Occurrences: 4},
|
||
{BookID: "b", Base: "図書館", Reading: "としょかん", FirstChapter: 1, Occurrences: 20},
|
||
}
|
||
// 図書館 is owned by the manual seed → its ruby candidate is skipped.
|
||
manual := map[string]bool{"図書館": true}
|
||
cands := rubyToCandidates(readings, manual)
|
||
|
||
if len(cands) != 2 {
|
||
t.Fatalf("want 2 candidates (鈴木, 田中; 図書館 skipped), got %d: %+v", len(cands), cands)
|
||
}
|
||
byBase := map[string]store.GlossaryEntry{}
|
||
for _, c := range cands {
|
||
byBase[c.Src] = c
|
||
if c.Status != "auto" || c.Source != "ruby" || c.Dst != "" {
|
||
t.Errorf("ruby candidate must be auto/ruby with no dst: %+v", c)
|
||
}
|
||
}
|
||
// 鈴木 deduped to the DOMINANT reading (すずき, 9 occ > スズキ, 3) with the earliest chapter.
|
||
suzuki := byBase["鈴木"]
|
||
if suzuki.RubyReading != "すずき" {
|
||
t.Errorf("dominant reading = %q, want すずき", suzuki.RubyReading)
|
||
}
|
||
if suzuki.SinceCh != 2 {
|
||
t.Errorf("since_ch = %d, want the earliest (2)", suzuki.SinceCh)
|
||
}
|
||
if suzuki.Confidence != 9 {
|
||
t.Errorf("confidence = %d, want 9 (occurrences of the dominant reading)", suzuki.Confidence)
|
||
}
|
||
if suzuki.RubyClass != rubyClassName {
|
||
t.Errorf("ruby_class = %q, want %q", suzuki.RubyClass, rubyClassName)
|
||
}
|
||
if _, skipped := byBase["図書館"]; skipped {
|
||
t.Error("図書館 should have been skipped (owned by the manual seed)")
|
||
}
|
||
}
|
||
|
||
// TestApprovedSharedKeyCollisions covers the Task-6 re-audit finding: a firing key shared by
|
||
// two DIFFERENT approved terms with different dst + overlapping windows is the alias
|
||
// generalization of the D16.1 polysemy livelock. Reverting approvedSharedKeyCollisions (or its
|
||
// seedGlossary wiring) admits the contradictory injection. Legitimate cases (same dst,
|
||
// non-overlapping windows) and the same-src case (D16.1's job) must NOT be flagged here.
|
||
func TestApprovedSharedKeyCollisions(t *testing.T) {
|
||
al := func(a string) []store.GlossaryAlias {
|
||
return []store.GlossaryAlias{{Alias: a, AliasType: "прозвище"}}
|
||
}
|
||
// Shared alias 老赵 across two different approved terms, different dst, default (overlapping)
|
||
// windows → collision (livelock).
|
||
e1 := store.GlossaryEntry{Src: "赵甲", Dst: "Чжао Цзя", Status: "approved", Aliases: al("老赵")}
|
||
e2 := store.GlossaryEntry{Src: "赵乙", Dst: "Чжао И", Status: "approved", Aliases: al("老赵")}
|
||
if cols := approvedSharedKeyCollisions([]store.GlossaryEntry{e1, e2}); len(cols) == 0 {
|
||
t.Error("shared alias 老赵 across different terms must be flagged (livelock)")
|
||
}
|
||
// Same dst → a legitimate merge (both render identically), no contradiction.
|
||
e2same := e2
|
||
e2same.Dst = "Чжао Цзя"
|
||
if cols := approvedSharedKeyCollisions([]store.GlossaryEntry{e1, e2same}); len(cols) != 0 {
|
||
t.Errorf("same-dst shared surface is a merge, not a collision: %v", cols)
|
||
}
|
||
// Non-overlapping windows → a spoiler-boundary handoff (only one fires per chapter) → allowed.
|
||
e1w, e2w := e1, e2
|
||
e1w.UntilCh, e2w.SinceCh = 5, 6
|
||
if cols := approvedSharedKeyCollisions([]store.GlossaryEntry{e1w, e2w}); len(cols) != 0 {
|
||
t.Errorf("non-overlapping windows are allowed: %v", cols)
|
||
}
|
||
// Self-review #1: a SUB-FLOOR src (道, single-key-banned) that shares an ELIGIBLE alias
|
||
// (大道) between different-dst rows slips loadGlossarySeed's src-scoped D16.1 check, so this
|
||
// guard must catch it via the shared alias key (livelock).
|
||
s1 := store.GlossaryEntry{Src: "道", Dst: "путь", Sense: "way", Status: "approved", Aliases: al("大道")}
|
||
s2 := store.GlossaryEntry{Src: "道", Dst: "дао", Sense: "dao", Status: "approved", Aliases: al("大道")}
|
||
if cols := approvedSharedKeyCollisions([]store.GlossaryEntry{s1, s2}); len(cols) == 0 {
|
||
t.Error("#1: sub-floor src sharing an eligible alias with a different dst must be flagged")
|
||
}
|
||
// The same sub-floor src with DISTINCT aliases is fine (道 does not fire, no shared key).
|
||
d1 := store.GlossaryEntry{Src: "道", Dst: "путь", Sense: "way", Status: "approved", Aliases: al("正道")}
|
||
d2 := store.GlossaryEntry{Src: "道", Dst: "дао", Sense: "dao", Status: "approved", Aliases: al("大道")}
|
||
if cols := approvedSharedKeyCollisions([]store.GlossaryEntry{d1, d2}); len(cols) != 0 {
|
||
t.Errorf("sub-floor src with distinct aliases has no shared firing key: %v", cols)
|
||
}
|
||
// A same-src pair with an ELIGIBLE shared src key IS a real collision in isolation (in the
|
||
// live flow loadGlossarySeed's D16.1 check fails loud first, so no double report).
|
||
p1 := store.GlossaryEntry{Src: "神通", Dst: "сила", Sense: "a", Status: "approved"}
|
||
p2 := store.GlossaryEntry{Src: "神通", Dst: "чудо", Sense: "b", Status: "approved"}
|
||
if cols := approvedSharedKeyCollisions([]store.GlossaryEntry{p1, p2}); len(cols) == 0 {
|
||
t.Error("an eligible shared src key is a real collision here too")
|
||
}
|
||
// An auto term sharing the key is not CONFIRMED, so no authoritative contradiction → allowed.
|
||
a1 := store.GlossaryEntry{Src: "钱甲", Dst: "Цянь А", Status: "approved", Aliases: al("老钱")}
|
||
a2 := store.GlossaryEntry{Src: "钱乙", Dst: "Цянь Б", Status: "auto", Aliases: al("老钱")}
|
||
if cols := approvedSharedKeyCollisions([]store.GlossaryEntry{a1, a2}); len(cols) != 0 {
|
||
t.Errorf("an auto term sharing a key is not a CONFIRMED contradiction: %v", cols)
|
||
}
|
||
}
|
||
|
||
// TestRubyReadingBecomesManualAlias covers D16.4: a manual term's name-shape ruby reading
|
||
// (all-Han base + all-kana reading) becomes a matchable ALIAS so the kana spelling of a
|
||
// seeded name is found. A kanji-bearing gloss/double-reading is NOT attached (footnote, not
|
||
// a surface), and a reading whose base is not a manual term is left to the auto-candidate
|
||
// path. Reverting attachRubyAliasesToManual leaves the manual entry alias-less and fails here.
|
||
func TestRubyReadingBecomesManualAlias(t *testing.T) {
|
||
entries := []store.GlossaryEntry{
|
||
{BookID: "b", Src: "鈴木", Dst: "Судзуки", Status: "approved", Source: "seed"},
|
||
{BookID: "b", Src: "泣蟲", Dst: "плакса", Status: "approved", Source: "seed"},
|
||
}
|
||
readings := []store.RubyReading{
|
||
{BookID: "b", Base: "鈴木", Reading: "すずき", FirstChapter: 1, Occurrences: 5}, // name-shape → alias
|
||
{BookID: "b", Base: "泣蟲", Reading: "泣き虫", FirstChapter: 1, Occurrences: 2}, // gloss (carries kanji) → NOT an alias
|
||
{BookID: "b", Base: "田中", Reading: "たなか", FirstChapter: 1, Occurrences: 3}, // base not manual → skipped (auto path)
|
||
}
|
||
attachRubyAliasesToManual(entries, readings)
|
||
|
||
if !hasAliasSurface(entries[0].Aliases, "すずき") {
|
||
t.Errorf("鈴木 did not gain its kana reading as an alias: %+v", entries[0].Aliases)
|
||
}
|
||
if hasAliasSurface(entries[1].Aliases, "泣き虫") {
|
||
t.Errorf("a kanji-bearing gloss reading must NOT become an alias: %+v", entries[1].Aliases)
|
||
}
|
||
// Idempotent: a second attach must not duplicate the alias.
|
||
attachRubyAliasesToManual(entries, readings)
|
||
n := 0
|
||
for _, a := range entries[0].Aliases {
|
||
if a.Alias == "すずき" {
|
||
n++
|
||
}
|
||
}
|
||
if n != 1 {
|
||
t.Errorf("ruby-alias attach not idempotent: すずき appears %d times", n)
|
||
}
|
||
// End-to-end: the kana spelling now matches through the materialized bank.
|
||
b := materializeMemory(entries, false)
|
||
if _, ok := injMap(b.Select("きのう、すずきに会った。", 1, nil, 0))["鈴木"]; !ok {
|
||
t.Error("kana spelling すずき did not match the manual 鈴木 via the reading alias")
|
||
}
|
||
}
|
||
|
||
// TestRubyHomophoneSkippedNotFailLoud covers the self-review interaction: two homophone seeded
|
||
// names (鈴木/鈴樹 both read すずき, different dst) must NOT both auto-get the kana alias — that
|
||
// would make approvedSharedKeyCollisions fail the whole book loud on an alias the operator
|
||
// cannot edit out. The colliding reading is skipped+reported (graceful), and the resulting
|
||
// entry set passes the shared-key collision check.
|
||
func TestRubyHomophoneSkippedNotFailLoud(t *testing.T) {
|
||
entries := []store.GlossaryEntry{
|
||
{BookID: "b", Src: "鈴木", Dst: "Судзуки", Status: "approved", Source: "seed"},
|
||
{BookID: "b", Src: "鈴樹", Dst: "Судзуки-другой", Status: "approved", Source: "seed"},
|
||
}
|
||
readings := []store.RubyReading{
|
||
{BookID: "b", Base: "鈴木", Reading: "すずき", FirstChapter: 1, Occurrences: 8},
|
||
{BookID: "b", Base: "鈴樹", Reading: "すずき", FirstChapter: 3, Occurrences: 4}, // homophone → collision
|
||
}
|
||
skipped := attachRubyAliasesToManual(entries, readings)
|
||
if len(skipped) != 1 {
|
||
t.Fatalf("want exactly one skipped homophone reading, got %d: %v", len(skipped), skipped)
|
||
}
|
||
// Exactly ONE term got the kana alias (the first, deterministically); the other was skipped.
|
||
got := 0
|
||
for _, e := range entries {
|
||
if hasAliasSurface(e.Aliases, "すずき") {
|
||
got++
|
||
}
|
||
}
|
||
if got != 1 {
|
||
t.Fatalf("want the kana alias on exactly one term, got %d", got)
|
||
}
|
||
// The resulting set must NOT trip the shared-key collision fail-loud (the whole point).
|
||
if cols := approvedSharedKeyCollisions(entries); len(cols) != 0 {
|
||
t.Errorf("homophone skip must avoid the fail-loud, got collisions: %v", cols)
|
||
}
|
||
}
|
||
|
||
// TestSeedSurroundingWhitespaceTrimmed covers the workflow-confirmed seed major: surrounding
|
||
// whitespace on a keying surface passed the emptiness check but was stored raw, so the term
|
||
// keyed WITH the space and could never match (silently inert), and a trailing-space sense
|
||
// disguised a same-sense contradiction. Reverting any of the three trims in loadGlossarySeed
|
||
// fails a branch here.
|
||
func TestSeedSurroundingWhitespaceTrimmed(t *testing.T) {
|
||
// (1) A leading-space src on an approved term must be trimmed, not stored raw — else the
|
||
// key " 強敵" can never match "強敵" and the approved term renders nothing for the whole book.
|
||
entries, err := loadGlossarySeed(writeSeed(t, "terms:\n - { src: \" 強敵\", dst: Соперник }\n"))
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(entries) != 1 || entries[0].Src != "強敵" {
|
||
t.Fatalf("src surrounding whitespace not trimmed: %+v", entries)
|
||
}
|
||
// (2) A trailing-space sense must be trimmed so a same-sense contradiction (same src, same
|
||
// sense, same default window, DIFFERENT dst) is caught by the duplicate-key guard — not
|
||
// stored as two distinct-looking rows that both inject a contradictory rendering.
|
||
if _, err := loadGlossarySeed(writeSeed(t, "terms:\n - { src: 先生, dst: учитель, sense: teacher }\n - { src: 先生, dst: доктор, sense: \"teacher \" }\n")); err == nil {
|
||
t.Error("trailing-space sense must be trimmed so the duplicate (src,sense,window) fails loud")
|
||
}
|
||
// (3) An alias with surrounding whitespace must be trimmed (an alias is itself a match key).
|
||
al, err := loadGlossarySeed(writeSeed(t, "terms:\n - src: 甲\n dst: A\n aliases:\n - { alias: \" 乙 \", type: x }\n"))
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(al) != 1 || len(al[0].Aliases) != 1 || al[0].Aliases[0].Alias != "乙" {
|
||
t.Fatalf("alias surrounding whitespace not trimmed: %+v", al)
|
||
}
|
||
}
|