textmachine/backend/internal/membank/memseed_test.go

697 lines
38 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 membank
import (
"os"
"path/filepath"
"strings"
"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
// (text.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 := Materialize(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 get the kana alias — the reading is
// genuinely ambiguous between them, and attaching it would make ApprovedSharedKeyCollisions fail
// the whole book loud on an alias the operator cannot edit out. External-review MAJOR (homophone
// first-wins): the fix skips the reading for BOTH owners symmetrically (previously the
// alphabetically-first base claimed it and only the latecomer was skipped). Both readings are
// 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)
// BOTH readings are skipped (first-wins fix): neither base may claim a contested reading.
if len(skipped) != 2 {
t.Fatalf("want both homophone readings skipped, got %d: %v", len(skipped), skipped)
}
// NEITHER term got the kana alias.
got := 0
for _, e := range entries {
if hasAliasSurface(e.Aliases, "すずき") {
got++
}
}
if got != 0 {
t.Fatalf("want the contested kana alias on NEITHER 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)
}
}
// TestRubyHomophoneNoWrongConfirmedInjection is the sharp mutation-verifier for the external-review
// MAJOR (homophone first-wins, class A2). Two seeded names 高橋/高梁 both read たかはし (4 kana, so
// the key is NOT collision-prone → it would fire CONFIRMED, not AMBIGUOUS). Under the old
// attach-then-block pass the alphabetically-first base won たかはし as an authoritative alias, so a
// kana chunk injected it as a CONFIRMED (and wrong) rendering for that base. The fix skips the
// reading for both, so NEITHER is injected — the kana form is left unmatchable, never wrongly
// authoritative. Reverting the fix (restoring first-wins) makes one of them fire CONFIRMED here.
func TestRubyHomophoneNoWrongConfirmedInjection(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: 9},
{BookID: "b", Base: "高梁", Reading: "たかはし", FirstChapter: 1, Occurrences: 2},
}
skipped := AttachRubyAliasesToManual(entries, readings)
if len(skipped) != 2 {
t.Fatalf("want both 4-kana homophone readings skipped, got %d: %v", len(skipped), skipped)
}
if cols := ApprovedSharedKeyCollisions(entries); len(cols) != 0 {
t.Errorf("contested reading must not be attached, got collisions: %v", cols)
}
// End-to-end: a kana occurrence of たかはし must NOT inject either name (no wrong authoritative
// rendering). Under first-wins the alphabetically-first base would be present here.
b := Materialize(entries, false)
inj := injMap(b.Select("たかはしが来た。", 1, nil, 0))
if d, ok := inj["高橋"]; ok {
t.Errorf("高橋 wrongly injected (%s) for an ambiguous homophone reading", d)
}
if d, ok := inj["高梁"]; ok {
t.Errorf("高梁 wrongly injected (%s) for an ambiguous homophone reading", d)
}
}
// TestRubyReadingSharedSameDstAttached locks the review minor (window/dst-eligibility awareness):
// a reading shared by two seeded terms that render the SAME dst is NOT a contradiction (both inject
// identically), and a reading shared across NON-overlapping spoiler windows is a legitimate handoff
// — neither is falsely skipped anymore. The old collides() (dst-only, window-blind) skipped both.
func TestRubyReadingSharedSameDstAttached(t *testing.T) {
// Same dst (two kanji spellings of one character) — attach to both.
same := []store.GlossaryEntry{
{BookID: "b", Src: "斉藤", Dst: "Сайто", Status: "approved", Source: "seed"},
{BookID: "b", Src: "齊藤", Dst: "Сайто", Status: "approved", Source: "seed"},
}
sameReadings := []store.RubyReading{
{BookID: "b", Base: "斉藤", Reading: "さいとう", FirstChapter: 1, Occurrences: 4},
{BookID: "b", Base: "齊藤", Reading: "さいとう", FirstChapter: 1, Occurrences: 3},
}
if skipped := AttachRubyAliasesToManual(same, sameReadings); len(skipped) != 0 {
t.Fatalf("same-dst shared reading must not be skipped, got: %v", skipped)
}
for _, e := range same {
if !hasAliasSurface(e.Aliases, "さいとう") {
t.Errorf("same-dst reading not attached to %q: %+v", e.Src, e.Aliases)
}
}
// Different dst but NON-overlapping windows (a spoiler handoff of the same reading) — attach both.
handoff := []store.GlossaryEntry{
{BookID: "b", Src: "序盤", Dst: "Раннее", Status: "approved", Source: "seed", SinceCh: 1, UntilCh: 100},
{BookID: "b", Src: "終盤", Dst: "Позднее", Status: "approved", Source: "seed", SinceCh: 101},
}
handoffReadings := []store.RubyReading{
{BookID: "b", Base: "序盤", Reading: "じょばん", FirstChapter: 1, Occurrences: 5},
{BookID: "b", Base: "終盤", Reading: "じょばん", FirstChapter: 101, Occurrences: 5},
}
if skipped := AttachRubyAliasesToManual(handoff, handoffReadings); len(skipped) != 0 {
t.Fatalf("non-overlapping-window shared reading must not be skipped, got: %v", skipped)
}
}
// 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)
}
}
// TestUnverifiedKeyConflicts pins the rule: every pair rendering one firing surface two ways in
// overlapping windows, except approved×approved (ApprovedSharedKeyCollisions' fail-loud). Both rows are
// injected and a signature changes nothing about that (D39.104 п.2), so an unsigned×unsigned pair is the
// same fault: «старейшина» (draft) against «глава клана» (auto) below.
func TestUnverifiedKeyConflicts(t *testing.T) {
rows := []store.GlossaryEntry{
{Src: "族長", Dst: "глава клана", Status: "approved"},
{Src: "族長", Dst: "старейшина", Status: "draft"}, // ← contradicts BOTH rows that render «глава клана»
{Src: "族長", Dst: "глава клана", Status: "auto"}, // same rendering as the approved row → no contradiction there
{Src: "鈴木", Dst: "Судзуки", Status: "approved"}, // untouched
{Src: "田中", Dst: "Танака", Status: "draft"}, // unsigned but unopposed
}
got := UnverifiedKeyConflicts(rows)
if len(got) != 2 {
t.Fatalf("want the approved×unsigned pair AND the unsigned×unsigned one, got %d: %v", len(got), got)
}
joined := strings.Join(got, " | ")
// The approved×unsigned pair (the original S14 guarantee).
if !strings.Contains(joined, `approved "族長"→"глава клана"`) || !strings.Contains(joined, `unsigned draft "族長"→"старейшина"`) {
t.Errorf("the approved×unsigned contradiction must still be named in full: %v", got)
}
// The unsigned×unsigned pair: neither side is signed, and an unsigned term owes one rendering too.
if !strings.Contains(joined, `unsigned draft "族長"→"старейшина" [ch 1..end] vs unsigned auto "族長"→"глава клана"`) {
t.Errorf("the unsigned×unsigned contradiction must be reported with both statuses: %v", got)
}
// Every message must locate the fault, or an operator cannot tell which two rows to reconcile.
for _, m := range got {
if !strings.Contains(m, `firing key "族长"`) || strings.Count(m, "[ch ") != 2 {
t.Errorf("a conflict must name its firing key and both windows: %q", m)
}
}
// approved×approved belongs to the fail-loud check; reporting it here too would state one fault twice.
both := []store.GlossaryEntry{
{Src: "族長", Dst: "глава клана", Status: "approved"},
{Src: "族長", Dst: "старейшина", Status: "approved", Sense: "elder"},
}
if got := UnverifiedKeyConflicts(both); len(got) != 0 {
t.Errorf("approved×approved belongs to the fail-loud check, not here: %v", got)
}
if cols := ApprovedSharedKeyCollisions(both); len(cols) != 1 {
t.Errorf("control: the fail-loud check must be the one that sees it, got %d", len(cols))
}
// The approved-only checks are blind to the original fixture.
if cols := ApprovedSharedKeyCollisions(rows); len(cols) != 0 {
t.Fatalf("premise broken: the approved-only check should see nothing here, got %v", cols)
}
// Non-overlapping spoiler windows are not a contradiction (the two never inject together).
windowed := []store.GlossaryEntry{
{Src: "族長", Dst: "глава клана", Status: "approved", SinceCh: 1, UntilCh: 3},
{Src: "族長", Dst: "старейшина", Status: "draft", SinceCh: 4},
}
if got := UnverifiedKeyConflicts(windowed); len(got) != 0 {
t.Fatalf("disjoint windows are not a contradiction, got %v", got)
}
// CONTROL: the same two rows with OVERLAPPING windows are reported, so the zero above is the window
// test answering and not a fixture that reaches nothing.
windowed[1].SinceCh = 3
if got := UnverifiedKeyConflicts(windowed); len(got) != 1 {
t.Fatalf("control: overlapping windows must be reported, got %d: %v", len(got), got)
}
// A pair sharing TWO firing keys is one contradiction, reported once under the first key.
twice := []store.GlossaryEntry{
{Src: "赵甲", Dst: "Чжао Цзя", Status: "draft", Aliases: []store.GlossaryAlias{alias("老赵"), alias("赵老")}},
{Src: "赵乙", Dst: "Чжао И", Status: "auto", Aliases: []store.GlossaryAlias{alias("老赵"), alias("赵老")}},
}
switch n := len(UnverifiedKeyConflicts(twice)); {
case n == 0:
t.Error("a pair sharing two firing keys must still be reported")
case n != 1:
t.Errorf("a pair sharing two keys is one fault, not %d", n)
}
}
// TestApprovedSharedKeyCollisionSeesAllowShort closes a mutation that SURVIVED the cold-run session's
// table: dropping `|| e.AllowShort` from entryFiringKeys broke no test, so the collision check's view of
// an allow_short key was unpinned. It matters precisely for the cold run's live signature — 蛊/转 reach
// the matcher ONLY through that override (D39.50 п.5), and a livelock the check cannot see is a livelock
// that ships.
func TestApprovedSharedKeyCollisionSeesAllowShort(t *testing.T) {
rows := []store.GlossaryEntry{
{Src: "蛊", Dst: "гу", Status: "approved", Sense: "существо", AllowShort: true},
{Src: "蛊", Dst: "Гу", Status: "approved", Sense: "род", AllowShort: true},
}
if got := ApprovedSharedKeyCollisions(rows); len(got) != 1 {
t.Fatalf("two signed senses of the same allow_short key must be reported once, got %d: %v", len(got), got)
}
// Without the override the key cannot fire at all, so the same pair is inert and must NOT be reported
// (the check mirrors the matcher — over-reporting here fails a load that would have run fine).
rows[0].AllowShort, rows[1].AllowShort = false, false
if got := ApprovedSharedKeyCollisions(rows); len(got) != 0 {
t.Fatalf("a single-key-BANNED shared src is inert and must not fail the load, got: %v", got)
}
}
// TestPolysemyGuardIsStatusBlind pins three facts together: the D16.1 refusal fires for every status, an
// unsigned row really is injected beside the approved one (D39.104 п.2 — no fork by status on the wire),
// and the remedies the message names therefore do not include a status. Break any one and the message
// starts advising an operator to do something that cannot work.
func TestPolysemyGuardIsStatusBlind(t *testing.T) {
seedFor := func(status string) string {
return "terms:\n - { src: 神通, dst: сила, sense: power }\n - { src: 神通, dst: чудо, sense: miracle, status: " + status + " }\n"
}
for _, status := range []string{"approved", "draft", "auto"} {
_, err := LoadGlossarySeed(writeSeed(t, seedFor(status)))
if err == nil {
t.Fatalf("status %q: the polysemy livelock must be refused whatever the second row's signature", status)
}
if strings.Contains(err.Error(), "status: auto") || strings.Contains(err.Error(), "mark one status") {
t.Errorf("status %q: the refusal must not offer a STATUS as a remedy — the guard ignores it and so does the wire: %s", status, err)
}
}
// CONTROL for the loop above: the same two senses with NON-overlapping windows are a legitimate
// spoiler handoff and load, so the refusals above are the guard speaking and not a broken fixture.
if _, err := LoadGlossarySeed(writeSeed(t, "terms:\n - { src: 神通, dst: сила, sense: power, until_ch: 5 }\n - { src: 神通, dst: чудо, sense: miracle, since_ch: 6, status: auto }\n")); err != nil {
t.Fatalf("control: non-overlapping windows must still load: %v", err)
}
// WHY a status cannot be the remedy: an unsigned row is injected as law beside the approved one, so
// the model is handed both renderings exactly as it would be for two approved rows.
for _, status := range []string{"approved", "draft", "auto"} {
b := bankFrom([]store.GlossaryEntry{gl("神通", "сила", "power", "approved"), gl("神通", "чудо", "miracle", status)})
if n := len(b.Select("他用神通打败了对手。", 1, nil, 0).Injected); n != 2 {
t.Errorf("status %q: both renderings must reach the wire (D39.104 п.2 — no fork by status), got %d injected", status, n)
}
}
// CONTROL for that loop: with the src absent from the chunk nothing fires, so the count of 2 above
// is the matcher answering and not a bank that injects unconditionally.
if n := len(bankFrom([]store.GlossaryEntry{gl("神通", "сила", "power", "approved")}).Select("他走了。", 1, nil, 0).Injected); n != 0 {
t.Errorf("control: a src absent from the chunk must inject nothing, got %d", n)
}
}
// TestConsolidationKeyConflicts covers the four states the check has to tell apart: a disagreement, a
// legitimate agreement, a legitimate spoiler handoff, and a REPLACEMENT that only looks like a conflict.
// Neither side is asked for a signature.
func TestConsolidationKeyConflicts(t *testing.T) {
bank := []store.GlossaryEntry{
{Src: "元始", Dst: "юаньши", Status: "draft", Source: "seed", SinceCh: 3, UntilCh: 9},
{Src: "神通", Dst: "сила", Status: "approved", Source: "seed", UntilCh: 20},
{Src: "赵甲", Dst: "Чжао Цзя", Status: "auto", Source: "mined", Aliases: []store.GlossaryAlias{alias("老赵")}},
}
// A rendering that disagrees with an UNSIGNED bank row in an overlapping window.
got := ConsolidationKeyConflicts([]store.GlossaryEntry{{Src: "元始", Dst: "камень первоисточника"}}, bank)
if len(got) != 1 {
t.Fatalf("an unsigned row is owed one rendering too: got %d %v", len(got), got)
}
for _, want := range []string{`firing key "元始"`, "камень первоисточника", "юаньши", "unsigned draft", "[ch 3..9]", "[ch 1..end]"} {
if !strings.Contains(got[0].Message(), want) {
t.Errorf("the message must carry %q so the operator can reconcile the two rows: %q", want, got[0].Message())
}
}
// The sheet prints only the existing row and the log prints the whole sentence, so the sheet's half
// must be a literal part of the log's — otherwise one fault reads as two facts on two surfaces.
if !strings.Contains(got[0].Message(), got[0].BankRowLabel()) {
t.Errorf("the sheet label must be the log sentence's own words: label=%q message=%q", got[0].BankRowLabel(), got[0].Message())
}
if got[0].Src != "元始" || got[0].BankDst != "юаньши" || got[0].Key != "元始" {
t.Errorf("the parts must identify both rows for a caller that formats its own line: %+v", got[0])
}
// The SIGNED side is not a special case. Neither of the collapse stage's own checks reports it (both
// skip the equal-source pair; CanonConflicts is approved-only). The windows differ, so both rows land.
if got := ConsolidationKeyConflicts([]store.GlossaryEntry{{Src: "神通", Dst: "чудо"}}, bank); len(got) != 1 {
t.Errorf("a consolidation contradicting a SIGNED row of the same surface must be reported: %v", got)
}
// Reached through a shared ALIAS rather than the src — the key is the firing surface, not the source.
if got := ConsolidationKeyConflicts([]store.GlossaryEntry{{Src: "赵乙", Dst: "Чжао И",
Aliases: []store.GlossaryAlias{alias("老赵")}}}, bank); len(got) != 1 {
t.Errorf("a contradiction reached through a shared alias must be reported: %v", got)
}
// AGREEMENT: the same rendering is the bank confirming itself, not a second rendering.
if got := ConsolidationKeyConflicts([]store.GlossaryEntry{{Src: "元始", Dst: "юаньши"}}, bank); len(got) != 0 {
t.Errorf("an identical rendering is not a contradiction: %v", got)
}
// SPOILER HANDOFF: disjoint windows never inject together, so they are not two renderings at once.
if got := ConsolidationKeyConflicts([]store.GlossaryEntry{{Src: "元始", Dst: "камень первоисточника",
SinceCh: 10}}, bank); len(got) != 0 {
t.Errorf("disjoint windows are a legitimate handoff: %v", got)
}
// REPLACEMENT: the proposal carries the bank row's whole UNIQUE tuple AND the row is the engine's own
// unsigned one, so this run's rendering takes its place and there is nothing lasting to report.
if got := ConsolidationKeyConflicts([]store.GlossaryEntry{{Src: "赵甲", Dst: "Чжао Цзя-старый"}}, bank); len(got) != 0 {
t.Errorf("a proposal on the engine's own unsigned row's UNIQUE key replaces it and cannot contradict it: %v", got)
}
// ⚠ THE SAME TUPLE OVER A ROW THE OWNER WROTE IS NOT A REPLACEMENT and must be reported. A proposal on
// a seed surface never lands at all — the emission drops it — so the bank keeps its rendering and the
// disagreement is permanent. This is the commonest shape there is: a hand-written seed row takes the
// default window and no sense, and so does a banknote proposal, so silence here hid the whole
// population from the sheet the owner signs by.
if got := ConsolidationKeyConflicts([]store.GlossaryEntry{{Src: "元始", Dst: "камень первоисточника",
SinceCh: 3, UntilCh: 9}}, bank); len(got) != 1 {
t.Errorf("a proposal sharing a SEED row's tuple is dropped, not merged, and the disagreement stands: %v", got)
}
// A proposal with no rendering is a term the role declined; it becomes no row and contradicts nothing.
if got := ConsolidationKeyConflicts([]store.GlossaryEntry{{Src: "元始", Dst: ""}}, bank); len(got) != 0 {
t.Errorf("an unrendered proposal is not a contradiction: %v", got)
}
// CONTROL for the four zeros: the same bank and surfaces DO report when the rendering really differs
// in an overlapping window.
if got := ConsolidationKeyConflicts([]store.GlossaryEntry{{Src: "元始", Dst: "первокамень", SinceCh: 5}}, bank); len(got) != 1 {
t.Fatalf("control: an overlapping window with a different rendering must report, got %d: %v", len(got), got)
}
}
// TestConsolidationTupleSkipMirrorsTheStoreKey pins the skip against the store's ACTUAL uniqueness rule.
// The glossary UNIQUE key is raw (migrate.go), so 族長 and 族长 with one sense and window are two rows the
// store accepts and the matcher fires both on — a real contradiction. Folding the sources here would skip
// it, and the caller is what guarantees the proposal already carries the source its row will have.
func TestConsolidationTupleSkipMirrorsTheStoreKey(t *testing.T) {
bank := []store.GlossaryEntry{{Src: "族長", Dst: "глава клана", Status: "draft", Source: "seed"}}
// Different raw src, same sense and window: both rows can exist, so this is a contradiction.
folded := []store.GlossaryEntry{{Src: "族长", Dst: "старейшина"}}
if got := ConsolidationKeyConflicts(folded, bank); len(got) != 1 {
t.Fatalf("族长 against a stored 族長 is a pair the store admits, not a replacement: %v", got)
}
// Byte-identical raw src on the same tuple: the store admits one row — but over a SEED row that does
// not make it a replacement, because the proposal is dropped at emission and never reaches the tuple.
same := []store.GlossaryEntry{{Src: "族長", Dst: "старейшина"}}
if got := ConsolidationKeyConflicts(same, bank); len(got) != 1 {
t.Errorf("the store admitting one row does not merge a proposal the emission drops: %v", got)
}
// CONTROL: the same tuple over the ENGINE's own unsigned row IS a replacement, so the assertion above
// is the row's provenance answering and not a skip that has stopped working.
engine := []store.GlossaryEntry{{Src: "族長", Dst: "глава клана", Status: "auto", Source: "mined"}}
if got := ConsolidationKeyConflicts(same, engine); len(got) != 0 {
t.Errorf("this run's rendering replaces the engine's own unsigned row for that tuple: %v", got)
}
}