package pipeline import ( "encoding/json" "strings" "testing" "textmachine/backend/internal/store" ) // memory_test.go ports the executable spec eval/memory_hotpath.py (T1–T10) to Go // unit tests, per the onboarding contract — but NOT the accept-REGEXPS verbatim // (research/14: a \b-regexp is the 18–36% false-flag source). The selection tests // (T1–T8) are the same cases; the post-check tests (T9/T10) use the decl-aware // matcher (memory_e1_test.go measures its false-flag rate on real Russian). func gl(src, dst, sense, status string) store.GlossaryEntry { return store.GlossaryEntry{BookID: "b", Src: src, Dst: dst, Sense: sense, Status: status, Source: "seed"} } func declJSON(invariant bool, forms ...string) string { b, _ := json.Marshal(declInfo{Invariant: invariant, Forms: forms}) return string(b) } func alias(a string) store.GlossaryAlias { return store.GlossaryAlias{Alias: a, AliasType: "прозвище"} } // specGlossary mirrors eval/memory_hotpath.py's G (with decl forms replacing the // toy accept-regexps). func specGlossary() []store.GlossaryEntry { ahq := gl("阿Q", "А-кью", "name", "approved") ahq.Decl = declJSON(true, "А-кью", "А кью") sishu := gl("四叔", "Четвёртый дядюшка", "char", "approved") sishu.Aliases = []store.GlossaryAlias{alias("鲁四老爷")} sishu.Decl = declJSON(false, "дядюшка", "дядюшки", "дядюшке", "дядюшку", "дядюшкой") luzhen := gl("鲁镇", "Лучжэнь", "", "approved") luzhen.Decl = declJSON(false, "Лучжэнь", "Лучжэня", "Лучжэне") songzao := gl("送灶", "проводы бога очага", "festival", "approved") songzao.Decl = declJSON(true, "бога очага") xiaoyan := gl("萧炎", "Сяо Янь", "name", "approved") xiaoyan.Aliases = []store.GlossaryAlias{alias("炎哥")} xiaoyan.Decl = declJSON(true, "Сяо Янь") xiuwei := gl("修为", "уровень совершенствования", "cultivation", "approved") xiuwei.Decl = declJSON(false, "совершенствования", "совершенствование") shadow := gl("影卫", "Тёмный страж", "spoiler", "approved") shadow.SinceCh = 200 shadow.Decl = declJSON(false, "Тёмный страж", "Тёмного стража") newname := gl("小D", "Малыш Дэ", "", "auto") newname.Decl = declJSON(false, "Малыш Дэ", "Малыша Дэ") return []store.GlossaryEntry{ahq, sishu, luzhen, songzao, xiaoyan, xiuwei, shadow, newname} } func bankFrom(entries []store.GlossaryEntry) *MemoryBank { return materializeMemory(entries, false) } // injMap maps injected src → disposition for assertions. func injMap(sel memorySelection) map[string]injectionDisposition { m := map[string]injectionDisposition{} for _, p := range sel.injected { m[p.entry.src] = p.disp } return m } func TestHotPathSpecT1toT10(t *testing.T) { b := bankFrom(specGlossary()) const budget = 0 // T1: exact + alias select; alias 鲁四老爷 pulls 四叔. sel := b.Select("阿Q走进鲁镇,遇见鲁四老爷。", 1, nil, budget) m := injMap(sel) if m["阿Q"] != memConfirmed || m["鲁镇"] != memConfirmed || m["四叔"] != memConfirmed { t.Errorf("T1 exact+alias: %v", m) } // T2: 送到灶 (literal) — 送灶 must NOT inject (bigram absent, 灶 single-key banned). sel = b.Select("厨房里,老妈子把饭菜送到灶间去热一热。", 1, nil, budget) if _, ok := injMap(sel)["送灶"]; ok { t.Error("T2 wrong-sense 送灶 injected (A3 homograph)") } // T3: 炎势 (flame) — 萧炎 must NOT inject. sel = b.Select("山火蔓延,炎势冲天,村民连夜逃走。", 1, nil, budget) if _, ok := injMap(sel)["萧炎"]; ok { t.Error("T3 wrong-sense 炎 injected") } // T4: 修好 (repair) — 修为 must NOT inject. sel = b.Select("工匠把石桥修好了。", 1, nil, budget) if _, ok := injMap(sel)["修为"]; ok { t.Error("T4 wrong-sense 修 injected") } // T5: trad→simp normalization — 魯鎮 matches key 鲁镇 (A4). sel = b.Select("魯鎮的冬天很冷。", 1, nil, budget) if _, ok := injMap(sel)["鲁镇"]; !ok { t.Error("T5 trad→simp: 魯鎮 did not match 鲁镇") } // T6: pronominal chunk — sticky carries 四叔 from the previous chunk, INHERITING the // prior chunk's CONFIRMED disposition (D16.2: sticky carries the disposition forward). sishuID := "四叔\x1fchar\x1f0\x1f0" sel = b.Select("他慢慢站起来,叹了口气。", 2, map[string]injectionDisposition{sishuID: memConfirmed}, budget) var stickySishu *pickedEntry for i := range sel.injected { if sel.injected[i].entry.src == "四叔" { stickySishu = &sel.injected[i] } } if stickySishu == nil || stickySishu.via != "sticky" { t.Errorf("T6 sticky scene-inertia failed: %+v", injMap(sel)) } // T7: spoiler — 影卫 (since_ch=200) in chapter 5 → hard reject. sel = b.Select("影卫出现了。", 5, nil, budget) if _, ok := injMap(sel)["影卫"]; ok { t.Error("T7 spoiler 影卫 injected in ch5") } if len(sel.rejected) != 1 || sel.rejected[0].entry.src != "影卫" { t.Errorf("T7 spoiler not rejected+logged: %+v", sel.rejected) } // T7b: same entry passes after the reveal chapter. sel = b.Select("影卫出现了。", 250, nil, budget) if _, ok := injMap(sel)["影卫"]; !ok { t.Error("T7b spoiler entry did not pass at ch250") } // T8: auto term 小D → ambiguous disposition. sel = b.Select("小D也来了。", 1, nil, budget) if injMap(sel)["小D"] != memAmbiguous { t.Errorf("T8 auto-term not ambiguous: %v", injMap(sel)) } // T9: post-check catches a missing dst (leak) and passes a present one. sel = b.Select("阿Q走进鲁镇。", 1, nil, budget) bad := b.postcheck(sel.injected, "Некто вошёл в Лучжэнь.") // А-кью dropped good := b.postcheck(sel.injected, "А-кью вошёл в Лучжэнь.") if !hasMiss(bad, "阿Q") { t.Errorf("T9 post-check missed the leak: %+v", bad) } if len(good) != 0 { t.Errorf("T9 post-check false-flagged a correct output: %+v", good) } // T10: post-check catches poisoning — injected dst absent, output used a foreign name. sel = b.Select("阿Q走进鲁镇。", 1, nil, budget) poison := b.postcheck(sel.injected, "Линь Чун вошёл в деревню Вэйчжуан.") if !hasMiss(poison, "阿Q") { t.Errorf("T10 post-check missed the poisoning: %+v", poison) } } func hasMiss(misses []postcheckMiss, src string) bool { for _, m := range misses { if m.Src == src { return true } } return false } // TestTrapCorpus ports the retrieval_bench wrong-sense traps: exact multi-char match // must fire 0/N on homograph chunks that contain the KEY CHARACTER but not the term. func TestTrapCorpus(t *testing.T) { entries := []store.GlossaryEntry{ gl("送灶", "проводы бога очага", "festival", "approved"), gl("修为", "уровень совершенствования", "cultivation", "approved"), gl("萧炎", "Сяо Янь", "name", "approved"), gl("斗气", "боевая ци", "term", "approved"), gl("钱老爷", "господин Цянь", "name", "approved"), // multi-char; the bare 钱 homograph must not fire } b := bankFrom(entries) traps := []struct{ name, text string }{ {"送到灶 literal", "厨房里冷了,老妈子把剩下的饭菜送到灶间去重新热一热。"}, {"修好 repair", "工匠们花了三天,终于把那条被山洪冲垮的石桥修好了。"}, {"炎势 flame", "山火蔓延,炎势冲天,浓烟滚滚,村民连夜收拾细软。"}, {"雾气 mist", "清晨的山谷里雾气很重,空气格外清新,露水打湿了草叶。"}, {"钱 money homograph", "他摸出几个铜钱,买了一碗热汤。"}, // 钱 present but 钱老爷 absent {"Chekhov negative", "Дмитрий Гуров сидел в ялтинском павильоне и лениво пил чай."}, } for _, tr := range traps { t.Run(tr.name, func(t *testing.T) { sel := b.Select(tr.text, 1, nil, 0) if len(sel.injected) != 0 { t.Errorf("trap fired %d injection(s), want 0: %v", len(sel.injected), injMap(sel)) } }) } } // TestLongestMatchContainment: a shorter key fully inside a longer key's span is // suppressed (longest-match / whole-entity replacement, A3), but the shorter entity // still fires when it stands alone. func TestLongestMatchContainment(t *testing.T) { entries := []store.GlossaryEntry{ gl("阿Q", "А-кью", "name", "approved"), gl("阿Q正传", "«Подлинная история А-кью»", "title", "approved"), } b := bankFrom(entries) // Inside the title: only the title fires. sel := b.Select("我读了《阿Q正传》。", 1, nil, 0) m := injMap(sel) if _, ok := m["阿Q正传"]; !ok { t.Errorf("title did not fire: %v", m) } if _, ok := m["阿Q"]; ok { t.Errorf("short key 阿Q fired inside the longer title span: %v", m) } // Standalone: the character name fires. sel = b.Select("阿Q走进村子。", 1, nil, 0) if _, ok := injMap(sel)["阿Q"]; !ok { t.Error("阿Q did not fire standalone") } } func TestSingleKeyBanAndAllowShort(t *testing.T) { banned := gl("炎", "пламя-как-имя", "", "approved") // single Han → banned by default b := bankFrom([]store.GlossaryEntry{banned}) if sel := b.Select("炎势冲天。", 1, nil, 0); len(sel.injected) != 0 { t.Error("single-char key fired despite the A3 ban") } // allow_short escape hatch lets it fire. banned.AllowShort = true b = bankFrom([]store.GlossaryEntry{banned}) if sel := b.Select("炎势冲天。", 1, nil, 0); len(sel.injected) != 1 { t.Error("allow_short did not re-enable the single-char key") } } func TestBudgetEviction(t *testing.T) { entries := []store.GlossaryEntry{ gl("甲甲", "Альфа", "", "approved"), gl("乙乙", "Бета", "", "auto"), // ambiguous → lower priority gl("丙丙", "Гамма", "", "approved"), } b := bankFrom(entries) chunk := "甲甲、乙乙、丙丙都在。" // Unbounded: all three fire, in priority order (the two approved before the auto). all := b.Select(chunk, 1, nil, 0) if len(all.injected) != 3 { t.Fatalf("unbounded: want 3 injected, got %d", len(all.injected)) } // A token budget that fits exactly the top TWO priority records (computed with the // same cost helper Select uses, so the test never drifts from the implementation). budget := glossaryLineTokens(all.injected[0].entry) + glossaryLineTokens(all.injected[1].entry) sel := b.Select(chunk, 1, nil, budget) if len(sel.injected) != 2 || len(sel.evicted) != 1 { t.Fatalf("budget: injected=%d evicted=%d (budget=%d)", len(sel.injected), len(sel.evicted), budget) } // The auto (ambiguous) term is the one evicted (confirmed>ambiguous priority, F2). if sel.evicted[0].entry.src != "乙乙" { t.Errorf("eviction priority wrong: evicted %s (want the auto term 乙乙)", sel.evicted[0].entry.src) } } // TestSelectDeterminism runs Select many times over a multi-hit chunk and asserts the // injected order is byte-identical — guards against a map-iteration leak (the // determinism invariant: the injected bytes enter request_hash). func TestSelectDeterminism(t *testing.T) { b := bankFrom(specGlossary()) chunk := "阿Q走进鲁镇,遇见鲁四老爷,谈起修为。" first := selKey(b.Select(chunk, 1, nil, 0)) for i := 0; i < 200; i++ { if got := selKey(b.Select(chunk, 1, nil, 0)); got != first { t.Fatalf("non-deterministic Select: run %d gave %q, first was %q", i, got, first) } } } func selKey(sel memorySelection) string { s := "" for _, p := range sel.injected { s += p.entry.src + ":" + string(p.disp) + "|" } return s } // TestEmptyDstNotMatchable covers self-review #1: a ruby candidate (dst="") must not be // matchable — it renders nothing, so admitting it would waste budget, evict a real line, // and inflate the exact-hit count. func TestEmptyDstNotMatchable(t *testing.T) { phantom := gl("甲甲", "", "", "auto") // ruby-style candidate, no dst real := gl("乙乙", "Настоящий", "", "auto") b := bankFrom([]store.GlossaryEntry{phantom, real}) // Tiny budget that would fit exactly one line: the phantom must not steal it. budget := glossaryLineTokens(&memoryEntry{src: "乙乙", dst: "Настоящий"}) sel := b.Select("甲甲和乙乙都在场。", 1, nil, budget) if len(sel.injected) != 1 || sel.injected[0].entry.src != "乙乙" { t.Fatalf("empty-dst phantom polluted selection: injected=%v", injMap(sel)) } if renderGlossaryBlock(sel.injected) == "" { t.Error("a renderable line was evicted by an empty-dst phantom") } // Unbounded: the phantom never appears as an exact hit. sel = b.Select("甲甲和乙乙都在场。", 1, nil, 0) if _, ok := injMap(sel)["甲甲"]; ok { t.Error("empty-dst phantom counted as an exact hit") } } // TestSpoilerBeforeSuppression covers self-review #6: a spoiler-blocked LONGER key must // not suppress a valid SHORTER name nested inside it. func TestSpoilerBeforeSuppression(t *testing.T) { short := gl("林动", "Линь Дун", "", "approved") long := gl("林动的父亲", "отец Линь Дуна", "", "approved") long.UntilCh = 3 // a spoiler: valid only through chapter 3 b := bankFrom([]store.GlossaryEntry{short, long}) // Chapter 10: the longer key is spoiler-blocked; the shorter valid name must survive. sel := b.Select("林动的父亲缓缓转身。", 10, nil, 0) if _, ok := injMap(sel)["林动"]; !ok { t.Errorf("valid shorter name dropped by a spoiler-blocked longer key: %v", injMap(sel)) } if _, ok := injMap(sel)["林动的父亲"]; ok { t.Error("spoiler-blocked longer key was injected") } if len(sel.rejected) != 1 || sel.rejected[0].entry.src != "林动的父亲" { t.Errorf("spoiler-blocked longer key not logged as rejected: %+v", sel.rejected) } // Chapter 2 (longer key in window): longest-match still prefers the longer entity. sel = b.Select("林动的父亲缓缓转身。", 2, nil, 0) if _, ok := injMap(sel)["林动"]; ok { t.Error("longest-match failed when the longer key is spoiler-valid") } if _, ok := injMap(sel)["林动的父亲"]; !ok { t.Error("spoiler-valid longer key was not injected at ch2") } } // TestBudgetFloorFree covers self-review #7: the per-line budget cost is floor-free, so a // block of short name lines is not ~1.85×-overcounted and needlessly evicted. func TestBudgetFloorFree(t *testing.T) { var entries []store.GlossaryEntry srcs := []string{"甲甲", "乙乙", "丙丙", "丁丁", "戊戊", "己己", "庚庚", "辛辛"} dsts := []string{"Ко", "Оцу", "Хэй", "Тэй", "Бо", "Ки", "Ко2", "Син"} for i := range srcs { entries = append(entries, gl(srcs[i], dsts[i], "", "approved")) } b := bankFrom(entries) chunk := strings.Join(srcs, "、") + "都在场。" all := b.Select(chunk, 1, nil, 0) if len(all.injected) != 8 { t.Fatalf("want 8 injected, got %d", len(all.injected)) } // A budget equal to the EXACT sum of the (floor-free) line costs fits all 8. budget := 0 for _, p := range all.injected { budget += glossaryLineTokens(p.entry) } sel := b.Select(chunk, 1, nil, budget) if len(sel.injected) != 8 || len(sel.evicted) != 0 { t.Errorf("floor-free budget=%d should fit all 8: injected=%d evicted=%d", budget, len(sel.injected), len(sel.evicted)) } } // TestPerLanguageMinKeyAndCollisionDisposition covers external-review major #2/#3: a // purely-phonetic key needs ≥3 chars (a 2-char kana/latin key collides inside ordinary // words), and a short (≤3) phonetic key injects as AMBIGUOUS even when approved. func TestPerLanguageMinKeyAndCollisionDisposition(t *testing.T) { entries := []store.GlossaryEntry{ gl("鈴木", "Судзуки", "", "approved"), // 2 Han → fires, CONFIRMED (ideographic anchor) gl("すずき", "Судзуки-х", "s2", "approved"), // 3 kana → fires but AMBIGUOUS (collision-prone) gl("ながいなまえ", "Длинное имя", "", "approved"), // 6 kana → fires, CONFIRMED (long enough) gl("リン", "Рин", "", "approved"), // 2 kana → BANNED (phonetic min 3) gl("ai", "ИИ", "", "approved"), // 2 latin → BANNED (phonetic min 3) } b := bankFrom(entries) sel := b.Select("鈴木とすずきとながいなまえが会った。", 1, nil, 0) m := injMap(sel) if m["鈴木"] != memConfirmed { t.Errorf("Han key must be CONFIRMED: %v", m) } if m["すずき"] != memAmbiguous { t.Errorf("short phonetic key must be AMBIGUOUS (A2 collision): %v", m) } if m["ながいなまえ"] != memConfirmed { t.Errorf("long phonetic key must be CONFIRMED: %v", m) } // The collision cases external-review named: a short phonetic key must NOT fire inside // an ordinary word (リン in リンゴ/apple, ai in raid). if s := b.Select("リンゴを食べた。", 1, nil, 0); len(s.injected) != 0 { t.Errorf("2-kana key fired inside リンゴ (collision): %v", injMap(s)) } if s := b.Select("raidアレイを組んだ。", 1, nil, 0); len(s.injected) != 0 { t.Errorf("2-latin key fired inside raid (collision): %v", injMap(s)) } // allow_short is the author's explicit override: the short key fires AND as CONFIRMED. rin := gl("リン", "Рин", "", "approved") rin.AllowShort = true b2 := bankFrom([]store.GlossaryEntry{rin}) if s := b2.Select("リンさんが来た。", 1, nil, 0); injMap(s)["リン"] != memConfirmed { t.Errorf("allow_short must let リン fire as CONFIRMED: %v", injMap(s)) } } // TestStickyInheritsDisposition covers D16.2: a collision-prone AMBIGUOUS exact match must // carry into the next chunk's sticky AS AMBIGUOUS — never silently upgraded to CONFIRMED. A // status-based recompute would upgrade it (past the post-check, into the editor constraints). // Reverting the inherit change (recomputing via dispositionFor) makes the carry CONFIRMED here. func TestStickyInheritsDisposition(t *testing.T) { // An approved name whose only key is collision-prone (3 kana): an exact match fires // AMBIGUOUS (the A2 collision downgrade), not CONFIRMED, despite the approved status. suzuki := gl("すずき", "Судзуки", "", "approved") b := bankFrom([]store.GlossaryEntry{suzuki}) sel1 := b.Select("すずきが来た。", 1, nil, 0) if injMap(sel1)["すずき"] != memAmbiguous { t.Fatalf("collision-prone approved key must fire AMBIGUOUS: %v", injMap(sel1)) } if len(sel1.activeIDs) != 1 { t.Fatalf("want exactly one active id, got %d", len(sel1.activeIDs)) } for _, disp := range sel1.activeIDs { if disp != memAmbiguous { t.Fatalf("activeIDs must carry the AMBIGUOUS disposition, got %q", disp) } } // Pronominal chunk (no key): sticky carries すずき, and its disposition MUST stay AMBIGUOUS. sel2 := b.Select("彼は立ち上がった。", 1, sel1.activeIDs, 0) var carried *pickedEntry for i := range sel2.injected { if sel2.injected[i].entry.src == "すずき" { carried = &sel2.injected[i] } } if carried == nil || carried.via != "sticky" { t.Fatalf("sticky did not carry すずき: %v", injMap(sel2)) } if carried.disp != memAmbiguous { t.Errorf("D16.2: sticky carry upgraded disposition to %q, want AMBIGUOUS", carried.disp) } } // TestUnionStickyNewestWins covers self-review finding #6: unionSticky merges the sticky // window so the MOST RECENT firing's disposition wins — an id that fired CONFIRMED via a clean // key then AMBIGUOUS via a collision alias must carry AMBIGUOUS (no silent upgrade); a revert to // keep-first would reintroduce the D16.2 upgrade. This exercises the merge that // TestStickyInheritsDisposition (single-chunk activeIDs) bypasses. func TestUnionStickyNewestWins(t *testing.T) { id := "x\x1f\x1f0\x1f0" // Oldest→newest CONFIRMED then AMBIGUOUS → newest (AMBIGUOUS) wins (the anti-upgrade case). if got := unionSticky([]map[string]injectionDisposition{{id: memConfirmed}, {id: memAmbiguous}})[id]; got != memAmbiguous { t.Errorf("newest firing must win: got %q, want ambiguous", got) } // Reversed → a re-established CONFIRMED (via a clean key in the newer chunk) wins. if got := unionSticky([]map[string]injectionDisposition{{id: memAmbiguous}, {id: memConfirmed}})[id]; got != memConfirmed { t.Errorf("a re-established CONFIRMED must win: got %q, want confirmed", got) } if unionSticky(nil) != nil { t.Error("empty window → nil") } } // TestSourcePhoneticWordBoundary covers D16.3: a spaced-phonetic (Latin/Cyrillic) source key // fires only as a WHOLE WORD, never inside a longer same-script word ("rose" in "roseanne"). // A cross-script neighbour is a valid boundary. Reverting suppressUnboundedPhonetic fires // "rose" inside "roseanne" and fails here. func TestSourcePhoneticWordBoundary(t *testing.T) { rose := gl("rose", "Роза", "", "approved") // 4 Latin → CONFIRMED, but boundary-checked b := bankFrom([]store.GlossaryEntry{rose}) // Inside a longer Latin word → suppressed (both a suffix and a prefix collision). if s := b.Select("roseanne walked in.", 1, nil, 0); len(s.injected) != 0 { t.Errorf("latin key fired inside 'roseanne': %v", injMap(s)) } if s := b.Select("the roses bloomed.", 1, nil, 0); len(s.injected) != 0 { t.Errorf("latin key fired inside 'roses': %v", injMap(s)) } // Standalone / at string edges → fires CONFIRMED. if injMap(b.Select("a rose bloomed.", 1, nil, 0))["rose"] != memConfirmed { t.Error("latin key must fire as a whole word") } if injMap(b.Select("rose.", 1, nil, 0))["rose"] != memConfirmed { t.Error("latin key at string edges must fire") } // Cross-script neighbour (a Han char in unspaced source) is a valid boundary → fires. if injMap(b.Select("我叫rose。", 1, nil, 0))["rose"] != memConfirmed { t.Error("latin key abutting a cross-script (Han) neighbour must fire") } } // TestKanaNotSourceBoundaryChecked documents the D16.3 kana carve-out: kana has no word // segmentation, so a kana key is NOT boundary-checked — it fires between kana neighbours (a // name+particle case すずきは), matching the long-key-CONFIRMED contract. The ≥4-kana compound // precision is deferred to the kana-precision measurement + B6 tokenizer (flagged). func TestKanaNotSourceBoundaryChecked(t *testing.T) { // A long kana key must still fire between kana neighbours (と…が), unaffected by the // source boundary check (this mirrors the existing long-phonetic-CONFIRMED expectation). name := gl("ながいなまえ", "Длинное имя", "", "approved") b := bankFrom([]store.GlossaryEntry{name}) if injMap(b.Select("私とながいなまえが会った。", 1, nil, 0))["ながいなまえ"] != memConfirmed { t.Error("kana key must NOT be source-boundary-suppressed (name+particle recall)") } } // TestOverlappingNonNestedKeysBothFire documents the eval-review low finding: two keys // that OVERLAP without nesting both fire (both surfaces are genuinely present in the // text). Accepted behavior (precision-side), asserted so a future change is deliberate. func TestOverlappingNonNestedKeysBothFire(t *testing.T) { entries := []store.GlossaryEntry{ gl("甲乙", "Пара-А", "", "approved"), gl("乙丙", "Пара-Б", "", "approved"), } b := bankFrom(entries) sel := b.Select("甲乙丙都在。", 1, nil, 0) // 甲乙 at [0,2), 乙丙 at [1,3) — overlap, neither contains the other m := injMap(sel) if m["甲乙"] != memConfirmed || m["乙丙"] != memConfirmed { t.Errorf("overlapping non-nested keys should both fire: %v", m) } } func TestInjectivityCollision(t *testing.T) { entries := []store.GlossaryEntry{ {BookID: "b", Src: "甲", Dst: "Мастер", Status: "approved"}, {BookID: "b", Src: "乙", Dst: "Мастер", Status: "approved"}, // same dst → collision (B2) {BookID: "b", Src: "丙", Dst: "Ученик", Status: "approved"}, } cols := injectivityCollisions(entries) if len(cols) != 1 { t.Fatalf("want 1 dst collision, got %d: %v", len(cols), cols) } } // TestRenderEditorConstraintBlock covers D1: the monolingual editor's injection is the // CONFIRMED dst forms ONLY (target constraints), deduped, with no source or "src → dst". // Reverting the CONFIRMED-only guard leaks an AMBIGUOUS candidate and fails here. func TestRenderEditorConstraintBlock(t *testing.T) { suzuki := &memoryEntry{src: "鈴木", dst: "Судзуки", status: "approved"} cand := &memoryEntry{src: "小D", dst: "Малыш Дэ", status: "auto"} // AMBIGUOUS tanaka := &memoryEntry{src: "田中", dst: "Танака", status: "approved"} tanakaKana := &memoryEntry{src: "たなか", dst: "Танака", status: "approved"} // same dst → deduped sel := []pickedEntry{ {entry: suzuki, via: "鈴木", disp: memConfirmed}, {entry: cand, via: "小d", disp: memAmbiguous}, {entry: tanaka, via: "田中", disp: memConfirmed}, {entry: tanakaKana, via: "たなか", disp: memConfirmed}, } block := renderEditorConstraintBlock(sel) if !strings.Contains(block, "«Судзуки»") || !strings.Contains(block, "«Танака»") { t.Errorf("confirmed dst forms missing: %q", block) } // AMBIGUOUS excluded — the editor must not be forced toward an unverified rendering. if strings.Contains(block, "Малыш Дэ") { t.Errorf("AMBIGUOUS candidate leaked into the editor constraints: %q", block) } // dst-only: no source key, no arrow (the editor is monolingual). if strings.Contains(block, "→") || strings.Contains(block, "鈴木") || strings.Contains(block, "ГЛОССАРИЙ") { t.Errorf("editor block must be target-only, no src→dst: %q", block) } // Deduped by dst (Танака appears once despite two source keys). if n := strings.Count(block, "«Танака»"); n != 1 { t.Errorf("dst not deduped: «Танака» appears %d times: %q", n, block) } // An all-AMBIGUOUS (or empty) selection yields no block at all. if got := renderEditorConstraintBlock([]pickedEntry{{entry: cand, disp: memAmbiguous}}); got != "" { t.Errorf("an all-AMBIGUOUS selection must yield no editor block, got %q", got) } }