407 lines
26 KiB
Go
407 lines
26 KiB
Go
package membank
|
||
|
||
import (
|
||
"sort"
|
||
"strings"
|
||
"testing"
|
||
|
||
"textmachine/backend/internal/lang"
|
||
"textmachine/backend/internal/store"
|
||
)
|
||
|
||
// 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.
|
||
//
|
||
// ⚠ WHAT YIELDS IS THE UNSIGNED ROW, AND ONLY TO A SIGNED ONE. The first version of this rule deduped on the
|
||
// bare `src` and that was a REGRESSION found by planting: the bank's uniqueness key is (src, sense, since,
|
||
// until), so one surface legitimately carries SEVERAL signed canons, and dropping one of them because they
|
||
// share a surface discards a signature — silently, while the translator's block still rendered both. Both
|
||
// halves are asserted here, because either alone is a rule somebody will re-break.
|
||
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}
|
||
|
||
// (1) THE UNSIGNED ROW YIELDS to the signed canon of the same surface.
|
||
// ⚠ This case does NOT prove order-independence, though it used to say it did: it is listed first in the
|
||
// input, but sortByPriority then puts the signed row in front, so a rule that kept whichever record
|
||
// arrived first would pass here too. Case (7) is the one that actually holds that, and it was written
|
||
// after a planting walked through this comment's claim.
|
||
block := RenderEditorConstraintBlock(sortByPriority([]PickedEntry{auto, signed}), tx)
|
||
if n := strings.Count(block, "方源 → "); n != 1 {
|
||
t.Fatalf("an unsigned rendering must yield to the signed canon of the same surface, got %d lines:\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)
|
||
}
|
||
|
||
// (2) ⛔ TWO SIGNED SENSES OF ONE SURFACE ARE BOTH LAW. 青山 the surname and 青山 the direction are two
|
||
// entities the owner decided about separately — that is what the bank's (src, sense, …) key is FOR — and
|
||
// dropping either is discarding a signature. This is the case the first version of the rule broke.
|
||
senses := []PickedEntry{
|
||
{entry: &entry{src: "青山", dst: "Циншань", sense: "surname", status: "approved"}, Via: "青山", Disp: Confirmed, KeyTrusted: true},
|
||
{entry: &entry{src: "青山", dst: "зелёная гора", sense: "direction", status: "approved"}, Via: "青山", Disp: Confirmed, KeyTrusted: true},
|
||
}
|
||
both := RenderEditorConstraintBlock(sortByPriority(senses), tx)
|
||
for _, want := range []string{"青山 → «Циншань»", "青山 → «зелёная гора»"} {
|
||
if !strings.Contains(both, want) {
|
||
t.Errorf("a signed canon was dropped for sharing a surface with another signed canon (%q):\n%s", want, both)
|
||
}
|
||
}
|
||
// And the translator's block must say the SAME thing — the two wires disagreeing about how many canons a
|
||
// surface has is the shape the regression took.
|
||
if draft := RenderGlossaryBlock(sortByPriority(senses), tx); strings.Count(draft, "青山 → ") != strings.Count(both, "青山 → ") {
|
||
t.Errorf("the two wires disagree about how many canons 青山 has:\neditor: %s\ndraft: %s", both, draft)
|
||
}
|
||
|
||
// (3) A HOMONYMIC dst is the case this must NOT collapse either: 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)
|
||
}
|
||
// (4) The same rendering arriving twice — an alias and its main entry — is still ONE order.
|
||
dup := []PickedEntry{
|
||
{entry: &entry{src: "田中", dst: "Танака", status: "approved"}, Via: "田中", Disp: Confirmed, KeyTrusted: true},
|
||
{entry: &entry{src: "田中", dst: "Танака", status: "approved"}, Via: "たなか", Disp: Confirmed, KeyTrusted: true},
|
||
}
|
||
if n := strings.Count(RenderEditorConstraintBlock(dup, tx), "田中 → «Танака»"); n != 1 {
|
||
t.Errorf("one rendering reaching the block twice is one order, got %d", n)
|
||
}
|
||
|
||
// (5) ⛔ SIGNATURE IS A STATUS, NOT A CONFIDENCE. A signed row that happened to fire through a doubtful
|
||
// key is still the owner's canon: its MATCH is uncertain, its AUTHORITY is not. Planted — the signed set
|
||
// keyed on the disposition instead of the status — an unsigned rendering of the same surface stood beside
|
||
// it and the model got two orders for one term. Every other fixture here has Confirmed on the signed row,
|
||
// so none of them could tell the two apart.
|
||
doubtfulSigned := []PickedEntry{
|
||
{entry: &entry{src: "鈴木", dst: "Судзуки", status: "approved", gender: "female"}, Via: "すずき", Disp: Ambiguous, KeyTrusted: false},
|
||
// ⚠ NEITHER row is Confirmed, and that is what makes this case able to fail. With the unsigned row
|
||
// Confirmed, a rule that keyed the signed set on the DISPOSITION still filled the set — off the
|
||
// unsigned row — and the yield happened anyway, so the planting survived under a green assertion.
|
||
// The signature must be the ONLY thing that can populate the set here.
|
||
{entry: &entry{src: "鈴木", dst: "Судзуки-сан", status: "auto", gender: "male"}, Via: "鈴木", Disp: Ambiguous, KeyTrusted: true},
|
||
}
|
||
got5 := RenderEditorConstraintBlock(sortByPriority(doubtfulSigned), tx)
|
||
if n := strings.Count(got5, "鈴木 → "); n != 1 {
|
||
t.Errorf("a SIGNED row that fired on a doubtful key is still the canon others yield to, got %d lines:\n%s", n, got5)
|
||
}
|
||
if !strings.Contains(got5, "鈴木 → «Судзуки»") {
|
||
t.Errorf("the signed rendering must be the survivor even when its own match is uncertain:\n%s", got5)
|
||
}
|
||
|
||
// (6) THE STATUS THAT YIELDS IS «NOT SIGNED», not «draft». `auto` is what the miner writes — the engine's
|
||
// own row, which is precisely the row this rule exists to subordinate — and a fixture using only `draft`
|
||
// let a planting narrow the rule to `draft` and leave every mined row standing beside the canon.
|
||
for _, status := range []string{"draft", "auto"} {
|
||
rows := []PickedEntry{
|
||
{entry: &entry{src: "元石", dst: "юаньши", status: status, gender: "none"}, Via: "元石", Disp: Ambiguous, KeyTrusted: true},
|
||
{entry: &entry{src: "元石", dst: "Юаньши", status: "approved"}, Via: "元石", Disp: Confirmed, KeyTrusted: true},
|
||
}
|
||
got := RenderEditorConstraintBlock(sortByPriority(rows), tx)
|
||
if n := strings.Count(got, "元石 → "); n != 1 {
|
||
t.Errorf("an unsigned row with status %q must yield to the signed canon, got %d lines:\n%s", status, n, got)
|
||
}
|
||
if !strings.Contains(got, "元石 → «Юаньши»") {
|
||
t.Errorf("status %q: the signed rendering must be the survivor:\n%s", status, got)
|
||
}
|
||
}
|
||
|
||
// (7) ORDER-INDEPENDENCE, asserted where it can actually fail: the block is rendered DIRECTLY, with no
|
||
// sortByPriority in front of it, in both orders. A single-pass rule that recorded signatures as it went
|
||
// would keep whichever row arrived first and pass every case above, because Select sorts the signed row
|
||
// to the front in production. The render must not depend on being handed a sorted slice.
|
||
unsignedFirst := []PickedEntry{
|
||
{entry: &entry{src: "蛊", dst: "гу-червь", status: "auto"}, Via: "蛊", Disp: Ambiguous, KeyTrusted: true},
|
||
{entry: &entry{src: "蛊", dst: "гу", status: "approved"}, Via: "蛊", Disp: Confirmed, KeyTrusted: true},
|
||
}
|
||
for _, c := range []struct {
|
||
name string
|
||
rows []PickedEntry
|
||
}{
|
||
{"unsigned first", unsignedFirst},
|
||
{"signed first", []PickedEntry{unsignedFirst[1], unsignedFirst[0]}},
|
||
} {
|
||
got := RenderEditorConstraintBlock(c.rows, tx)
|
||
if n := strings.Count(got, "蛊 → "); n != 1 {
|
||
t.Errorf("%s: one order per surface must not depend on the input order, got %d lines:\n%s", c.name, n, got)
|
||
}
|
||
if !strings.Contains(got, "蛊 → «гу»") {
|
||
t.Errorf("%s: the SIGNED rendering must survive whichever order it arrives in:\n%s", c.name, got)
|
||
}
|
||
}
|
||
|
||
// (8) A SIGNED ROW WITH NO RENDERING IS NOT A CANON. It orders nothing, so nothing may yield to it —
|
||
// otherwise the unsigned rendering is dropped in favour of a row that prints no line at all and the term
|
||
// disappears from the law block entirely. The seed loader refuses such a row today, so the clause is
|
||
// defensive; it is pinned because «unreachable» is a statement about the CURRENT loader, and the clause
|
||
// is one word away from being deleted as dead code.
|
||
emptyCanon := []PickedEntry{
|
||
{entry: &entry{src: "灵泉", dst: "", status: "approved"}, Via: "灵泉", Disp: Confirmed, KeyTrusted: true},
|
||
{entry: &entry{src: "灵泉", dst: "духовный источник", status: "auto"}, Via: "灵泉", Disp: Ambiguous, KeyTrusted: true},
|
||
}
|
||
if got := RenderEditorConstraintBlock(sortByPriority(emptyCanon), tx); !strings.Contains(got, "灵泉 → «духовный источник»") {
|
||
t.Errorf("a signed row with no rendering orders nothing, so the unsigned rendering must stand — "+
|
||
"yielding to it deletes the term from the block:\n%s", got)
|
||
}
|
||
|
||
// (9) The signature set and the lookup must agree about whitespace: a src recorded untrimmed and looked
|
||
// up trimmed silently stops matching, and every unsigned row of that surface becomes law again.
|
||
padded := []PickedEntry{
|
||
{entry: &entry{src: " 元海 ", dst: "Юаньхай", status: "approved"}, Via: "元海", Disp: Confirmed, KeyTrusted: true},
|
||
{entry: &entry{src: "元海", dst: "Юань Хай", status: "auto"}, Via: "元海", Disp: Ambiguous, KeyTrusted: true},
|
||
}
|
||
if n := strings.Count(RenderEditorConstraintBlock(sortByPriority(padded), tx), "元海 → "); n != 1 {
|
||
t.Errorf("the signed set and the lookup must normalize the surface the same way, got %d lines", n)
|
||
}
|
||
|
||
// (10) ⚠ TWO UNSIGNED ROWS OF ONE SURFACE BOTH STAND — and this is a DECLARED CHANGE OF WIRE BEHAVIOUR,
|
||
// written down here because it happened and was not declared when it did.
|
||
//
|
||
// The rule this block enforces used to dedup on the bare `src`, which collapsed any two renderings of a
|
||
// surface to whichever the sort put first. That was corrected because it dropped a second SIGNED canon
|
||
// (青山 the surname and 青山 the direction are two decisions the owner made separately). The correction
|
||
// keys on (src, dst) and makes only an UNSIGNED row yield, and only to a SIGNED one — so where there is
|
||
// no signature at all, nothing yields and both renderings reach the model. Measured across the change:
|
||
// the same two unsigned rows produced ONE line before and TWO after.
|
||
//
|
||
// ⚠ WHAT THIS CASE DOES AND DOES NOT SAY. It pins the RENDER's behaviour: with no signature present
|
||
// there is nothing to prefer, so the block does not arbitrate — it shows both and lets the run's own
|
||
// unsigned-conflict report (TestUnsignedConflictIsReportedByTheRun) be the place a human is told. A
|
||
// silent pick-by-sort-order would be the engine choosing between two of its own guesses and hiding
|
||
// that it did, which is strictly worse at THIS layer.
|
||
//
|
||
// It does NOT say that two unsigned renderings of one surface may exist in the first place. That is an
|
||
// UPSTREAM question and the owner has answered it: an unsigned term must have ONE rendering too, and
|
||
// the consolidation stage owns that, not the signature. Measured today, the bank accepts the state —
|
||
// two rows, one src, one (empty) sense, overlapping spoiler windows, different dst — because the
|
||
// same-sense overlap guard (memseed.go) runs PER FILE and the merge (storeOrder) refuses only an exact
|
||
// duplicate of the UNIQUE key, so a seed row and a mined-delta row collide only after both have passed
|
||
// their own loader. When that is fixed upstream this state stops being reachable and this case becomes
|
||
// a statement about something that no longer occurs — which is the right time to delete it, and the
|
||
// wrong time to read it as doctrine that two lines are DESIRABLE.
|
||
unsignedPair := []PickedEntry{
|
||
{entry: &entry{src: "元石", dst: "юаньши", status: "draft"}, Via: "元石", Disp: Ambiguous, KeyTrusted: true},
|
||
{entry: &entry{src: "元石", dst: "камень первоисточника", status: "auto"}, Via: "元石", Disp: Ambiguous, KeyTrusted: true},
|
||
}
|
||
got10 := RenderEditorConstraintBlock(sortByPriority(unsignedPair), tx)
|
||
if n := strings.Count(got10, "元石 → "); n != 2 {
|
||
t.Errorf("two UNSIGNED renderings of one surface must BOTH reach the model — there is no signature "+
|
||
"for either to yield to, and picking one by sort order hides a disagreement the run reports. "+
|
||
"Got %d lines:\n%s", n, got10)
|
||
}
|
||
// And the draft wire must say the same thing: the two wires disagreeing about how many renderings a
|
||
// surface has is the shape the original regression took.
|
||
if draft := RenderGlossaryBlock(sortByPriority(unsignedPair), tx); strings.Count(draft, "元石 → ") != 2 {
|
||
t.Errorf("the two wires disagree about the unsigned pair:\neditor: %s\ndraft: %s", got10, draft)
|
||
}
|
||
}
|
||
|
||
// TestTheBudgetKeepsTheTrustworthyMatchFirst is where priorityRank's DISPOSITION axis actually decides
|
||
// something, and it is the only place it can be separated from the STATUS axis: two SIGNED rows, one matched
|
||
// by a collision-prone key. Everywhere else the two are correlated — an auto row is always ambiguous — which
|
||
// is why removing the disposition bit passed the whole tree when it was planted.
|
||
//
|
||
// The budget is what makes the order observable: it admits one record, and the one that must survive is the
|
||
// row whose key is trustworthy, because a doubtful match is the one that may not be about the entity at all.
|
||
func TestTheBudgetKeepsTheTrustworthyMatchFirst(t *testing.T) {
|
||
// Both APPROVED, both real; only the KEY differs. すずき is three kana — short and purely phonetic, so
|
||
// the A2 collision rule downgrades it to AMBIGUOUS despite the signature — while たなかさん is long
|
||
// enough to be trusted. Han keys anchor at 2, so this state is only reachable on a phonetic source,
|
||
// which is exactly why no zh fixture ever separated the two axes.
|
||
clean := gl("たなかさん", "Танака-сан", "", "approved")
|
||
doubtful := gl("すずき", "Судзуки", "", "approved")
|
||
const chunk = "すずきとたなかさんが来た。"
|
||
// ⚠ THE DOUBTFUL ROW IS LISTED FIRST, and that is the whole test: entries keep their frozen order, so a
|
||
// priority rule that had lost its disposition axis would leave it first and the assertion below would be
|
||
// satisfied by accident. Planted, that is exactly what happened until this order was fixed.
|
||
b := bankFrom([]store.GlossaryEntry{doubtful, clean})
|
||
sel := b.Select(chunk, 1, nil, 0)
|
||
if m := injMap(sel); len(m) != 2 || m["たなかさん"] != Confirmed || m["すずき"] != Ambiguous {
|
||
t.Fatalf("premise: one trusted and one collision-prone SIGNED row must fire, got %+v", m)
|
||
}
|
||
if sel.Injected[0].entry.src != "たなかさん" {
|
||
t.Fatalf("the trustworthy match must rank first, so a binding budget keeps it: got %q first", sel.Injected[0].entry.src)
|
||
}
|
||
// With a budget that admits ONE, the doubtful match is what gets evicted — the preference the
|
||
// disposition bit exists for, and the only observable consequence it has.
|
||
tight := b.Select(chunk, 1, nil, glossaryLineTokens(sel.Injected[0].entry))
|
||
if len(tight.Injected) != 1 || tight.Injected[0].entry.src != "たなかさん" {
|
||
t.Fatalf("a binding budget must spend on the trustworthy match: injected %+v, evicted %+v", injMap(tight), tight.Evicted)
|
||
}
|
||
}
|
||
|
||
// 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
|
||
}
|
||
|
||
// TestAStickyRecordIsStillLawForTheEditor pins what the block's own doccomment claims — "every injected
|
||
// record with a rendering" — and it claimed it about a population no test contained. Planted, excluding
|
||
// sticky records from the editor block made them vanish from the law with the whole tree green, golden
|
||
// included: a term carried by scene inertia into a pronominal chunk is exactly the case where the editor
|
||
// most needs to know what the book calls it, and it would have been the case the block silently dropped.
|
||
func TestAStickyRecordIsStillLawForTheEditor(t *testing.T) {
|
||
tx := ruTX()
|
||
carried := PickedEntry{
|
||
entry: &entry{src: "方源", dst: "Фан Юань", status: "approved", gender: "male"},
|
||
Sticky: true, Disp: Confirmed, KeyTrusted: true,
|
||
}
|
||
block := RenderEditorConstraintBlock([]PickedEntry{carried}, tx)
|
||
if !strings.Contains(block, "方源 → «Фан Юань»") {
|
||
t.Fatalf("a carried record is law for the editor exactly like a fresh hit:\n%s", block)
|
||
}
|
||
if !strings.Contains(block, tx.GenderMale) {
|
||
t.Errorf("and it keeps its directive — the carry does not weaken what the row says:\n%s", block)
|
||
}
|
||
// The translator's block has always carried them; the two must not disagree about which records exist.
|
||
if draft := RenderGlossaryBlock([]PickedEntry{carried}, tx); !strings.Contains(draft, "方源 → Фан Юань") {
|
||
t.Errorf("the two wires disagree about a carried record:\n%s", draft)
|
||
}
|
||
}
|