188 lines
11 KiB
Go
188 lines
11 KiB
Go
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
|
||
}
|