textmachine/backend/internal/pipeline/memseed_test.go

162 lines
5.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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)
}
}
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)")
}
}