textmachine/backend/internal/pipeline/bankdecisions_converge_test.go

185 lines
7.2 KiB
Go

package pipeline
import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
"textmachine/backend/internal/config"
"textmachine/backend/internal/membank"
)
// bankdecisions_converge_test.go: the same convergence contract as membank/decisions_converge_test.go,
// but through the DOOR — real files, the real lock, the real refusal classes. The semantics are pinned
// one layer down; what is pinned here is that a caller who follows the published instruction («re-send
// the SAME document») is not answered with a refusal class and left with nowhere to go.
// declineProject writes a book that carries a SIGNED seed term plus a mined-delta holding a row of its
// own for the same surface — the lawful livelock repair, and the one state the door could not converge on.
func declineProject(t *testing.T) string {
t.Helper()
cfg := decideProject(t)
dir := filepath.Dir(cfg)
writeFile(t, filepath.Join(dir, "glossary-seed.yaml"), `
terms:
- src: 方源
dst: Фан Юань
type: name
status: approved
`)
// The book must DECLARE the seed; the delta/rejects paths stay conventional.
raw, err := os.ReadFile(cfg)
if err != nil {
t.Fatal(err)
}
writeFile(t, cfg, string(raw)+"\nglossary_seed: glossary-seed.yaml\n")
writeFile(t, filepath.Join(dir, "decide-book"+config.MinedDeltaSuffix), `
terms:
- src: 方源
dst: Фан-Юань
status: auto
`)
return cfg
}
func declineDoc(t *testing.T, dir string) string {
t.Helper()
return decisionsDoc(t, dir, membank.Decision{Action: membank.ActionDecline, Src: "方源"})
}
// TestReSendingAnAppliedDeclineIsNotARefusal is the door-level form of the ordered invariant.
func TestReSendingAnAppliedDeclineIsNotARefusal(t *testing.T) {
cfg := declineProject(t)
dir := filepath.Dir(cfg)
doc := declineDoc(t, dir)
first, err := ApplyBankDecisions(t.Context(), cfg, doc, false)
if err != nil {
t.Fatalf("the first send is lawful: %v", err)
}
if !first.Changed || len(first.Accepted) != 1 || first.Accepted[0].State != membank.StateApplied {
t.Fatalf("first send = %+v", first)
}
second, err := ApplyBankDecisions(t.Context(), cfg, doc, false)
if err != nil {
t.Fatalf("re-sending the SAME document must converge, got %v (class %v)", err, refusalClassOf(err))
}
if second.Changed {
t.Fatalf("a converged re-send changes nothing: %+v", second)
}
if len(second.Accepted) != 1 || second.Accepted[0].State != membank.StateAlreadyApplied {
t.Fatalf("re-send = %+v", second.Accepted)
}
// The published instruction includes a SAFE PREVIEW; it must answer the same way, not a refusal.
dry, err := ApplyBankDecisions(t.Context(), cfg, doc, true)
if err != nil {
t.Fatalf("--dry-run of an applied document must not refuse: %v", err)
}
if dry.Changed {
t.Fatalf("dry-run of an applied document projects no change: %+v", dry)
}
}
// TestAnInterruptedDeclineIsFinishedByTheSameDocument is the case that locked a user out: the process
// died between the two renames. The rejects-first order leaves the DELTA row intact, so the identical
// document finishes the job instead of being bounced.
func TestAnInterruptedDeclineIsFinishedByTheSameDocument(t *testing.T) {
cfg := declineProject(t)
dir := filepath.Dir(cfg)
doc := declineDoc(t, dir)
// Reproduce the half-state exactly as the interrupted write leaves it: the reject list landed, the
// delta did not. (writeDecisionFiles commits rejects first — see TestTheRenameOrderIsWhatMakesADeclineRecover.)
writeFile(t, filepath.Join(dir, "decide-book"+configMinedRejectsSuffix), "rejects:\n - src: 方源\n")
rep, err := ApplyBankDecisions(t.Context(), cfg, doc, false)
if err != nil {
t.Fatalf("the half-state must be finishable by the same document, got %v (class %v)", err, refusalClassOf(err))
}
if !rep.Changed || !rep.WrittenDelta {
t.Fatalf("the re-send must write the half that did not land: %+v", rep)
}
raw := readAll(t, filepath.Join(dir, "decide-book"+configMinedDeltaSuffix))
if strings.Contains(raw, "方源") {
t.Fatalf("the declined surface must be gone from the delta after the converged re-send:\n%s", raw)
}
// And now it is inert.
again, err := ApplyBankDecisions(t.Context(), cfg, doc, false)
if err != nil || again.Changed {
t.Fatalf("after convergence the document is a no-op: %+v / %v", again, err)
}
}
// TestAnInertDeclineIsStillRefusedAtTheDoor is the direction guard at the door: the narrowing must not
// have opened the refusal the livelock bought.
func TestAnInertDeclineIsStillRefusedAtTheDoor(t *testing.T) {
cfg := decideProject(t) // no mined-delta at all
dir := filepath.Dir(cfg)
writeFile(t, filepath.Join(dir, "glossary-seed.yaml"), `
terms:
- src: 方源
dst: Фан Юань
type: name
status: approved
`)
raw, err := os.ReadFile(cfg)
if err != nil {
t.Fatal(err)
}
writeFile(t, cfg, string(raw)+"\nglossary_seed: glossary-seed.yaml\n")
rep, err := ApplyBankDecisions(t.Context(), cfg, declineDoc(t, dir), false)
if refusalClassOf(err) != RefusalDecisionsRejected {
t.Fatalf("a genuinely inert decline must still be refused, got %v", err)
}
if len(rep.Rejected) != 1 || !strings.Contains(rep.Rejected[0].Reason, "filters PROPOSALS") {
t.Fatalf("the refusal must keep its own words: %+v", rep.Rejected)
}
// Nothing was written by the refusal.
if _, serr := os.Stat(filepath.Join(dir, "decide-book"+configMinedRejectsSuffix)); !errors.Is(serr, os.ErrNotExist) {
t.Fatalf("a refused call must write nothing: %v", serr)
}
}
// TestADeclinedSurfaceNeverEntersTheBankFromTheDelta is the other half of the rejects-first order, and
// it exists because an adversarial pass showed the order alone was not safe. Rejects-first is what lets
// an interrupted decline converge — and it does so by leaving the DELTA ROW on disk. seedGlossary loads
// the mined-delta into the live bank and never consulted the reject list, so between the crash and the
// operator's re-send a PAID run would have injected into the editor's glossary a term the owner had
// explicitly declined. Convergence must not be bought with a second silent harm.
func TestADeclinedSurfaceNeverEntersTheBankFromTheDelta(t *testing.T) {
bookPath := setupProjectOpts(t, "http://127.0.0.1:1", projectOpts{})
r := newRunner(t, bookPath)
defer r.Close()
// The exact half-state the rejects-first order produces: the reject landed, the delta row did not go.
writeFile(t, r.Book.MinedDelta, "terms:\n - src: 方源\n dst: Фан-Юань\n status: approved\n")
writeFile(t, r.Book.MinedRejects, "rejects:\n - src: 方源\n")
got, err := r.loadMinedDelta()
if err != nil {
t.Fatalf("load: %v", err)
}
for _, e := range got {
if e.Src == "方源" {
t.Fatalf("a DECLINED surface reached the bank through the mined-delta: %+v", e)
}
}
if len(got) != 0 {
t.Fatalf("the delta held only the declined term; nothing may survive it: %+v", got)
}
// The filter is scoped to what the owner actually declined — an undeclined row still loads.
writeFile(t, r.Book.MinedDelta,
"terms:\n - src: 方源\n dst: Фан-Юань\n status: approved\n - src: 李青\n dst: Ли Цин\n status: approved\n")
got2, err := r.loadMinedDelta()
if err != nil {
t.Fatalf("load: %v", err)
}
if len(got2) != 1 || got2[0].Src != "李青" {
t.Fatalf("only the declined surface may be dropped, got %+v", got2)
}
}