textmachine/backend/internal/membank/decisions_test.go

1064 lines
56 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package membank
import (
"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 «<byte length>:<value>», 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/<k>/
// <src>`), 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 once the subject is identity-only:
// there is no channel through which a decision could CREATE such a fault — `Decision` has no gender
// field and termFromBank carries only the identity and the kind — so every gender fault this verdict
// can ever hold is pre-existing by construction.
//
// 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)
}
}