218 lines
12 KiB
Go
218 lines
12 KiB
Go
package membank
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
|
|
"textmachine/backend/internal/seed"
|
|
"textmachine/backend/internal/store"
|
|
)
|
|
|
|
// decisions_converge_test.go: the door's own idempotency contract, on the ONE trajectory that broke it.
|
|
//
|
|
// The contract, in this package's words (AcceptedDecision.State): «a retry of a decision that is
|
|
// already in the files changes nothing, writes nothing and still exits 0, because a worker that resumes
|
|
// and retries must not be able to split the state.» The published form of the same promise is
|
|
// «re-send the SAME document — the retry converges».
|
|
//
|
|
// It was false for exactly one document: a DECLINE of a surface that belongs to a signed seed term and
|
|
// that the mined-delta holds a row for — the lawful livelock repair. The first call is accepted BECAUSE
|
|
// the delta holds the row; applying it DROPS the row; and refuseSeedConflicts, which fires when the
|
|
// delta has no row, then refuses the identical document forever. The refusal was reading the state its
|
|
// own acceptance had produced.
|
|
|
|
// seedSurfaceState builds the exact state that trajectory needs: a SIGNED seed term, and a mined-delta
|
|
// holding a row of its own for the same surface.
|
|
func seedSurfaceState() ([]store.GlossaryEntry, seed.File) {
|
|
seedRows := []store.GlossaryEntry{bankRow("方源", "Фан Юань", "approved", "seed")}
|
|
delta := seed.File{Terms: []seed.Term{{Src: "方源", Dst: "Фан-Юань", Status: "auto"}}}
|
|
return seedRows, delta
|
|
}
|
|
|
|
func declineOf(src string) Decision { return Decision{Action: ActionDecline, Src: src} }
|
|
|
|
// TestTheSameDeclineDocumentConverges is the ordered invariant: send it, apply it, send it again — and
|
|
// the second answer is «already applied», not a refusal.
|
|
func TestTheSameDeclineDocumentConverges(t *testing.T) {
|
|
seedRows, delta := seedSurfaceState()
|
|
doc := []Decision{declineOf("方源")}
|
|
|
|
first := ApplyDecisions(ApplyInput{Seed: seedRows, Delta: delta, Decisions: doc})
|
|
if len(first.Rejected) > 0 {
|
|
t.Fatalf("the FIRST send is lawful (the delta holds a row of its own): %+v", first.Rejected)
|
|
}
|
|
if len(first.Accepted) != 1 || first.Accepted[0].State != StateApplied {
|
|
t.Fatalf("first send = %+v", first.Accepted)
|
|
}
|
|
if !first.DeltaTouched || !first.RejectsTouched {
|
|
t.Fatalf("a decline over a held surface must touch BOTH documents: %+v", first)
|
|
}
|
|
|
|
// The re-send sees the state the first one produced — which is the whole point.
|
|
second := ApplyDecisions(ApplyInput{Seed: seedRows, Delta: first.Delta, Rejects: first.Rejects, Decisions: doc})
|
|
if len(second.Rejected) > 0 {
|
|
t.Fatalf("the SAME document was refused on re-send — the retry does not converge: %+v", second.Rejected)
|
|
}
|
|
if len(second.Accepted) != 1 || second.Accepted[0].State != StateAlreadyApplied {
|
|
t.Fatalf("the re-send must answer already_applied, got %+v", second.Accepted)
|
|
}
|
|
if second.DeltaTouched || second.RejectsTouched {
|
|
t.Fatalf("a converged re-send must change nothing: delta=%v rejects=%v", second.DeltaTouched, second.RejectsTouched)
|
|
}
|
|
// And a THIRD send is the same answer: convergence is a fixed point, not a one-off tolerance.
|
|
third := ApplyDecisions(ApplyInput{Seed: seedRows, Delta: second.Delta, Rejects: second.Rejects, Decisions: doc})
|
|
if len(third.Rejected) > 0 || third.Accepted[0].State != StateAlreadyApplied {
|
|
t.Fatalf("third send = %+v / %+v", third.Accepted, third.Rejected)
|
|
}
|
|
}
|
|
|
|
// TestAnAcceptedDeclineDoesNotPoisonTheRestOfItsDocument: the layer is all-or-nothing, so the refusal
|
|
// did not merely strand the decline — it discarded every lawful decision sent beside it.
|
|
func TestAnAcceptedDeclineDoesNotPoisonTheRestOfItsDocument(t *testing.T) {
|
|
seedRows, delta := seedSurfaceState()
|
|
doc := []Decision{declineOf("方源"), approve("李青", "Ли Цин")}
|
|
|
|
first := ApplyDecisions(ApplyInput{Seed: seedRows, Delta: delta, Decisions: doc})
|
|
if len(first.Rejected) > 0 {
|
|
t.Fatalf("first send: %+v", first.Rejected)
|
|
}
|
|
second := ApplyDecisions(ApplyInput{Seed: seedRows, Delta: first.Delta, Rejects: first.Rejects, Decisions: doc})
|
|
if len(second.Rejected) > 0 {
|
|
t.Fatalf("the re-send refused, taking the lawful approve down with it: %+v", second.Rejected)
|
|
}
|
|
if len(second.Accepted) != 2 {
|
|
t.Fatalf("both decisions must be answered, got %+v", second.Accepted)
|
|
}
|
|
for _, a := range second.Accepted {
|
|
if a.State != StateAlreadyApplied {
|
|
t.Fatalf("every decision of a fully-landed document is already_applied, got %+v", a)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestTheHalfStateOfAnInterruptedDeclineConverges is the case where a refusal was not an inconvenience
|
|
// but a locked door: the process died between the two renames. Under the rejects-first order the
|
|
// surviving half is the REJECTS and the delta still holds the row, so the identical document finishes
|
|
// the job. (The engine's own writeDecisionFiles pins that order; this pins what the order buys.)
|
|
func TestTheHalfStateOfAnInterruptedDeclineConverges(t *testing.T) {
|
|
seedRows, delta := seedSurfaceState()
|
|
doc := []Decision{declineOf("方源")}
|
|
full := ApplyDecisions(ApplyInput{Seed: seedRows, Delta: delta, Decisions: doc})
|
|
if len(full.Rejected) > 0 {
|
|
t.Fatalf("setup: %+v", full.Rejected)
|
|
}
|
|
|
|
// Half-state: the REJECTS landed, the delta did not (it still holds the row).
|
|
half := ApplyDecisions(ApplyInput{Seed: seedRows, Delta: delta, Rejects: full.Rejects, Decisions: doc})
|
|
if len(half.Rejected) > 0 {
|
|
t.Fatalf("an interrupted decline must be finishable by re-sending the same document: %+v", half.Rejected)
|
|
}
|
|
if !half.DeltaTouched {
|
|
t.Fatal("the re-send must finish the unwritten half (drop the delta row)")
|
|
}
|
|
if deltaHoldsSurface(half.Delta, "方源") {
|
|
t.Fatal("after the converged re-send the delta must no longer hold the declined surface")
|
|
}
|
|
// The finished state is then the fixed point.
|
|
again := ApplyDecisions(ApplyInput{Seed: seedRows, Delta: half.Delta, Rejects: half.Rejects, Decisions: doc})
|
|
if len(again.Rejected) > 0 || again.DeltaTouched || again.RejectsTouched {
|
|
t.Fatalf("after convergence the document must be inert: %+v / %+v", again.Rejected, again.Accepted)
|
|
}
|
|
}
|
|
|
|
// TestAGenuinelyInertDeclineIsStillRefused is the DIRECTION GUARD, and it is half the order: the
|
|
// refusal that was narrowed is load-bearing and was bought with a livelock. A decline of a seed surface
|
|
// with NO delta row of its own AND no reject on the record still refuses — with the same sentence.
|
|
func TestAGenuinelyInertDeclineIsStillRefused(t *testing.T) {
|
|
seedRows := []store.GlossaryEntry{bankRow("方源", "Фан Юань", "approved", "seed")}
|
|
res := ApplyDecisions(ApplyInput{Seed: seedRows, Decisions: []Decision{declineOf("方源")}})
|
|
if len(res.Rejected) != 1 {
|
|
t.Fatalf("an inert decline of a signed seed surface must still be refused, got %+v", res.Rejected)
|
|
}
|
|
if r := res.Rejected[0].Reason; !strings.Contains(r, "filters PROPOSALS") ||
|
|
!strings.Contains(r, "Remove it from glossary_seed instead") {
|
|
t.Fatalf("the refusal must keep its own words, got %q", r)
|
|
}
|
|
// The narrowing is EXACTLY «already on the record», nothing wider: an unrelated reject does not
|
|
// license it.
|
|
other := seed.RejectFile{Rejects: []seed.Reject{{Src: "李青"}}}
|
|
res2 := ApplyDecisions(ApplyInput{Seed: seedRows, Rejects: other, Decisions: []Decision{declineOf("方源")}})
|
|
if len(res2.Rejected) != 1 {
|
|
t.Fatalf("a reject for a DIFFERENT surface must not lift the refusal, got %+v", res2.Rejected)
|
|
}
|
|
}
|
|
|
|
// TestARepeatedInertDeclineConverges closes the pair: once the refusal has been lifted for a surface
|
|
// that IS on the record, the record is what decides — including for a surface the owner declined when
|
|
// it was still a live delta row and which the seed later grew to cover.
|
|
func TestARepeatedInertDeclineConverges(t *testing.T) {
|
|
seedRows := []store.GlossaryEntry{bankRow("方源", "Фан Юань", "approved", "seed")}
|
|
onRecord := seed.RejectFile{Rejects: []seed.Reject{{Src: "方源", Note: "решено владельцем"}}}
|
|
res := ApplyDecisions(ApplyInput{Seed: seedRows, Rejects: onRecord, Decisions: []Decision{declineOf("方源")}})
|
|
if len(res.Rejected) > 0 {
|
|
t.Fatalf("a decline already on the record must converge, not refuse: %+v", res.Rejected)
|
|
}
|
|
if len(res.Accepted) != 1 || res.Accepted[0].State != StateAlreadyApplied {
|
|
t.Fatalf("got %+v", res.Accepted)
|
|
}
|
|
if res.RejectsTouched {
|
|
t.Fatal("nothing to write: the reject is already there")
|
|
}
|
|
// The owner's own words survive the repeat (the note rule of addReject).
|
|
if res.Rejects.Rejects[0].Note != "решено владельцем" {
|
|
t.Fatalf("the repeat erased the owner's note: %+v", res.Rejects.Rejects[0])
|
|
}
|
|
}
|
|
|
|
// TestTheStandingLedgerConvergesThroughBothDoors is the trajectory an adversarial pass walked, and every
|
|
// step of it is an ACCEPTED call — no crash, no hand edit. A caller who keeps one document as the
|
|
// standing ledger of their decisions (which is the workflow the published retry describes) re-sends it
|
|
// and, before the fix, was refused by the OTHER inert-decline door — losing the lawful approve beside it,
|
|
// because the layer is all-or-nothing.
|
|
func TestTheStandingLedgerConvergesThroughBothDoors(t *testing.T) {
|
|
// 方小子 starts as a delta term of its OWN, so declining it is lawful and changes something.
|
|
delta := seed.File{Terms: []seed.Term{{Src: "方小子", Dst: "Малыш Фан", Status: "approved"}}}
|
|
// The bank offers 方源 with 方小子 among its aliases, so approving 方源 later makes 方小子 an alias.
|
|
bank := []store.GlossaryEntry{{Src: "方源", Status: "auto", Source: "mined",
|
|
Aliases: []store.GlossaryAlias{{Alias: "方小子", AliasType: "mined"}}}}
|
|
|
|
step1 := ApplyDecisions(ApplyInput{Bank: bank, Delta: delta, Decisions: []Decision{declineOf("方小子")}})
|
|
if len(step1.Rejected) > 0 {
|
|
t.Fatalf("step 1 (decline a term of its own) is lawful: %+v", step1.Rejected)
|
|
}
|
|
step2 := ApplyDecisions(ApplyInput{Bank: bank, Delta: step1.Delta, Rejects: step1.Rejects,
|
|
Decisions: []Decision{{Action: ActionApprove, ID: TermID("方源", "", 0, 0), Dst: "Фан Юань"}}})
|
|
if len(step2.Rejected) > 0 {
|
|
t.Fatalf("step 2 (approve the owning term) is lawful: %+v", step2.Rejected)
|
|
}
|
|
|
|
// The standing ledger: BOTH decisions, re-sent over the state they themselves produced.
|
|
ledger := []Decision{declineOf("方小子"), {Action: ActionApprove, ID: TermID("方源", "", 0, 0), Dst: "Фан Юань"}}
|
|
res := ApplyDecisions(ApplyInput{Bank: bank, Delta: step2.Delta, Rejects: step2.Rejects, Decisions: ledger})
|
|
if len(res.Rejected) > 0 {
|
|
t.Fatalf("the standing ledger was refused on re-send — and an all-or-nothing door discards the lawful approve with it: %+v", res.Rejected)
|
|
}
|
|
for _, a := range res.Accepted {
|
|
if a.State != StateAlreadyApplied {
|
|
t.Fatalf("every decision of a fully-landed ledger is already_applied, got %+v", a)
|
|
}
|
|
}
|
|
if res.DeltaTouched || res.RejectsTouched {
|
|
t.Fatal("a converged re-send must change nothing")
|
|
}
|
|
}
|
|
|
|
// TestDecliningALiveAliasIsStillRefused is the direction guard for the SECOND door: the alias refusal is
|
|
// load-bearing (declining an alias does not withdraw the term, and the owner has to be told to decline
|
|
// the term itself), and the narrowing must not have opened it for a surface nobody has decided about.
|
|
func TestDecliningALiveAliasIsStillRefused(t *testing.T) {
|
|
delta := seed.File{Terms: []seed.Term{{Src: "方源", Dst: "Фан Юань", Status: "approved",
|
|
Aliases: []seed.Alias{{Alias: "方小子"}}}}}
|
|
res := ApplyDecisions(ApplyInput{Delta: delta, Decisions: []Decision{declineOf("方小子")}})
|
|
if len(res.Rejected) != 1 {
|
|
t.Fatalf("declining a live alias must still be refused, got %+v", res.Rejected)
|
|
}
|
|
if r := res.Rejected[0].Reason; !strings.Contains(r, "is an ALIAS of the approved term") {
|
|
t.Fatalf("the refusal must keep its own words, got %q", r)
|
|
}
|
|
}
|