package membank import ( "errors" "strings" "testing" "gopkg.in/yaml.v3" "textmachine/backend/internal/seed" "textmachine/backend/internal/store" ) // decisions_test.go: the SEMANTICS of the bank-decisions door (D39.156, promt §4.2). Every rule here is // one a caller can lose money or consistency by getting wrong, so each has its own case and its own name. func bankRow(src, dst, status, source string) store.GlossaryEntry { return store.GlossaryEntry{Src: src, Dst: dst, Status: status, Source: source} } func approve(src, dst string) Decision { return Decision{Action: ActionApprove, Src: src, Dst: dst} } // applyOK runs the pass and fails the test if anything was refused — for the cases whose subject is what // an ACCEPTED decision does. func applyOK(t *testing.T, in ApplyInput) ApplyResult { t.Helper() res := ApplyDecisions(in) if len(res.Rejected) > 0 { t.Fatalf("unexpected refusals: %+v", res.Rejected) } return res } func TestApproveWritesAnExplicitApprovedStatus(t *testing.T) { // The load-bearing byte of the whole promotion (backlog row 199, mine 1). The seed loader defaults an // ABSENT status to approved, so a file without the field would work — but the signature map a caller // copies from carries `status: auto`, which the mining stop filters out as unsigned, and the stop then // never clears. Writing the word is what makes the promotion survive being read back. res := applyOK(t, ApplyInput{Decisions: []Decision{approve("方源", "Фан Юань")}}) if len(res.Delta.Terms) != 1 { t.Fatalf("got %d delta terms, want 1", len(res.Delta.Terms)) } if got := res.Delta.Terms[0].Status; got != "approved" { t.Fatalf("status = %q, want an EXPLICIT \"approved\"", got) } raw, err := RenderSeedFile(res.Delta) if err != nil { t.Fatal(err) } if !strings.Contains(string(raw), "status: approved") { t.Errorf("the rendered file must SPELL the status; got:\n%s", raw) } } func TestApproveWithoutDstIsRefused(t *testing.T) { // Mine 2 of row 199: an approved/draft term with an empty dst is not a weak signature, it is a LOAD // FAILURE of the next run. A WHICH-only surface can therefore only ever be declined. res := ApplyDecisions(ApplyInput{Decisions: []Decision{{Action: ActionApprove, Src: "蛊"}}}) if len(res.Rejected) != 1 { t.Fatalf("want exactly one refusal, got %+v", res.Rejected) } if !strings.Contains(res.Rejected[0].Reason, "dst") { t.Errorf("the refusal must say what is missing, got %q", res.Rejected[0].Reason) } // …and it must be charged to the DECISION, not to the document: a caller with ten decisions has to // be told which one to fix. if res.Rejected[0].Index != 0 { t.Errorf("index = %d, want the offending decision's own index", res.Rejected[0].Index) } } func TestDeclineCarryingARenderingIsIllFormed(t *testing.T) { res := ApplyDecisions(ApplyInput{Decisions: []Decision{{Action: ActionDecline, Src: "蛊", Dst: "Гу"}}}) if len(res.Rejected) != 1 || res.Rejected[0].Index != 0 { t.Fatalf("a decline that also names a rendering is half an edit and must be refused: %+v", res.Rejected) } } func TestDeclineWritesTheRejectAndWithdrawsTheApproval(t *testing.T) { // A decision REPLACES the opposite one; there is no return to the undecided state. And the withdrawal // has to be NAMED, or an owner cannot see that they overwrote their own earlier decision. in := ApplyInput{ Delta: seed.File{Terms: []seed.Term{{Src: "蛊", Dst: "Гу", Status: "approved"}}}, Decisions: []Decision{{Action: ActionDecline, Src: "蛊", Note: "не термин"}}, } res := applyOK(t, in) if len(res.Delta.Terms) != 0 { t.Errorf("declining a surface must withdraw its approval, %d term(s) left", len(res.Delta.Terms)) } if len(res.Rejects.Rejects) != 1 || res.Rejects.Rejects[0].Src != "蛊" { t.Fatalf("reject list = %+v", res.Rejects.Rejects) } if len(res.Accepted) != 1 || len(res.Accepted[0].Replaced) == 0 { t.Errorf("what the decision displaced must be reported: %+v", res.Accepted) } } func TestApproveWithdrawsAPriorDecline(t *testing.T) { in := ApplyInput{ Rejects: seed.RejectFile{Rejects: []seed.Reject{{Src: "方源"}}}, Decisions: []Decision{approve("方源", "Фан Юань")}, } res := applyOK(t, in) if len(res.Rejects.Rejects) != 0 { t.Errorf("approving a declined surface must withdraw the decline, got %+v", res.Rejects.Rejects) } } func TestRepeatedDecisionIsAlreadyApplied(t *testing.T) { // The idempotency contract as a caller sees it. Two resumes and a worker retry all exist; a verb that // applied twice would split the state between them. first := applyOK(t, ApplyInput{Decisions: []Decision{approve("方源", "Фан Юань")}}) second := applyOK(t, ApplyInput{Delta: first.Delta, Decisions: []Decision{approve("方源", "Фан Юань")}}) if second.Accepted[0].State != StateAlreadyApplied { t.Errorf("state = %q, want %q", second.Accepted[0].State, StateAlreadyApplied) } a, _ := RenderSeedFile(first.Delta) b, _ := RenderSeedFile(second.Delta) if string(a) != string(b) { t.Errorf("a repeat decision changed the document:\n%s\n---\n%s", a, b) } } func TestReDecidingAReplacesTheRendering(t *testing.T) { first := applyOK(t, ApplyInput{Decisions: []Decision{approve("方源", "Фан Юань")}}) second := applyOK(t, ApplyInput{Delta: first.Delta, Decisions: []Decision{approve("方源", "Фан Юань-Другой")}}) if len(second.Delta.Terms) != 1 || second.Delta.Terms[0].Dst != "Фан Юань-Другой" { t.Fatalf("re-deciding must REPLACE in place, got %+v", second.Delta.Terms) } if second.Accepted[0].State != StateApplied { t.Errorf("state = %q, want %q", second.Accepted[0].State, StateApplied) } } func TestApproveKeepsHandAuthoredMetadata(t *testing.T) { // The decision vocabulary is the published read-out's, which does not carry `gender`. Replacing the // row wholesale would therefore DELETE a field the owner set by hand and that no decision can restore. in := ApplyInput{ Delta: seed.File{Terms: []seed.Term{{Src: "方源", Dst: "Фан", Gender: "male", Status: "approved"}}}, Decisions: []Decision{approve("方源", "Фан Юань")}, } res := applyOK(t, in) if res.Delta.Terms[0].Gender != "male" { t.Errorf("a decision about the rendering discarded the term's gender: %+v", res.Delta.Terms[0]) } } func TestOneCallDecidesATermOnce(t *testing.T) { res := ApplyDecisions(ApplyInput{Decisions: []Decision{approve("方源", "A"), approve("方源", "B")}}) if len(res.Rejected) == 0 { t.Fatal("two decisions about one term in one call are ill-formed") } if len(res.Delta.Terms) != 0 { t.Error("an ill-formed call must leave the document untouched") } } func TestOneCallCannotBothApproveAndDeclineASurface(t *testing.T) { res := ApplyDecisions(ApplyInput{Decisions: []Decision{ approve("方源", "Фан Юань"), {Action: ActionDecline, Src: "方源"}, }}) if len(res.Rejected) == 0 { t.Fatal("promoting a term and suppressing its proposals in one call is a contradiction") } } // TestAllOrNothing is the mutation pin for a PARTIAL write: nine good decisions and one refused must // leave the files exactly as they were, and the report must name the refusal. func TestAllOrNothing(t *testing.T) { prior := seed.File{Terms: []seed.Term{{Src: "蛊", Dst: "Гу", Status: "approved"}}} ds := []Decision{approve("方源", "Фан Юань"), {Action: ActionApprove, Src: "花月"}} // the second has no dst res := ApplyDecisions(ApplyInput{Delta: prior, Decisions: ds}) if len(res.Rejected) != 1 { t.Fatalf("want one refusal, got %+v", res.Rejected) } if len(res.Accepted) != 0 { t.Errorf("a refused call reports NOTHING as accepted, got %+v", res.Accepted) } before, _ := RenderSeedFile(prior) after, _ := RenderSeedFile(res.Delta) if string(before) != string(after) { t.Errorf("a refused call changed the document:\n%s\n---\n%s", before, after) } } func TestDecisionOnASignedSeedTermIsRefusedByName(t *testing.T) { // Silently accepting it would mine the next run: the delta row's UNIQUE key is the seed row's, and // the flat INSERT in ReplaceGlossary aborts the whole paid run on the constraint (D39.20 deviation 1). seedRows := []store.GlossaryEntry{bankRow("方源", "Фан Юань", "approved", "seed")} res := ApplyDecisions(ApplyInput{Seed: seedRows, Decisions: []Decision{approve("方源", "Другой")}}) if len(res.Rejected) != 1 { t.Fatalf("want one refusal, got %+v", res.Rejected) } r := res.Rejected[0] if !strings.Contains(r.Reason, "方源") || !strings.Contains(r.Reason, "seed") { t.Errorf("the refusal must NAME the conflicting seed term, got %q", r.Reason) } } func TestDecliningASignedSeedSurfaceIsRefused(t *testing.T) { // A reject only filters PROPOSALS, and a seeded surface is already excluded from them — so the // decision would read as done and change nothing. // // ⚠ The REASON has to be that one, and the live probe on the stand book is what found it otherwise: a // decline whose key happened to match the seed row exactly was refused with the APPROVE's reasoning // ("a delta row with that key would crash the bank replace") — false for a decline, which writes no // delta row at all. A refusal that names the wrong mechanism sends the caller to fix the wrong thing. seedRows := []store.GlossaryEntry{bankRow("方源", "Фан Юань", "approved", "seed")} res := ApplyDecisions(ApplyInput{Seed: seedRows, Decisions: []Decision{{Action: ActionDecline, Src: "方源"}}}) if len(res.Rejected) != 1 { t.Fatalf("want one refusal, got %+v", res.Rejected) } if r := res.Rejected[0].Reason; !strings.Contains(r, "filters PROPOSALS") { t.Errorf("a decline must be refused for the DECLINE's reason, got: %q", r) } } // TestPromotionKeepsTheBankRowAliases: promoting a mined row must carry the miner's alias CLUSTER with // it. Once a signed delta row holds the key, the auto-bank row carrying those surfaces is dropped as a // key clash (loadAutoBank) — so a promotion that started from a blank row would silently narrow the term // to its own src, and the other surfaces of the same entity would stop matching. Found by review. func TestPromotionKeepsTheBankRowAliases(t *testing.T) { row := store.GlossaryEntry{Src: "方源", Status: "auto", Source: "mined", Type: "name", Aliases: []store.GlossaryAlias{{Alias: "方小子", AliasType: "mined"}, {Alias: "源仔", AliasType: "mined"}}} res := applyOK(t, ApplyInput{ Bank: []store.GlossaryEntry{row}, Decisions: []Decision{{Action: ActionApprove, ID: TermID("方源", "", 0, 0), Dst: "Фан Юань"}}, }) got := res.Delta.Terms[0] if len(got.Aliases) != 2 { t.Fatalf("the promoted row carries %d aliases, the bank row carried %d: %+v", len(got.Aliases), len(row.Aliases), got) } if got.Aliases[0].Type != "mined" { t.Errorf("the alias TYPE must survive too: %+v", got.Aliases) } if got.Type != "name" { t.Errorf("the engine's own classification must survive a decision that does not name a kind: %q", got.Type) } } func TestTwoDeclinesOfOneSurfaceAreIllFormed(t *testing.T) { // A decline names a SURFACE, not a sense: the reject list holds a src and nothing else. Two of them // in one call are two decisions about one thing — and before the guard covered this, the second // silently overwrote the first's note while BOTH reported applied. res := ApplyDecisions(ApplyInput{Decisions: []Decision{ {Action: ActionDecline, Src: "方源", Sense: "герой", Note: "первая"}, {Action: ActionDecline, Src: "方源", Sense: "гора", Note: "вторая"}, }}) if len(res.Rejected) == 0 { t.Fatalf("two declines of one surface must be refused, got accepted=%+v", res.Accepted) } } func TestTwoApprovalsOfOneSurfaceInDifferentWindowsStayLegitimate(t *testing.T) { // The other side of that guard: an approve is KEY-scoped, so one surface across two spoiler windows // is genuinely two terms and must not be caught by the decline rule. // // The windows are NON-overlapping deliberately. Two renderings of one surface over overlapping // windows is the D16.1 polysemy livelock — the matcher keys only on src and cannot pick a sense — // and the document check refuses it, correctly. Measured: the first version of this case used // overlapping windows and was refused for exactly that reason. res := ApplyDecisions(ApplyInput{Decisions: []Decision{ {Action: ActionApprove, Src: "Mark", Sense: "имя", UntilChapter: 10, Dst: "Марк"}, {Action: ActionApprove, Src: "Mark", Sense: "валюта", SinceChapter: 11, Dst: "марка"}, }}) if len(res.Rejected) != 0 { t.Fatalf("two senses of one surface are two terms: %+v", res.Rejected) } if len(res.Delta.Terms) != 2 { t.Fatalf("got %d terms, want 2", len(res.Delta.Terms)) } } // TestDecliningACharacterAVoiceProfileNamesIsRefused: a voice/address row naming a term the bank does // not have STOPS the run, and a seed may legitimately name a character whose TERM lives in the delta. // Declining it was an ACCEPTED decision that killed the next run — reproduced before the fix. func TestDecliningACharacterAVoiceProfileNamesIsRefused(t *testing.T) { in := ApplyInput{ Voices: []store.VoiceProfile{{Src: "方源", Sense: "", Register: "сухой", SinceCh: 1}}, Delta: seed.File{Terms: []seed.Term{{Src: "方源", Dst: "Фан Юань", Status: "approved"}}}, Decisions: []Decision{{Action: ActionDecline, Src: "方源"}}, } res := ApplyDecisions(in) if len(res.Rejected) == 0 { t.Fatalf("declining the only term a voice profile names must be refused, got %+v", res.Accepted) } if len(res.Delta.Terms) != 1 { t.Error("a refused call must leave the delta untouched") } } func TestDecliningAnAliasOfAnApprovedTermIsRefused(t *testing.T) { // The same rule as the seed-surface decline, one file along: an alias is already excluded from // proposals, and this door cannot remove one — so accepting the decline would report "applied" for a // decision that did nothing, while the term went on rendering that very surface. Found by review. in := ApplyInput{ Delta: seed.File{Terms: []seed.Term{{Src: "方源", Dst: "Фан Юань", Status: "approved", Aliases: []seed.Alias{{Alias: "方小子", Type: "mined"}}}}}, Decisions: []Decision{{Action: ActionDecline, Src: "方小子"}}, } res := ApplyDecisions(in) if len(res.Rejected) != 1 { t.Fatalf("want one refusal, got accepted=%+v rejects=%+v", res.Accepted, res.Rejects.Rejects) } if !strings.Contains(res.Rejected[0].Reason, "方源") { t.Errorf("the refusal must name the term that owns the alias: %q", res.Rejected[0].Reason) } // Declining the term ITSELF still works — the guard must not swallow the ordinary case. ok := ApplyDecisions(ApplyInput{Delta: in.Delta, Decisions: []Decision{{Action: ActionDecline, Src: "方源"}}}) if len(ok.Rejected) != 0 { t.Fatalf("declining the term itself must still work: %+v", ok.Rejected) } } // TestApprovingARubyReadingOfASeedTermIsRefused closes the ruby half of the same class as the voice one: // the run GRAFTS every name-shaped ruby reading onto its seed term as a firing alias before it judges // collisions, so a seed term is reachable through surfaces its file never spells. A door that read only // the seed file accepted a mined term whose src IS such a reading, reported it applied, and handed the // next run the D16.1 livelock — proven end to end by review before this check existed. func TestApprovingARubyReadingOfASeedTermIsRefused(t *testing.T) { sd := []store.GlossaryEntry{{Src: "高橋", Dst: "Такахаси", Status: "approved", Source: "seed"}} ruby := []store.RubyReading{{Base: "高橋", Reading: "たかはし", Occurrences: 3, FirstChapter: 1}} res := ApplyDecisions(ApplyInput{Seed: sd, Ruby: ruby, Decisions: []Decision{approve("たかはし", "Другое Имя")}}) if len(res.Rejected) == 0 { t.Fatalf("the kana reading is a FIRING surface of the seed term; approving another rendering for it is the livelock: %+v", res.Accepted) } // …and the seed rows the check borrowed must come back untouched: the graft happens on a copy, or // the before/after comparison would judge two different seeds. if len(sd[0].Aliases) != 0 { t.Errorf("the caller's seed rows were mutated by the check: %+v", sd[0].Aliases) } // The SAME rendering is a legitimate merge and must still pass. ok := ApplyDecisions(ApplyInput{Seed: sd, Ruby: ruby, Decisions: []Decision{approve("たかはし", "Такахаси")}}) if len(ok.Rejected) != 0 { t.Errorf("the same rendering on a reading surface is a merge, not a contradiction: %+v", ok.Rejected) } } func TestDecisionByBankID(t *testing.T) { // The id is an INDEPENDENTLY computed constant, not TermID's own output. Deriving the expectation from // the function under test made this pin agree with any implementation of it — including one that lost // the property the length prefixes exist for, and including one that changed the published id under a // consumer holding the old one. Computed outside Go, from the spec in TermID's own comment: the four // parts (src, sense, since, until) each written as «:», concatenated, SHA-256, the // first 16 hex characters. const wantID = "e1e2b5625d031d33" // 方源 / no sense / window [0,0] row := bankRow("方源", "", "auto", "mined") if got := TermID(row.Src, row.Sense, row.SinceCh, row.UntilCh); got != wantID { t.Fatalf("the PUBLISHED id of a term moved: %q, want %q — every consumer holding the old one now names nothing", got, wantID) } res := applyOK(t, ApplyInput{ Bank: []store.GlossaryEntry{row}, Decisions: []Decision{{Action: ActionApprove, ID: wantID, Dst: "Фан Юань"}}, }) if len(res.Delta.Terms) != 1 || res.Delta.Terms[0].Src != "方源" { t.Fatalf("an id must resolve to its term, got %+v", res.Delta.Terms) } if res.Accepted[0].ID != wantID { t.Errorf("the report must echo the id: %q vs %q", res.Accepted[0].ID, wantID) } // The window is part of the identity: a term re-cut to another window is another row, and the id has // to say so or a decision would land on the wrong one. if same := TermID("方源", "", 1, 20); same == wantID { t.Fatal("two different chapter windows produced one id") } } // TestTermIDIsInjectiveWhereASeparatorWouldNotBe pins the PROPERTY the length prefixes were introduced // for, which no example of a single id can show: the encoding is injective by construction, so it does // not rest on an assumption about what the data contains. // // The pair below is the counterexample to the separator-joined encoding the first version used: joined // on U+001F, («a␟b», «c») and («a», «b␟c») are the SAME string, so two different terms would share an id // and a decision naming one would silently land on the other. `src` and `sense` are free text copied out // of a seed YAML and nothing on the load path rejects a control character, so the assumption was about // data the engine never validates. Both ids are independently computed, like the one above. func TestTermIDIsInjectiveWhereASeparatorWouldNotBe(t *testing.T) { const sep = "\x1f" a := TermID("a"+sep+"b", "c", 0, 0) b := TermID("a", "b"+sep+"c", 0, 0) if a == b { t.Fatalf("two different terms share the id %q — the encoding is not injective", a) } if a != "3e6e2a9e576ee801" || b != "aba452db63439ad6" { t.Fatalf("the published ids moved: %q / %q", a, b) } } func TestUnknownBankIDIsRefused(t *testing.T) { res := ApplyDecisions(ApplyInput{Decisions: []Decision{{Action: ActionApprove, ID: "deadbeefdeadbeef", Dst: "X"}}}) if len(res.Rejected) != 1 || !strings.Contains(res.Rejected[0].Reason, "deadbeefdeadbeef") { t.Fatalf("an unresolvable id must be refused by name, got %+v", res.Rejected) } } func TestIDAndTupleTogetherAreIllFormed(t *testing.T) { row := bankRow("方源", "", "auto", "mined") res := ApplyDecisions(ApplyInput{ Bank: []store.GlossaryEntry{row}, Decisions: []Decision{{Action: ActionApprove, ID: TermID("方源", "", 0, 0), Src: "蛊", Dst: "Гу"}}, }) if len(res.Rejected) != 1 { t.Fatalf("naming a term twice in one decision must be refused, got %+v", res.Rejected) } } func TestUnknownActionIsRefused(t *testing.T) { res := ApplyDecisions(ApplyInput{Decisions: []Decision{{Action: "clear", Src: "方源"}}}) if len(res.Rejected) != 1 { t.Fatalf("there is no third verb, got %+v", res.Rejected) } } // TestACollisionTheSetWouldIntroduceIsRefused: the result has to survive the checks `translate` runs on // the way into the bank, or the door accepts a decision that kills the next paid run hours later. func TestACollisionTheSetWouldIntroduceIsRefused(t *testing.T) { seedRows := []store.GlossaryEntry{{Src: "赵大", Dst: "Чжао Да", Status: "approved", Aliases: []store.GlossaryAlias{{Alias: "老赵"}}}} res := ApplyDecisions(ApplyInput{Seed: seedRows, Decisions: []Decision{approve("老赵", "Старина Чжао")}}) if len(res.Rejected) == 0 { t.Fatal("a shared firing key with a different rendering is the D16.1 livelock class and must be refused") } } // TestAPreExistingCollisionDoesNotBlockAnUnrelatedDecision is the other half of that rule. A book whose // files already contradict each other must stay repairable through this door — refusing a decision over // a fault it did not cause would make the door useless exactly when it is needed. // // …but it must not be SILENT either: a verb that answers "applied, exit 0" about a book whose next run // is already going to die at the bank boundary is telling the caller something false by omission. The // fault is reported, not charged. (Found independently by review; the first version only had the // not-charged half.) func TestAPreExistingCollisionDoesNotBlockAnUnrelatedDecision(t *testing.T) { seedRows := []store.GlossaryEntry{{Src: "赵大", Dst: "Чжао Да", Status: "approved", Aliases: []store.GlossaryAlias{{Alias: "老赵"}}}} prior := seed.File{Terms: []seed.Term{{Src: "老赵", Dst: "Старина Чжао", Status: "approved"}}} res := ApplyDecisions(ApplyInput{Seed: seedRows, Delta: prior, Decisions: []Decision{approve("花月", "Хуа Юэ")}}) if len(res.Rejected) != 0 { t.Fatalf("a pre-existing collision must not be charged to an unrelated decision: %+v", res.Rejected) } if len(res.Delta.Terms) != 2 { t.Errorf("got %d terms, want the prior one plus the new one", len(res.Delta.Terms)) } if len(res.Preexisting) == 0 { t.Error("the fault the files ALREADY have must be reported, not passed over in silence") } // A clean book reports nothing, so the field cannot read as noise. clean := ApplyDecisions(ApplyInput{Decisions: []Decision{approve("花月", "Хуа Юэ")}}) if len(clean.Preexisting) != 0 { t.Errorf("a clean book must report no pre-existing fault, got %+v", clean.Preexisting) } } func TestDecodeDecisionsRefusesAnUnknownField(t *testing.T) { _, err := DecodeDecisions([]byte(`{"decisions_version":"` + DecisionsVersion + `","book_id":"b","decisions":[{"action":"approve","src":"x","dst":"y","gender":"male"}]}`)) if err == nil { t.Fatal("a field this engine does not know is a caller believing it set something") } } func TestDecodeDecisionsRefusesAForeignVersion(t *testing.T) { if _, err := DecodeDecisions([]byte(`{"decisions_version":"tm-bank-decisions-v99","book_id":"b","decisions":[]}`)); err == nil { t.Fatal("a document version this engine does not speak must be refused loudly") } } func TestDecodeDecisionsRequiresABookID(t *testing.T) { if _, err := DecodeDecisions([]byte(`{"decisions_version":"` + DecisionsVersion + `","decisions":[{"action":"approve","src":"x","dst":"y"}]}`)); err == nil { t.Fatal("decisions carry a user's words into a book's canon; the book must be named") } } // --- the dofix pack: pins written AFTER planting the mutation each one has to catch ------------------ func TestALeadingNewlineNoteSurvivesTheWriteAndTheReadBack(t *testing.T) { // The blocker the acceptance found, as an execution. `note` is free text a user types, and a value // whose first byte is a newline is perfectly lawful — it was ACCEPTED (exit 0, applied), written as a // block scalar the engine's own parser refuses, and every later call on that book died reading it, // including the projection the law calls a safe preview. The book was repairable only with the text // editor this door exists to abolish. // // Two properties, and the pin needs both: the call is accepted, and what it writes READS BACK. res := applyOK(t, ApplyInput{Decisions: []Decision{ {Action: ActionDecline, Src: "方小子", Note: "\nне термин"}, {Action: ActionApprove, Src: "方源", Dst: "Фан Юань", Note: "\n\tи с табом"}, }}) rejects, err := RenderRejectFile(res.Rejects) if err != nil { t.Fatalf("the reject list must be writable: %v", err) } delta, err := RenderSeedFile(res.Delta) if err != nil { t.Fatalf("the delta must be writable: %v", err) } backR, err := seed.DecodeRejects(rejects) if err != nil { t.Fatalf("the engine wrote a reject list it cannot read:\n%s\n%v", rejects, err) } backD, err := seed.DecodeFile(delta) if err != nil { t.Fatalf("the engine wrote a delta it cannot read:\n%s\n%v", delta, err) } if len(backR.Rejects) != 1 || backR.Rejects[0].Note != "не термин" { t.Errorf("the note must survive, trimmed of the whitespace no format carries: %+v", backR.Rejects) } if len(backD.Terms) != 1 || backD.Terms[0].Note != "и с табом" { t.Errorf("the delta note must survive: %+v", backD.Terms) } // …and the whole book stays decidable afterwards, which is the half the acceptance's repro turned on. again := ApplyDecisions(ApplyInput{Delta: backD, Rejects: backR, Decisions: []Decision{approve("花月", "Хуа Юэ")}}) if len(again.Rejected) != 0 { t.Fatalf("the next call on that book must not be refused: %+v", again.Rejected) } } // TestYamlV3CannotReadBackWhatItWritesForALeadingNewline is a CHARACTERISATION of the dependency, not of // this engine. It states the upstream defect the normalizer and the render gate exist for, so the day the // library is replaced or fixed this test goes red and a session learns the workaround can go. // // Verified at source in gopkg.in/yaml.v3 v3.0.1: inside a block sequence item the emitter advances its // indent by a hard-coded 2 to skip the «- » indicator (emitterc.go:239-241, its own comment says so), // while the block-scalar indentation hint prints best_indent (emitterc.go:1848-1852). The two disagree by // exactly the width of «- », so the parser looks for the content two columns further right than it was // written. Upstream fixed this in the successor's v4 line (yaml/go-yaml PR #150, issue #65); v3.0.1 is // what go.mod pins and it is unmaintained. // // ⚠ The popular workaround — `enc.SetIndent(2)` — is WRONG here and is deliberately not used: measured on // this pin's version it makes the document parse and SILENTLY drops the leading newline, turning a loud // failure into a quiet edit of the owner's words. func TestYamlV3CannotReadBackWhatItWritesForALeadingNewline(t *testing.T) { raw, err := yaml.Marshal(seed.RejectFile{Rejects: []seed.Reject{{Src: "X", Note: "\nтекст"}}}) if err != nil { t.Fatal(err) } if !strings.Contains(string(raw), "|4-") { t.Fatalf("the library no longer emits the indentation hint — re-measure the workaround:\n%s", raw) } if _, err := seed.DecodeRejects(raw); err == nil { t.Fatalf("the library now reads back what it writes — seed.Normalize's second job and renderProved's\n"+ "reason are both re-openable, and this test should be replaced by the round-trip it now permits:\n%s", raw) } } func TestTheRenderGateRefusesBytesThatReadBackAsAnotherDocument(t *testing.T) { // The gate itself, not the input that reaches it. Normalization removes every value MEASURED to break // the emitter, so no reachable document exercises the gate today — which is exactly why it is a gate // and not an assumption: the measured set is not the same thing as the true set. Driven directly, with // a reader that lies, because that is the failure the gate is for. doc := seed.RejectFile{Rejects: []seed.Reject{{Src: "X", Note: "n"}}} _, err := renderProved(doc, func([]byte) (seed.RejectFile, error) { return seed.RejectFile{Rejects: []seed.Reject{{Src: "X", Note: "SOMETHING ELSE"}}}, nil }, "test document") if err == nil { t.Fatal("bytes that read back as a different document must never be handed to a writer") } if !strings.Contains(err.Error(), "line ") { t.Errorf("the refusal must point at a place: %v", err) } // A reader that refuses outright is the other half. if _, err := renderProved(doc, func([]byte) (seed.RejectFile, error) { return seed.RejectFile{}, errors.New("nope") }, "test document"); err == nil { t.Fatal("bytes that do not parse at all must never be handed to a writer") } } func TestRepairingOneOfTwoBrokenTermsIsAccepted(t *testing.T) { // The door's own promise (ApplyResult.Preexisting): «a book whose files already contradict each other // has to stay repairable through this door». It was false for MORE THAN ONE fault: the loadability // verdict named every broken term in one string, so fixing one changed the string entirely, the // before/after comparison saw a brand-new fault, and the call was refused — naming a term the decision // never touched. broken := seed.File{Terms: []seed.Term{ {Src: "甲", Dst: "", Status: "approved"}, {Src: "乙", Dst: "", Status: "approved"}, }} one := ApplyDecisions(ApplyInput{Delta: broken, Decisions: []Decision{approve("甲", "Цзя")}}) if len(one.Rejected) != 0 { t.Fatalf("repairing ONE of two pre-existing faults must be accepted, got %+v", one.Rejected) } if len(one.Preexisting) != 2 { t.Errorf("both faults must be REPORTED (they are still there): %+v", one.Preexisting) } // The repair actually landed, and the other fault is still standing. got, at := findTerm(one.Delta.Terms, termKey{Src: "甲"}) if at < 0 || got.Dst != "Цзя" { t.Errorf("the repaired term: %+v", one.Delta.Terms) } // And the second repair, on the document the first produced, is accepted too — which is the workflow. two := ApplyDecisions(ApplyInput{Delta: one.Delta, Decisions: []Decision{approve("乙", "И")}}) if len(two.Rejected) != 0 { t.Fatalf("the second repair must be accepted too: %+v", two.Rejected) } if len(two.Preexisting) != 1 || len(ApplyDecisions(ApplyInput{Delta: two.Delta, Decisions: []Decision{approve("丙", "Бин")}}).Preexisting) != 0 { t.Errorf("the reported set must shrink as the faults are repaired: %+v", two.Preexisting) } } func TestApprovingATermAndDecliningItsAliasInOneCallIsRefused(t *testing.T) { // The rule judges the SET, as ApplyDecisions says it does. Judged against the file the call STARTED // from, the term that owns the alias was not there yet, so both decisions reported `applied` and the // nickname went on firing in every chapter while the owner believed they had withdrawn it. bank := []store.GlossaryEntry{{Src: "方源", Status: "auto", Source: "mined", Aliases: []store.GlossaryAlias{{Alias: "方小子", AliasType: "mined"}}}} res := ApplyDecisions(ApplyInput{Bank: bank, Decisions: []Decision{ approve("方源", "Фан Юань"), {Action: ActionDecline, Src: "方小子"}, }}) if len(res.Rejected) != 1 || res.Rejected[0].Index != 1 { t.Fatalf("the decline of an alias the same call approves must be refused, by index: %+v", res.Rejected) } if !strings.Contains(res.Rejected[0].Reason, "方源") { t.Errorf("the refusal must name the term that owns the alias: %q", res.Rejected[0].Reason) } // The mirror case stays legitimate: declining a term AND its alias is not inert, because by the end // of the call nothing owns the alias any more. prior := seed.File{Terms: []seed.Term{{Src: "方源", Dst: "Фан Юань", Status: "approved", Aliases: []seed.Alias{{Alias: "方小子"}}}}} both := ApplyDecisions(ApplyInput{Delta: prior, Decisions: []Decision{ {Action: ActionDecline, Src: "方源"}, {Action: ActionDecline, Src: "方小子"}, }}) if len(both.Rejected) != 0 { t.Fatalf("declining a term and its alias together is not inert: %+v", both.Rejected) } } func TestAReversedChapterWindowIsRefused(t *testing.T) { // A window that ends before it begins is WRITTEN, LOADS, and fires in no chapter at all — a decision // that looks taken and does nothing, which is the outcome this door exists to make impossible. res := ApplyDecisions(ApplyInput{Decisions: []Decision{ {Action: ActionApprove, Src: "方源", Dst: "Фан Юань", SinceChapter: 90, UntilChapter: 3}, }}) if len(res.Rejected) != 1 || res.Rejected[0].Index != 0 { t.Fatalf("want the reversed window refused by index, got %+v", res.Rejected) } if !strings.Contains(res.Rejected[0].Reason, "[90,3]") { t.Errorf("the refusal must quote the window: %q", res.Rejected[0].Reason) } // An OPEN upper bound is not a reversal — it is how «from chapter 90 on» is written. open := ApplyDecisions(ApplyInput{Decisions: []Decision{ {Action: ActionApprove, Src: "方源", Dst: "Фан Юань", SinceChapter: 90}, }}) if len(open.Rejected) != 0 { t.Fatalf("an open window must stay legitimate: %+v", open.Rejected) } // The BOUNDARY: a single-chapter window (since == until) is a legitimate term of one chapter, not a // reversal — the acceptance planted `>` → `>=` here and the battery stayed green. one := ApplyDecisions(ApplyInput{Decisions: []Decision{ {Action: ActionApprove, Src: "方源", Dst: "Фан Юань", SinceChapter: 7, UntilChapter: 7}, }}) if len(one.Rejected) != 0 { t.Fatalf("a single-chapter window [7,7] must stay legitimate: %+v", one.Rejected) } } // TestANewFaultIsNotMaskedByAnotherTermsFaultOfTheSameClass: a livelock the set introduces on 花月 must // refuse even though 方源 already carries one of the same kind. ⚠ Verified by planting, not assumed: this // case is held by the shared-key guard, whose CLASS STRING embeds the key and the owner (`shared-key// // `), so it survives the «subjectOf returns only the class» mutation — the pin that actually // reddens under that planting is the window test below, where the class string carries no identity. // This test's own value is the cross-term half: masking must not creep in through any future rewrite of // the shared-key guard's subject either. func TestANewFaultIsNotMaskedByAnotherTermsFaultOfTheSameClass(t *testing.T) { prior := seed.File{Terms: []seed.Term{ {Src: "方源", Dst: "Фан Юань", Status: "approved", SinceCh: 1, UntilCh: 10}, {Src: "方源", Dst: "Фан Юань II", Status: "approved", SinceCh: 5, UntilCh: 20}, {Src: "花月", Dst: "Хуа Юэ", Status: "approved", SinceCh: 1, UntilCh: 10}, }} res := ApplyDecisions(ApplyInput{Delta: prior, Decisions: []Decision{ {Action: ActionApprove, Src: "花月", Dst: "Хуа Юэ II", SinceChapter: 5, UntilChapter: 20}, }}) if len(res.Rejected) == 0 { t.Fatalf("an overlap the set INTRODUCES on 花月 must refuse even though 方源 already carries one: accepted=%d preexisting=%+v", len(res.Accepted), res.Preexisting) } if res.DeltaBytes != nil { t.Error("nothing may be handed to the writer") } } // TestANewFaultIsNotMaskedByTheSameTermsFaultInAnotherWindow is the WINDOW half of the same subject // rule, against the surviving mutation «subjectOf without since/until»: window-less, a term's one // existing overlap fault excuses every further overlap anyone introduces on that term. func TestANewFaultIsNotMaskedByTheSameTermsFaultInAnotherWindow(t *testing.T) { prior := seed.File{Terms: []seed.Term{ {Src: "方源", Dst: "Фан Юань", Status: "approved", SinceCh: 1, UntilCh: 10}, {Src: "方源", Dst: "Фан Юань II", Status: "approved", SinceCh: 5, UntilCh: 20}, }} res := ApplyDecisions(ApplyInput{Delta: prior, Decisions: []Decision{ {Action: ActionApprove, Src: "方源", Dst: "Фан Юань III", SinceChapter: 15, UntilChapter: 30}, }}) if len(res.Rejected) == 0 { t.Fatalf("a THIRD overlapping window the set introduces must refuse even though the pair already overlaps: accepted=%d preexisting=%+v", len(res.Accepted), res.Preexisting) } if res.DeltaBytes != nil { t.Error("nothing may be handed to the writer") } } func TestADeclineWithdrawsAnApprovalWrittenInAnotherOrthography(t *testing.T) { // The REPLACEMENT half of a decline has to match the surface the way the matcher does. Every existing // case used surfaces on which NormalizeSourceKey is the identity, so dropping the normalization there // left the battery green: an approval written in simplified characters would have survived a decline // of the traditional form, and the declined rendering would have gone on firing. prior := seed.File{Terms: []seed.Term{{Src: "虫", Dst: "Червь", Status: "approved"}}} res := applyOK(t, ApplyInput{Delta: prior, Decisions: []Decision{{Action: ActionDecline, Src: "蟲"}}}) if len(res.Delta.Terms) != 0 { t.Fatalf("the decline must withdraw the approval whichever orthography it was written in: %+v", res.Delta.Terms) } if len(res.Accepted) != 1 || len(res.Accepted[0].Replaced) == 0 { t.Errorf("and it must SAY that it overwrote a decision: %+v", res.Accepted) } } func TestPromotionCarriesTheOwnersNote(t *testing.T) { // The note is the only durable channel for WHY a term is rendered this way, and it is what survives // the file being re-rendered — the canonical rewrite destroys an operator's header comments, so a // promotion that dropped the note would leave a book with no provenance at all. res := applyOK(t, ApplyInput{Decisions: []Decision{ {Action: ActionApprove, Src: "方源", Dst: "Фан Юань", Note: "клан 古月, не «источник»"}, }}) if len(res.Delta.Terms) != 1 || res.Delta.Terms[0].Note != "клан 古月, не «источник»" { t.Fatalf("the note must reach the delta row: %+v", res.Delta.Terms) } } func TestANoteIsNotErasedByAnOmission(t *testing.T) { // One rule for both files. The delta side kept a note an omitting decision did not restate; the reject // side ERASED it, so a caller re-sending the same decline from a screen that carries no notes threw // away the owner's own words while reporting success. prior := seed.RejectFile{Rejects: []seed.Reject{{Src: "水", Note: "родовое слово, не имя"}}} res := applyOK(t, ApplyInput{Rejects: prior, Decisions: []Decision{{Action: ActionDecline, Src: "水"}}}) if len(res.Rejects.Rejects) != 1 || res.Rejects.Rejects[0].Note != "родовое слово, не имя" { t.Fatalf("a decline that states no note must keep the one that is there: %+v", res.Rejects.Rejects) } if res.Accepted[0].State != StateAlreadyApplied || res.RejectsTouched { t.Errorf("and it changed nothing, so it must say so: state=%q touched=%v", res.Accepted[0].State, res.RejectsTouched) } // New words DO replace the old ones — «not decided» is not «not changeable». other := applyOK(t, ApplyInput{Rejects: prior, Decisions: []Decision{{Action: ActionDecline, Src: "水", Note: "иначе"}}}) if other.Rejects.Rejects[0].Note != "иначе" { t.Fatalf("a stated note replaces: %+v", other.Rejects.Rejects) } } func TestWithRubyAliasesDoesNotWriteIntoTheCallersRows(t *testing.T) { // AttachRubyAliasesToManual appends IN PLACE, and the entries it is given are the caller's own seed // rows — which the before/after comparison reads a second time. An append that fits the spare capacity // of a shared backing array writes past the caller's length and into whatever else is sliced from it, // so the "before" verdict would be computed over rows the "after" pass had already edited. // // Written as the memory property rather than as a symptom: the symptom is a spurious refusal that // appears only when the capacities line up, which is exactly the bug that survives a table of examples. backing := make([]store.GlossaryAlias, 2, 8) backing[0], backing[1] = store.GlossaryAlias{Alias: "老赵"}, store.GlossaryAlias{Alias: "小赵"} rows := []store.GlossaryEntry{ {Src: "赵大", Dst: "Чжао Да", Status: "approved", Aliases: backing[:1]}, {Src: "高橋", Dst: "Такахаси", Status: "approved", Aliases: backing[1:2]}, } snapshot := append([]store.GlossaryAlias(nil), backing[:cap(backing)]...) withRubyAliases(rows, []store.RubyReading{{Base: "高橋", Reading: "たかはし", Occurrences: 2, FirstChapter: 1}}) for i, a := range backing[:cap(backing)] { if a != snapshot[i] { t.Fatalf("the ruby graft wrote into the caller's memory at index %d: %+v became %+v\nwhole array: %+v", i, snapshot[i], a, backing[:cap(backing)]) } } } func TestAPreExistingFaultOfEveryKindStaysReportedAndUnblocking(t *testing.T) { // The Preexisting verdict runs FOUR checks and only one of them was ever executed by a test. Each of // the other three is a different way a book's files can already be broken, and each must behave the // same way: reported, never charged to a decision that did not cause it. seedRows := []store.GlossaryEntry{{Src: "赵大", Dst: "Чжао Да", Status: "approved"}} for _, tc := range []struct { name string in ApplyInput about string }{{ name: "MinedDeltaSeedCollisions: the delta already holds a term the seed owns", in: ApplyInput{Seed: seedRows, Delta: seed.File{Terms: []seed.Term{ {Src: "赵大", Dst: "Другое", Status: "approved"}}}}, about: "赵大", }, { name: "GenderVocabViolations: a delta row carries a gender nothing understands", in: ApplyInput{Delta: seed.File{Terms: []seed.Term{ {Src: "花月", Dst: "Хуа Юэ", Status: "approved", Gender: "Male"}}}}, about: "gender", }, { name: "UnknownVoiceCharacters: a voice profile names a character no term defines", in: ApplyInput{Seed: seedRows, Voices: []store.VoiceProfile{{Src: "田七", SinceCh: 1}}}, about: "田七", }} { t.Run(tc.name, func(t *testing.T) { in := tc.in in.Decisions = []Decision{approve("新词", "Новое слово")} res := ApplyDecisions(in) if len(res.Rejected) != 0 { t.Fatalf("a pre-existing fault must not be charged to an unrelated decision: %+v", res.Rejected) } if len(res.Preexisting) == 0 { t.Fatal("…and it must be REPORTED, or the verb answers «applied» about a book whose next run dies") } if !strings.Contains(strings.Join(res.Preexisting, "\n"), tc.about) { t.Errorf("the report must name the subject %q: %+v", tc.about, res.Preexisting) } }) } } func TestAHandAuthoredNoteTheEngineCannotWriteIsRepairedNotRefused(t *testing.T) { // The OTHER end of the format defect, and the one the door's own input rule cannot reach: the value // is already IN the file, written by hand in a form the YAML library reads but cannot re-emit. Without // the document normalizer this makes the book undecidable — every call that touches the delta refuses, // and the only repair left is the text editor the door exists to replace. // // Written from BYTES rather than from a Go value on purpose: `note: |2-` with a blank first line is // what an operator's editor produces and what the library parses happily; it is only the WRITE that // breaks. (The pair with TestALeadingNewlineNoteSurvivesTheWriteAndTheReadBack is deliberate: the two // normalization sites — the request and the document — cover for each other for a decision's own // text, so each needs the case the other cannot reach.) raw := []byte("terms:\n - src: 陈博\n dst: Чэньбо\n status: approved\n note: |2-\n\n подпись владельца\n") prior, err := seed.DecodeFile(raw) if err != nil { t.Fatalf("test premise broken: an operator's file must PARSE (only the write is broken): %v", err) } if prior.Terms[0].Note != "\nподпись владельца" { t.Fatalf("test premise broken: the parsed note is %q", prior.Terms[0].Note) } res := ApplyDecisions(ApplyInput{Delta: prior, Decisions: []Decision{approve("花月", "Хуа Юэ")}}) if len(res.Rejected) != 0 { t.Fatalf("a value the engine cannot re-emit must be REPAIRED by the write, not turn the book into a refusal: %+v", res.Rejected) } back, err := seed.DecodeFile(res.DeltaBytes) if err != nil { t.Fatalf("and what is written must read back: %v\n%s", err, res.DeltaBytes) } if len(back.Terms) != 2 || back.Terms[0].Note != "подпись владельца" { t.Fatalf("the owner's words must survive, minus the whitespace the format does not carry: %+v", back.Terms) } } func TestARepeatOfADecisionWhoseTextIsPaddedIsStillAlreadyApplied(t *testing.T) { // The REQUEST-side normalizer, isolated. Its job is not only to make a value writable — the document // normalizer covers that on the way out — but to make the door's own idempotency comparisons see the // same string the file holds. Untrimmed, a caller re-sending «\nтекст» against a file holding «текст» // reports `applied` for a call that changes nothing: the byte gate one layer up still refuses to // rewrite the file, so the FILE is safe and only the ANSWER is wrong — which is the quiet kind. first := applyOK(t, ApplyInput{Decisions: []Decision{ {Action: ActionDecline, Src: "水", Note: "родовое слово"}, {Action: ActionApprove, Src: "方源", Dst: "Фан Юань", Note: "клан"}, }}) again := applyOK(t, ApplyInput{Delta: first.Delta, Rejects: first.Rejects, Decisions: []Decision{ {Action: ActionDecline, Src: " 水 ", Note: "\nродовое слово\n"}, {Action: ActionApprove, Src: "方源", Dst: " Фан Юань ", Note: "\tклан"}, }}) for _, a := range again.Accepted { if a.State != StateAlreadyApplied { t.Errorf("decision %d (%s %q) reports %q — the same decision written with surrounding whitespace is the same decision", a.Index, a.Action, a.Src, a.State) } } if again.DeltaTouched || again.RejectsTouched { t.Errorf("nothing changed, so no document was touched: delta=%v rejects=%v", again.DeltaTouched, again.RejectsTouched) } } func TestAFaultTheDoorHasNoChannelForCannotDeadlockIt(t *testing.T) { // The §4.2 disease one level down, found by review after the first fix. Three of the four checks name // a term's RENDERING in their message — which is exactly what a decision changes — so keyed on the // message, a fault about a term looked BRAND NEW the moment the door changed that term's dst. The // refusal was then over a fault the call did not introduce and, for `gender`, could not possibly fix: // this door has no gender channel at all, so the only repair left was the text editor it exists to // abolish. // // Reproduced end to end before the fix: exit 14, with `rejected` and `preexisting_problems` carrying // THE SAME fault under two different renderings. prior := seed.File{Terms: []seed.Term{ {Src: "方源", Dst: "Фан Юань", Status: "approved", Gender: "badvalue"}, }} res := ApplyDecisions(ApplyInput{Delta: prior, Decisions: []Decision{approve("方源", "Фан Юань II")}}) if len(res.Rejected) != 0 { t.Fatalf("changing the dst of a term that already carries an unfixable fault must be accepted: %+v", res.Rejected) } if len(res.Preexisting) != 1 || !strings.Contains(res.Preexisting[0], "gender") { t.Fatalf("…and the fault must still be REPORTED: %+v", res.Preexisting) } if got, at := findTerm(res.Delta.Terms, termKey{Src: "方源"}); at < 0 || got.Dst != "Фан Юань II" { t.Fatalf("the decision must have landed: %+v", res.Delta.Terms) } // ⚠ Why the gender axis in particular can never deadlock the door: there is no channel through which a // decision could AUTHOR such a fault. `Decision` has no gender field, and while termFromBank now carries // a bank row's gender forward (backlog row 210 gave the field a producer), it carries a value the bank // already holds — and the bank's own vocabulary check refuses an unknown one on the way in. So every // gender fault this verdict can hold is pre-existing by construction, exactly as before. // // The guard itself is untouched, which is what the rest of this file's collision cases assert (see // TestACollisionTheSetWouldIntroduceIsRefused): a fault the SET really does introduce still refuses, // on the same before/after machinery. } func TestRepairingAnUnloadableDocumentIsNotRefusedOverWhatItUNMASKS(t *testing.T) { // The same family, one step further out, found while reproducing the case above. The three checks // below the bank loader used to be SKIPPED on a document that does not LOAD — so the moment a // decision repaired loadability, every fault they then saw looked new, and the owner was refused for // fixing the thing that was stopping everything. // // ⚠ The first fix for this excused those faults wholesale, and that was WORSE — see the second half // of this test. What actually settles it is making the BEFORE verdict complete: the loader now hands // back the entries it built even when it refuses, so the checks run on both sides and the ordinary // compare-by-subject rule answers both cases with no special case at all. prior := seed.File{Terms: []seed.Term{ {Src: "花月", Dst: "", Sense: "x", Status: "approved", Gender: "alsobad"}, // does not load AND has a masked fault }} res := ApplyDecisions(ApplyInput{Delta: prior, Decisions: []Decision{ {Action: ActionApprove, Src: "花月", Sense: "x", Dst: "Хуа Юэ"}, }}) if len(res.Rejected) != 0 { t.Fatalf("repairing the fault that stopped the load must not be refused over what the load was hiding: %+v", res.Rejected) } if len(res.Preexisting) == 0 || !strings.Contains(strings.Join(res.Preexisting, "\n"), "gender") { t.Fatalf("the unmasked fault must be REPORTED: %+v", res.Preexisting) } // …and the document that comes out LOADS, which is the whole guarantee the rule rests on. if _, err := ParseBankSeed("mined-delta", res.DeltaBytes); err != nil { t.Fatalf("the result must load: %v\n%s", err, res.DeltaBytes) } // A call that leaves the document STILL unloadable, with a NEW loader fault, is refused as before. worse := ApplyDecisions(ApplyInput{Delta: prior, Decisions: []Decision{ {Action: ActionApprove, Src: "甲", Dst: "Цзя", Kind: "name"}, }}) if len(worse.Rejected) != 0 { t.Fatalf("an unrelated decision on an unloadable document is still just a repair: %+v", worse.Rejected) } // …AND a set that repairs loadability while INTRODUCING a livelock is still refused. This is the // half the first fix got wrong: excusing everything the repair revealed meant a collision the call // really did create was filed as pre-existing and the document was WRITTEN — the door diagnosing a // deterministic-matcher livelock and shipping it, with exit 0 and a report blaming somebody else. broken := seed.File{Terms: []seed.Term{ {Src: "甲", Dst: "", Status: "approved"}, {Src: "赵大", Dst: "Чжао Да", Status: "approved", Aliases: []seed.Alias{{Alias: "老赵"}}}, }} both := ApplyDecisions(ApplyInput{Delta: broken, Decisions: []Decision{ approve("甲", "Цзя"), // repairs the load approve("老赵", "Другое"), // …and collides with 赵大's alias, which this call did NOT touch }}) if len(both.Rejected) == 0 { t.Fatalf("a livelock this call introduces must be refused even while it repairs the load: accepted=%d preexisting=%+v", len(both.Accepted), both.Preexisting) } if both.DeltaBytes != nil { t.Error("…and nothing may be handed to the writer") } } func TestADeclineIsRefusedWhenTheSurfaceGOESONFiringAsAnotherTermsAlias(t *testing.T) { // The rule judges what the surface DOES after the call, not what row it came from. An earlier version // let a decline through whenever the surface had been a delta term of its own — on the theory that // such a decline withdraws a term rather than naming somebody's alias. Measured, that escape was a // no-op in every input except the one where it did harm: the row is dropped, ANOTHER term still // carries the surface as a firing alias, and the owner is told `applied` while the rendering they // declined goes on appearing in every chapter. prior := seed.File{Terms: []seed.Term{ {Src: "老赵", Dst: "Старина Чжао", Status: "approved"}, {Src: "赵大", Dst: "Чжао Да", Status: "approved", Aliases: []seed.Alias{{Alias: "老赵"}}}, }} res := ApplyDecisions(ApplyInput{Delta: prior, Decisions: []Decision{{Action: ActionDecline, Src: "老赵"}}}) if len(res.Rejected) != 1 { t.Fatalf("a decline that leaves the surface firing must be refused: %+v (accepted %+v)", res.Rejected, res.Accepted) } if !strings.Contains(res.Rejected[0].Reason, "赵大") { t.Errorf("the refusal must name the term that keeps firing it: %q", res.Rejected[0].Reason) } if len(res.Delta.Terms) != 2 { t.Errorf("all-or-nothing: the row must not have been dropped: %+v", res.Delta.Terms) } } func TestDecliningTheAliasOfAnUNSIGNEDRowIsAccepted(t *testing.T) { // The inert-decline rule rests on ONE fact: an approved term's surfaces are already excluded from // proposals, so declining them changes nothing. That fact is false for an UNSIGNED row — // unsignedEngineSurfaces drops mined rows whose status is not `approved`, so their surfaces keep // being proposed, and declining one is exactly what the signing screen asks for. // // `status: auto` in the delta is not a corner case: it is the state row 199's first format mine is // entirely about (a signature-map row copied mechanically carries it). Ignoring the status refused // the decline AND told the owner the row was "the approved term", which it was not. for _, status := range []string{"auto", "draft"} { prior := seed.File{Terms: []seed.Term{ {Src: "赵大", Dst: "", Status: status, Aliases: []seed.Alias{{Alias: "老赵"}}}, }} res := ApplyDecisions(ApplyInput{Delta: prior, Decisions: []Decision{{Action: ActionDecline, Src: "老赵"}}}) if len(res.Rejected) != 0 { t.Errorf("status %q: declining the alias of an unsigned row is not inert: %+v", status, res.Rejected) } } // An APPROVED owner still refuses it, which is the rule this narrows and not one it removes. signed := seed.File{Terms: []seed.Term{ {Src: "赵大", Dst: "Чжао Да", Status: "approved", Aliases: []seed.Alias{{Alias: "老赵"}}}, }} if res := ApplyDecisions(ApplyInput{Delta: signed, Decisions: []Decision{{Action: ActionDecline, Src: "老赵"}}}); len(res.Rejected) != 1 { t.Fatalf("an alias of an APPROVED term is still inert to decline: %+v", res.Rejected) } } func TestTwoRecordsWithNoIdentityDoNotBlockEachOthersRepair(t *testing.T) { // The three "record N has no identity at all" messages are the only ones with nothing to key on but a // POSITION, and a position moves when an earlier record is removed. They carry an explicit // positionless subject for that reason, and nothing tested it. // ⚠ The identity-less rows come AFTER the one the decline removes, and that ordering is the whole // test: the first version put them first, so their positions never moved and the pin passed with the // positionless subject removed. A pin that cannot fail is not a pin — caught by planting the mutation. prior := seed.File{Terms: []seed.Term{ {Src: "赵大", Dst: "Чжао Да", Status: "approved"}, {Src: "", Dst: "x", Status: "approved"}, {Src: "", Dst: "y", Status: "approved"}, }} // The decline removes the FIRST row, so every later record's position shifts. res := ApplyDecisions(ApplyInput{Delta: prior, Decisions: []Decision{{Action: ActionDecline, Src: "赵大"}}}) if len(res.Rejected) != 0 { t.Fatalf("a shifted POSITION is not a new fault: %+v", res.Rejected) } if len(res.Preexisting) == 0 { t.Error("…and the identity-less records must still be reported") } } func TestDecliningASeedAliasIsAcceptedWhenTheDeltaHoldsThatRow(t *testing.T) { // The refusal's own sentence — «a reject only filters PROPOSALS, so declining it would change // nothing» — is TRUE only while the delta has no row of its own for that surface. When it does, the // decline DROPS that row, and dropping it is usually the repair for the collision the same report is // listing under preexisting_problems. Refusing there told the owner to «remove it from glossary_seed // instead», which would not have removed the delta row either: the instruction did not fix the thing // the report complained about. seedRows := []store.GlossaryEntry{{Src: "赵大", Dst: "Чжао Да", Status: "approved", Aliases: []store.GlossaryAlias{{Alias: "老赵"}}}} prior := seed.File{Terms: []seed.Term{{Src: "老赵", Dst: "Старина Чжао", Status: "approved"}}} blocked := ApplyDecisions(ApplyInput{Seed: seedRows, Delta: prior}) if len(blocked.Preexisting) == 0 || !strings.Contains(blocked.Preexisting[0], "老赵") { t.Fatalf("test premise broken: the two rows must already collide: %+v", blocked.Preexisting) } res := ApplyDecisions(ApplyInput{Seed: seedRows, Delta: prior, Decisions: []Decision{{Action: ActionDecline, Src: "老赵"}}}) if len(res.Rejected) != 0 { t.Fatalf("the decline drops the delta row and repairs the collision — it is not inert: %+v", res.Rejected) } if len(res.Delta.Terms) != 0 { t.Fatalf("the row must be gone: %+v", res.Delta.Terms) } // With NO delta row the refusal is true again, and it still stands. inert := ApplyDecisions(ApplyInput{Seed: seedRows, Decisions: []Decision{{Action: ActionDecline, Src: "老赵"}}}) if len(inert.Rejected) != 1 { t.Fatalf("declining a seed alias the delta knows nothing about IS inert and must be refused: %+v", inert.Rejected) } } // TestApprovingATermKeepsTheGenderTheEngineProduced is the hop the gender axis was measured to lose, and it // is the one the owner is invited to take. Backlog row 210 gave `gender` its first automatic producer (the // §2 classifier); this door builds the delta row that PROMOTES an auto-mined term, and it used to copy only // the identity and the kind — so the approval wrote a row with no gender, and loadAutoBank then dropped the // auto row that had one as a key clash. The datum survived every hop except the one act the stop exists for. func TestApprovingATermKeepsTheGenderTheEngineProduced(t *testing.T) { bank := []store.GlossaryEntry{{ BookID: "b", Src: "方源", Dst: "Фан Юань", Type: "name", Gender: "male", Status: "auto", Source: "mined", Aliases: []store.GlossaryAlias{{Alias: "方", AliasType: "mined"}}, }} res := applyOK(t, ApplyInput{Bank: bank, Decisions: []Decision{approve("方源", "Фан Юань")}}) got, at := findTerm(res.Delta.Terms, termKey{Src: "方源"}) if at < 0 { t.Fatalf("the approval must have produced a delta row: %+v", res.Delta.Terms) } if got.Gender != "male" { t.Errorf("approving a term must not erase the gender the engine derived for it, got %q", got.Gender) } // The neighbours it travels with, asserted so a future edit cannot trade one for another. if got.Type != "name" || len(got.Aliases) != 1 { t.Errorf("the identity must still travel with it: %+v", got) } if got.Status != "approved" || got.Dst != "Фан Юань" { t.Errorf("the promotion still sets the status and takes the decision's rendering: %+v", got) } }