textmachine/backend/internal/pipeline/memseed_test.go

226 lines
9 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)
}
}
// 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)
}
}