1064 lines
56 KiB
Go
1064 lines
56 KiB
Go
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)
|
||
}
|
||
}
|