package membank import ( "strings" "testing" "textmachine/backend/internal/lang" ) // render_test.go: the injection RENDERER fixtures — the exact bytes the bank puts on the wire for // each role. ruTX is the ru target wire-text (pair-14 §2: the strings live in lang/data/injection.txt). func ruTX() lang.InjectionTexts { return lang.InjectionTextsFor("ru") } func TestRenderGlossaryBlock(t *testing.T) { confirmed := PickedEntry{entry: &entry{src: "阿Q", dst: "А-кью", status: "approved"}, Disp: Confirmed} ambiguous := PickedEntry{entry: &entry{src: "小D", dst: "Малыш Дэ", status: "auto"}, Disp: Ambiguous} emptyDst := PickedEntry{entry: &entry{src: "鈴木", dst: "", status: "auto"}, Disp: Ambiguous} block := RenderGlossaryBlock([]PickedEntry{confirmed, ambiguous, emptyDst}, ruTX()) if !strings.Contains(block, "阿Q → А-кью") { t.Errorf("confirmed line missing: %q", block) } if !strings.Contains(block, "小D → Малыш Дэ ⟨проверить⟩") { t.Errorf("ambiguous line missing its unverified tag: %q", block) } if strings.Contains(block, "鈴木") { t.Errorf("empty-dst candidate should be skipped (nothing to inject): %q", block) } // Nothing to inject → empty block (better nothing). if RenderGlossaryBlock(nil, ruTX()) != "" { t.Error("empty selection must render an empty block") } if RenderGlossaryBlock([]PickedEntry{emptyDst}, ruTX()) != "" { t.Error("only-empty-dst selection must render an empty block") } } // TestDC3GenderInjection pins the DC3 gender constraint annotation on the editor block (WS5 §5(б)): a // gendered CONFIRMED term carries a gender directive; a genderless term does not (byte-identical line). func TestDC3GenderInjection(t *testing.T) { mk := func(gender string) []PickedEntry { return []PickedEntry{{entry: &entry{src: "方源", dst: "Фан Юань", status: "approved", gender: gender}, Via: "方源", Disp: Confirmed}} } male := RenderEditorConstraintBlock(mk("male"), ruTX()) if !strings.Contains(male, "方源 → «Фан Юань» (муж.") { t.Fatalf("male term must carry a masculine directive, got:\n%s", male) } hidden := RenderEditorConstraintBlock(mk("hidden"), ruTX()) if !strings.Contains(hidden, "пол СКРЫТ") { t.Fatalf("hidden term must carry the gender-avoidance mandate, got:\n%s", hidden) } none := RenderEditorConstraintBlock(mk(""), ruTX()) if !strings.HasSuffix(none, "«Фан Юань»") { t.Fatalf("a genderless term must have NO gender note (line ends at the dst), got:\n%s", none) } } // TestZeroValuePickedEntryAndBankDoNotPanic pins the pack-16 tail: both types are EXPORTED with exported // fields, so a caller outside this package can construct a zero value whose private row/matcher is nil. // Every consumer used to nil-deref on it — a panic in a paid run. The contract is now "a row-less record is // skipped; a matcher-less bank selects nothing", which cannot change a real bank's behaviour because // Materialize and Select always populate both. func TestZeroValuePickedEntryAndBankDoNotPanic(t *testing.T) { zero := []PickedEntry{{Via: "x", Disp: Confirmed}} tx := lang.InjectionTextsFor("ru") if got := RenderGlossaryBlock(zero, tx); got != "" { t.Fatalf("a row-less record must render nothing, got %q", got) } if got := RenderEditorConstraintBlock(zero, tx); got != "" { t.Fatalf("a row-less record must render no constraint, got %q", got) } var b Bank if misses := b.Postcheck(zero, "любой текст"); !misses.Empty() { t.Fatalf("a row-less record must produce no post-check miss, got %+v", misses) } if sel := b.Select("непустой текст для матчера", 1, nil, 100); len(sel.Injected) != 0 { t.Fatalf("a zero-value bank must select nothing, got %+v", sel.Injected) } } // TestEditorSectionFollowsProvenance pins the S7 fix (cold-run session, backlog 19): the editor's two // sections separate WHO SIGNED a rendering, not how confident the match was. dispositionFor downgrades an // approved row matched by a collision-prone key (short + purely phonetic) to AMBIGUOUS — a statement about // the firing, not the authority — and routing the section on it filed the owner's own signature under // «никем не утверждено». Invisible on zh (Han keys anchor at 2), structural on any phonetic source. func TestEditorSectionFollowsProvenance(t *testing.T) { signedShort := PickedEntry{entry: &entry{src: "リン", dst: "Рин", status: "approved", gender: "female"}, Via: "りん", Disp: Ambiguous} unsigned := PickedEntry{entry: &entry{src: "方源", dst: "Фан Юань", status: "draft"}, Via: "方源", Disp: Ambiguous} block := RenderEditorConstraintBlock([]PickedEntry{signedShort, unsigned}, ruTX()) tx := ruTX() canon, rest, ok := strings.Cut(block, tx.EditorUnverifiedHeader) if !ok { t.Fatalf("both sections must be present, got:\n%s", block) } if !strings.Contains(canon, "リン → «Рин»") { t.Errorf("an owner-SIGNED row belongs to the canon section, got:\n%s", canon) } if strings.Contains(canon, "方源") { t.Errorf("an unsigned row must not be canon, got:\n%s", canon) } if !strings.Contains(rest, "方源 → «Фан Юань»") || !strings.Contains(rest, tx.UnverifiedMarker) { t.Errorf("the unsigned row must be in the marked section, got:\n%s", rest) } if strings.Contains(canon, tx.UnverifiedMarker) { t.Errorf("the canon section must carry no unverified marker, got:\n%s", canon) } // The gender DIRECTIVE still keys on the match, not on the signature: on a collision-prone firing the // row may not be about the entity on the page, and «женский род» would then be an instruction to get // it wrong. Authority moves the section; confidence keeps the directive. if strings.Contains(canon, tx.GenderFemale) { t.Errorf("a collision-prone firing must carry no gender directive, got:\n%s", canon) } // The disposition itself is UNCHANGED — it still guards the post-check and the translator block's // ⟨проверить⟩ tag. Only the section moved. if signedShort.Disp != Ambiguous { t.Fatal("the fix must not upgrade the disposition; it only re-routes the section") } if draft := RenderGlossaryBlock([]PickedEntry{signedShort}, ruTX()); !strings.Contains(draft, tx.UnverifiedMarker) { t.Errorf("the TRANSLATOR block still marks a collision-prone match unverified, got: %q", draft) } }