185 lines
7.2 KiB
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)
|
|
}
|
|
}
|