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 → allowed (polysemy). if _, err := loadGlossarySeed(writeSeed(t, ` terms: - { src: 道, dst: путь, sense: way } - { src: 道, dst: дао, sense: dao } `)); err != nil { t.Errorf("distinct senses should be allowed: %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)") } } // 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) } }