textmachine/backend/internal/membank/render_test.go

188 lines
11 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 (
"sort"
"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") }
// ⚠ THE ⟨проверить⟩ TAG THIS TEST USED TO REQUIRE IS RETIRED (D39.104 п.2, backlog row 134, ordered by
// this pack's brief). The assertion is inverted rather than deleted: an unsigned row now renders as an
// ordinary line, and the guarantee that replaced «the model is told which rows are candidates» is «every
// row is law and the lines are indistinguishable». A revert reds here.
func TestRenderGlossaryBlock(t *testing.T) {
confirmed := PickedEntry{entry: &entry{src: "阿Q", dst: "А-кью", status: "approved"}, Disp: Confirmed, KeyTrusted: true}
ambiguous := PickedEntry{entry: &entry{src: "小D", dst: "Малыш Дэ", status: "auto"}, Disp: Ambiguous, KeyTrusted: true}
emptyDst := PickedEntry{entry: &entry{src: "鈴木", dst: "", status: "auto"}, Disp: Ambiguous, KeyTrusted: true}
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("unsigned line missing: %q", block)
}
if strings.Contains(block, ruTX().UnverifiedMarker) {
t.Errorf("the ⟨проверить⟩ marker left the wire: %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, KeyTrusted: true}}
}
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)
}
}
// TestTheGenderDirectiveAsksTheMatchAndTheWireAsksNothingElse is what the S7 fix (cold-run session,
// backlog 19) became when the editor's two sections merged. S7's subject was the ROUTING: it separated
// sections by WHO SIGNED a rendering rather than by how confident the match was, because an approved row
// matched by a collision-prone key is downgraded to AMBIGUOUS — a statement about the firing, not the
// authority — and routing on it filed the owner's own signature under «никем не утверждено». Row 134
// removed the sections, so the routing question is gone; the DIRECTIVE question S7 also answered is not,
// and it is what this test now holds, on both blocks at once.
//
// Invisible on zh (Han keys anchor at 2), structural on any phonetic source.
func TestTheGenderDirectiveAsksTheMatchAndTheWireAsksNothingElse(t *testing.T) {
tx := ruTX()
// An owner-SIGNED row whose short phonetic key may have fired inside another word.
signedShort := PickedEntry{entry: &entry{src: "リン", dst: "Рин", status: "approved", gender: "female"}, Via: "りん", Disp: Ambiguous}
// An UNSIGNED row on a clean key — the shape the auto-produced bank actually has.
unsigned := PickedEntry{entry: &entry{src: "方源", dst: "Фан Юань", status: "draft", gender: "male"}, Via: "方源", Disp: Ambiguous, KeyTrusted: true}
block := RenderEditorConstraintBlock([]PickedEntry{signedShort, unsigned}, tx)
if strings.Count(block, tx.EditorHeader) != 1 || strings.Contains(block, tx.EditorUnverifiedHeader) {
t.Fatalf("the editor gets ONE law block: %s", block)
}
for _, want := range []string{"リン → «Рин»", "方源 → «Фан Юань»"} {
if !strings.Contains(block, want) {
t.Errorf("every injected row is in the law block, %q is not: %s", want, block)
}
}
if strings.Contains(block, tx.UnverifiedMarker) {
t.Errorf("no line is marked apart any more: %s", block)
}
// BOTH BLOCKS ASK THE SAME QUESTION, and it is the MATCH. The signature stopped deciding this when the
// editor's two sections merged: with the unsigned RENDERING already carrying the canon's force, holding
// back only the gender was an exception with nothing under it (D39.104 п.1 — the wire is law for every
// row «независимо от статуса»). What is withheld is the doubtful KEY, on both wires, because a gender
// note attached to another entity is wrong rather than merely unsigned.
if !strings.Contains(block, "方源 → «Фан Юань»"+tx.GenderMale) {
t.Errorf("an unsigned row on a clean key must carry its gender to the editor: %s", block)
}
if strings.Contains(block, tx.GenderFemale) {
t.Errorf("a collision-prone firing must carry no gender to the editor: %s", block)
}
draft := RenderGlossaryBlock([]PickedEntry{signedShort, unsigned}, tx)
if !strings.Contains(draft, "方源 → Фан Юань"+tx.GenderMale) {
t.Errorf("an unsigned row on a clean key must carry its gender to the translator: %q", draft)
}
if strings.Contains(draft, tx.GenderFemale) {
t.Errorf("a collision-prone match must carry no gender to the translator either: %q", draft)
}
// The disposition itself is UNCHANGED — it still guards the post-check and the priority budget.
if signedShort.Disp != Ambiguous {
t.Fatal("the split must not upgrade the disposition; it only stops the wire asking it")
}
}
// TestTheLawBlockGivesOneOrderPerSourceTerm is the conflict the section merge made reachable and the render
// silently passed on. Two "lawful" renderings of ONE source term — the owner's signed row and the engine's
// own consolidation of the same surface — used to be two DIFFERENT statements under two headers, one
// binding and one explicitly rejectable. In a single law block they are two ORDERS for one term, and when
// they carry different genders they are two contradictory orders about one character.
//
// ⚠ THE WINNER IS NOT «WHICHEVER CAME FIRST»: Select hands records in priority order and priorityRank puts
// Confirmed before Ambiguous, so the surviving line is the SIGNED one whenever there is one. The test feeds
// the unsigned row FIRST on purpose — a dedup that kept the first record of the input slice, rather than the
// first of the priority order, would pass a same-order fixture and fail here.
func TestTheLawBlockGivesOneOrderPerSourceTerm(t *testing.T) {
tx := ruTX()
signed := PickedEntry{entry: &entry{src: "方源", dst: "Фан Юань", status: "approved", gender: "male"}, Via: "方源", Disp: Confirmed, KeyTrusted: true}
auto := PickedEntry{entry: &entry{src: "方源", dst: "Фан Юан", status: "draft", gender: "female"}, Via: "方源", Disp: Ambiguous, KeyTrusted: true}
// Priority order is what the block is rendered from; the unsigned record is listed first in the input.
block := RenderEditorConstraintBlock(sortByPriority([]PickedEntry{auto, signed}), tx)
if n := strings.Count(block, "方源 → "); n != 1 {
t.Fatalf("one source term is one ORDER, got %d lines for it:\n%s", n, block)
}
if !strings.Contains(block, "方源 → «Фан Юань»") {
t.Errorf("the SIGNED rendering must be the one that survives:\n%s", block)
}
if strings.Contains(block, tx.GenderFemale) {
t.Errorf("the losing row's gender must not reach the wire alongside the winner's:\n%s", block)
}
if !strings.Contains(block, tx.GenderMale) {
t.Errorf("the surviving row keeps its own directive:\n%s", block)
}
// A HOMONYMIC dst is the case this dedup must NOT collapse: two different source terms rendering to one
// Russian surface stay two lines, which is the whole reason the block binds a canon to its source.
homonyms := []PickedEntry{
{entry: &entry{src: "田中", dst: "Танака", status: "approved"}, Via: "田中", Disp: Confirmed, KeyTrusted: true},
{entry: &entry{src: "済", dst: "Танака", status: "approved"}, Via: "済", Disp: Confirmed, KeyTrusted: true},
}
if got := RenderEditorConstraintBlock(homonyms, tx); !strings.Contains(got, "田中 → «Танака»") || !strings.Contains(got, "済 → «Танака»") {
t.Errorf("two source terms with one rendering must stay two lines:\n%s", got)
}
}
// sortByPriority is what Select does before rendering: the block's order is the budget priority order, and a
// test that hand-builds a selection has to reproduce it or it is testing a state the engine never produces.
func sortByPriority(in []PickedEntry) []PickedEntry {
out := append([]PickedEntry(nil), in...)
sort.SliceStable(out, func(i, j int) bool { return priorityRank(out[i]) < priorityRank(out[j]) })
return out
}