195 lines
7.1 KiB
Go
195 lines
7.1 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 → 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)")
|
||
}
|
||
}
|