772 lines
35 KiB
Go
772 lines
35 KiB
Go
package pipeline
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"syscall"
|
||
"testing"
|
||
"time"
|
||
|
||
"textmachine/backend/internal/config"
|
||
"textmachine/backend/internal/membank"
|
||
"textmachine/backend/internal/store"
|
||
)
|
||
|
||
// The two conventional suffixes, named once so the pins below read as the convention rather than as
|
||
// string literals that could drift from it.
|
||
const (
|
||
configMinedDeltaSuffix = config.MinedDeltaSuffix
|
||
configMinedRejectsSuffix = config.MinedRejectsSuffix
|
||
)
|
||
|
||
// bankdecisions_test.go: the WIRING contract of `bank-apply` — the lock, the writes and what is NOT
|
||
// written. The decision semantics are pinned one layer down (membank/decisions_test.go); everything here
|
||
// is about the file system and the refusal classes, which is where a partial write or a lost lock lives.
|
||
|
||
// decideProject writes a minimal book that declares NEITHER decision key, which is the case the
|
||
// convention default exists for and the one the platform produces.
|
||
func decideProject(t *testing.T) string {
|
||
t.Helper()
|
||
dir := t.TempDir()
|
||
writeFile(t, filepath.Join(dir, "source.txt"), "текст")
|
||
writeFile(t, filepath.Join(dir, "book.yaml"), `
|
||
book_id: decide-book
|
||
title: Т
|
||
source_lang: zh
|
||
target_lang: ru
|
||
pipeline: pipeline.yaml
|
||
models: models.yaml
|
||
source_file: source.txt
|
||
ceilings: { book_usd: 1.0 }
|
||
`)
|
||
return filepath.Join(dir, "book.yaml")
|
||
}
|
||
|
||
func decisionsDoc(t *testing.T, dir string, ds ...membank.Decision) string {
|
||
t.Helper()
|
||
body, err := json.Marshal(membank.DecisionsDoc{Version: membank.DecisionsVersion, BookID: "decide-book", Decisions: ds})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
p := filepath.Join(dir, fmt.Sprintf("decisions-%d.json", len(ds)))
|
||
writeFile(t, p, string(body))
|
||
return p
|
||
}
|
||
|
||
func approveDecision(src, dst string) membank.Decision {
|
||
return membank.Decision{Action: membank.ActionApprove, Src: src, Dst: dst}
|
||
}
|
||
|
||
// inode identifies a file across a rewrite. writeFileAtomic replaces through a temp file + rename, so a
|
||
// rewrite ALWAYS changes the inode — which makes this the exact test for "wrote nothing", stronger than
|
||
// comparing bytes (identical bytes written again would pass a byte comparison and fail this).
|
||
func inode(t *testing.T, path string) uint64 {
|
||
t.Helper()
|
||
st, err := os.Stat(path)
|
||
if err != nil {
|
||
t.Fatalf("stat %s: %v", path, err)
|
||
}
|
||
sys, ok := st.Sys().(*syscall.Stat_t)
|
||
if !ok {
|
||
t.Skip("no inode on this platform")
|
||
}
|
||
return sys.Ino
|
||
}
|
||
|
||
func TestBankApplyWritesTheConventionalFiles(t *testing.T) {
|
||
cfg := decideProject(t)
|
||
dir := filepath.Dir(cfg)
|
||
rep, err := ApplyBankDecisions(t.Context(), cfg, decisionsDoc(t, dir, approveDecision("方源", "Фан Юань")), false)
|
||
if err != nil {
|
||
t.Fatalf("apply: %v", err)
|
||
}
|
||
if !rep.Changed || rep.Version != membank.DecisionsReportVersion {
|
||
t.Fatalf("report = %+v", rep)
|
||
}
|
||
// Law п.3: the answer carries the document version. §4.2: it carries the DEPTH as an explicit field,
|
||
// because a caller must not assume a corrected term re-forms the draft.
|
||
if rep.Depth != membank.DecisionDepth {
|
||
t.Errorf("depth = %q, want %q", rep.Depth, membank.DecisionDepth)
|
||
}
|
||
// Both lists are lists, never null: a consumer has one shape to iterate.
|
||
if rep.Accepted == nil || rep.Rejected == nil {
|
||
t.Errorf("accepted/rejected must serialize as [] not null: %+v", rep)
|
||
}
|
||
if rep.Files.MinedDelta != filepath.Join(dir, "decide-book.mined-delta.yaml") {
|
||
t.Errorf("the report must name the file it wrote: %q", rep.Files.MinedDelta)
|
||
}
|
||
raw, err := os.ReadFile(rep.Files.MinedDelta)
|
||
if err != nil {
|
||
t.Fatalf("the conventional delta must exist after an accepted decision: %v", err)
|
||
}
|
||
if !strings.Contains(string(raw), "status: approved") || !strings.Contains(string(raw), "Фан Юань") {
|
||
t.Errorf("delta:\n%s", raw)
|
||
}
|
||
// The reject list was not touched by an approval, so it must not have been CREATED either: a file in
|
||
// advance is exactly what the convention default abolishes.
|
||
if _, err := os.Stat(rep.Files.MinedRejects); !errors.Is(err, os.ErrNotExist) {
|
||
t.Errorf("a decision that decided nothing about rejects created the reject file: %v", err)
|
||
}
|
||
}
|
||
|
||
// TestBankApplyRepeatIsAByteNoOp is the mutation pin for "the repeat call writes the file again". Inode
|
||
// identity is what makes it a pin: an implementation that re-renders and re-writes identical bytes passes
|
||
// every content check and fails this one.
|
||
func TestBankApplyRepeatIsAByteNoOp(t *testing.T) {
|
||
cfg := decideProject(t)
|
||
dir := filepath.Dir(cfg)
|
||
doc := decisionsDoc(t, dir, approveDecision("方源", "Фан Юань"))
|
||
if _, err := ApplyBankDecisions(t.Context(), cfg, doc, false); err != nil {
|
||
t.Fatalf("first apply: %v", err)
|
||
}
|
||
delta := filepath.Join(dir, "decide-book.mined-delta.yaml")
|
||
before, beforeIno := readAll(t, delta), inode(t, delta)
|
||
|
||
rep, err := ApplyBankDecisions(t.Context(), cfg, doc, false)
|
||
if err != nil {
|
||
t.Fatalf("the repeat of an applied decision must succeed: %v", err)
|
||
}
|
||
if rep.Changed {
|
||
t.Error("a repeat call must report changed=false")
|
||
}
|
||
if len(rep.Accepted) != 1 || rep.Accepted[0].State != membank.StateAlreadyApplied {
|
||
t.Errorf("the report must say «already applied», got %+v", rep.Accepted)
|
||
}
|
||
if got := readAll(t, delta); got != before {
|
||
t.Errorf("the file changed:\n%s\n---\n%s", before, got)
|
||
}
|
||
if got := inode(t, delta); got != beforeIno {
|
||
t.Errorf("the file was REWRITTEN with identical bytes (inode %d → %d) — a no-op must not write", beforeIno, got)
|
||
}
|
||
// A hand-written head comment is the sharpest form of the same assertion, because it is the thing a
|
||
// re-render destroys and no content check would notice: the round-trip through the schema preserves
|
||
// every RECORD and no comment. This is the shape the incident took when a planted mutation was left
|
||
// in the tree — it is what this test caught, so it is what it asserts.
|
||
writeFile(t, delta, "# the owner's own note about this delta\n"+before)
|
||
handIno := inode(t, delta)
|
||
rep, err = ApplyBankDecisions(t.Context(), cfg, doc, false)
|
||
if err != nil {
|
||
t.Fatalf("the repeat over a hand-commented file must succeed: %v", err)
|
||
}
|
||
if rep.Changed {
|
||
t.Error("changed=true on a call that decided nothing — the report itself must not lie")
|
||
}
|
||
if got := readAll(t, delta); !strings.HasPrefix(got, "# the owner's own note") {
|
||
t.Errorf("the owner's comment was destroyed by a call that decided nothing:\n%s", got)
|
||
}
|
||
if got := inode(t, delta); got != handIno {
|
||
t.Errorf("the file was rewritten (inode %d → %d) by a call that decided nothing", handIno, got)
|
||
}
|
||
}
|
||
|
||
func readAll(t *testing.T, path string) string {
|
||
t.Helper()
|
||
b, err := os.ReadFile(path)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
return string(b)
|
||
}
|
||
|
||
// TestBankApplyDryRunWritesNothing: the projection is obtained WITHOUT the mutation (law п.7). A verb
|
||
// that printed the projection and wrote in the same call would not have implemented the rule.
|
||
func TestBankApplyDryRunWritesNothing(t *testing.T) {
|
||
cfg := decideProject(t)
|
||
dir := filepath.Dir(cfg)
|
||
rep, err := ApplyBankDecisions(t.Context(), cfg, decisionsDoc(t, dir, approveDecision("方源", "Фан Юань")), true)
|
||
if err != nil {
|
||
t.Fatalf("projection: %v", err)
|
||
}
|
||
if rep.Mode != "projection" || !rep.Changed {
|
||
t.Errorf("a projection must SAY it is one and still report what would change: %+v", rep)
|
||
}
|
||
if len(rep.Accepted) != 1 || rep.Accepted[0].State != membank.StateApplied {
|
||
t.Errorf("the projection must name what it would apply: %+v", rep.Accepted)
|
||
}
|
||
for _, p := range []string{rep.Files.MinedDelta, rep.Files.MinedRejects} {
|
||
if _, err := os.Stat(p); !errors.Is(err, os.ErrNotExist) {
|
||
t.Errorf("the projection wrote %s", p)
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestBankApplyRefusesAPartialSet is the mutation pin for a PARTIAL write at the file level: one refused
|
||
// decision out of two and NEITHER file exists afterwards.
|
||
func TestBankApplyRefusesAPartialSet(t *testing.T) {
|
||
cfg := decideProject(t)
|
||
dir := filepath.Dir(cfg)
|
||
doc := decisionsDoc(t, dir,
|
||
approveDecision("方源", "Фан Юань"),
|
||
membank.Decision{Action: membank.ActionApprove, Src: "花月"}) // no dst
|
||
rep, err := ApplyBankDecisions(t.Context(), cfg, doc, false)
|
||
if err == nil {
|
||
t.Fatal("a set with a refused decision must refuse as a whole")
|
||
}
|
||
// Its OWN class, not the config one: 10 means the operator fixes a deployment and the platform
|
||
// exempts it from the book's attempt budget as a host-wide condition; this means the END USER
|
||
// re-decides one document on one book.
|
||
if got := refusalClassOf(err); got != RefusalDecisionsRejected {
|
||
t.Errorf("refusal class = %q, want %q", got, RefusalDecisionsRejected)
|
||
}
|
||
if rep.Changed || len(rep.Rejected) != 1 {
|
||
t.Errorf("the report must name the refusal and claim no change: %+v", rep)
|
||
}
|
||
if _, err := os.Stat(rep.Files.MinedDelta); !errors.Is(err, os.ErrNotExist) {
|
||
t.Error("nine accepted decisions out of ten are not nine decisions to write")
|
||
}
|
||
}
|
||
|
||
// TestADeclineDoesNotRewriteTheDelta: a decision touches ONE document, and the other must not be
|
||
// re-rendered as collateral. Found by review — a decline used to canonicalize a hand-formatted delta it
|
||
// had no opinion about, destroying an operator's comments on a call that never mentioned that file.
|
||
func TestADeclineDoesNotRewriteTheDelta(t *testing.T) {
|
||
cfg := decideProject(t)
|
||
dir := filepath.Dir(cfg)
|
||
delta := filepath.Join(dir, "decide-book.mined-delta.yaml")
|
||
hand := "# the owner's own notes about this delta\nterms:\n - src: 老王\n dst: Лао Ван\n status: approved\n"
|
||
writeFile(t, delta, hand)
|
||
|
||
doc := decisionsDoc(t, dir, membank.Decision{Action: membank.ActionDecline, Src: "方源"})
|
||
rep, err := ApplyBankDecisions(t.Context(), cfg, doc, false)
|
||
if err != nil {
|
||
t.Fatalf("apply: %v", err)
|
||
}
|
||
if got := readAll(t, delta); got != hand {
|
||
t.Errorf("the delta was rewritten by a decision that did not touch it:\n%s", got)
|
||
}
|
||
if !strings.Contains(readAll(t, rep.Files.MinedRejects), "方源") {
|
||
t.Error("the decline itself did not land")
|
||
}
|
||
}
|
||
|
||
// TestBankApplyRefusesABusyProject is the mutation pin for a BLOCKING lock, and for the class it must
|
||
// refuse with: exactly 12 (project_locked), never 10. The band distinguishes them non-cosmetically —
|
||
// 10 means a human has to fix a deployment, 12 means wait and retry (D39.134 п.2а).
|
||
func TestBankApplyRefusesABusyProject(t *testing.T) {
|
||
cfg := decideProject(t)
|
||
dir := filepath.Dir(cfg)
|
||
held, err := store.LockProject(filepath.Join(dir, "decide-book.db"))
|
||
if err != nil {
|
||
t.Fatalf("hold the project: %v", err)
|
||
}
|
||
defer held.Release()
|
||
|
||
done := make(chan error, 1)
|
||
go func() {
|
||
_, aerr := ApplyBankDecisions(t.Context(), cfg, decisionsDoc(t, dir, approveDecision("方源", "Фан Юань")), false)
|
||
done <- aerr
|
||
}()
|
||
// It must come back AT ONCE. A blocking take would sit here until the run that holds the project
|
||
// finishes — hours — which is the whole difference between "nothing happened, retry" and a wedged
|
||
// worker. The bound is what makes that a test rather than a hope.
|
||
select {
|
||
case err = <-done:
|
||
case <-time.After(10 * time.Second):
|
||
t.Fatal("the verb is still waiting for a project another process holds — the flock must be taken NON-blocking")
|
||
}
|
||
if err == nil {
|
||
t.Fatal("a busy project must be refused, not queued behind the run that holds it")
|
||
}
|
||
if got := refusalClassOf(err); got != RefusalProjectLocked {
|
||
t.Fatalf("refusal class = %q, want %q", got, RefusalProjectLocked)
|
||
}
|
||
// The projection takes the same lock: a projection computed against state another process is
|
||
// rewriting is not a projection of anything.
|
||
if _, derr := ApplyBankDecisions(t.Context(), cfg, decisionsDoc(t, dir, approveDecision("方源", "Фан Юань")), true); refusalClassOf(derr) != RefusalProjectLocked {
|
||
t.Errorf("the projection mode must respect the same arbiter, got %v", derr)
|
||
}
|
||
}
|
||
|
||
// TestBankApplyReleasesTheLock: a verb that kept the flock would block the very run its decisions are for.
|
||
func TestBankApplyReleasesTheLock(t *testing.T) {
|
||
cfg := decideProject(t)
|
||
dir := filepath.Dir(cfg)
|
||
if _, err := ApplyBankDecisions(t.Context(), cfg, decisionsDoc(t, dir, approveDecision("方源", "Фан Юань")), false); err != nil {
|
||
t.Fatalf("apply: %v", err)
|
||
}
|
||
l, err := store.LockProject(filepath.Join(dir, "decide-book.db"))
|
||
if err != nil {
|
||
t.Fatalf("the project is still held after the verb returned: %v", err)
|
||
}
|
||
l.Release()
|
||
}
|
||
|
||
func TestBankApplyRefusesDecisionsForAnotherBook(t *testing.T) {
|
||
cfg := decideProject(t)
|
||
dir := filepath.Dir(cfg)
|
||
body, err := json.Marshal(membank.DecisionsDoc{Version: membank.DecisionsVersion, BookID: "some-other-book",
|
||
Decisions: []membank.Decision{approveDecision("方源", "Фан Юань")}})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
p := filepath.Join(dir, "foreign.json")
|
||
writeFile(t, p, string(body))
|
||
if _, err := ApplyBankDecisions(t.Context(), cfg, p, false); err == nil {
|
||
t.Fatal("decisions computed for another book must be refused")
|
||
}
|
||
}
|
||
|
||
// TestBankApplyDeclineThenApproveConverges walks the replacement semantics end to end on real files.
|
||
func TestBankApplyDeclineThenApproveConverges(t *testing.T) {
|
||
cfg := decideProject(t)
|
||
dir := filepath.Dir(cfg)
|
||
decline := decisionsDoc(t, dir, membank.Decision{Action: membank.ActionDecline, Src: "方源", Note: "n"})
|
||
if _, err := ApplyBankDecisions(t.Context(), cfg, decline, false); err != nil {
|
||
t.Fatalf("decline: %v", err)
|
||
}
|
||
rejects := filepath.Join(dir, "decide-book.mined-rejects.yaml")
|
||
if !strings.Contains(readAll(t, rejects), "方源") {
|
||
t.Fatalf("the decline was not recorded:\n%s", readAll(t, rejects))
|
||
}
|
||
// A second decision REPLACES the first; there is no "return to undecided" in between.
|
||
body := decisionsDoc(t, dir, approveDecision("方源", "Фан Юань"))
|
||
rep, err := ApplyBankDecisions(t.Context(), cfg, body, false)
|
||
if err != nil {
|
||
t.Fatalf("approve after decline: %v", err)
|
||
}
|
||
if got := readAll(t, rejects); strings.Contains(got, "方源") {
|
||
t.Errorf("the withdrawn decline is still on the reject list:\n%s", got)
|
||
}
|
||
if len(rep.Accepted) != 1 || len(rep.Accepted[0].Replaced) == 0 {
|
||
t.Errorf("the report must name what the decision displaced: %+v", rep.Accepted)
|
||
}
|
||
}
|
||
|
||
// TestTheRunReadsTheConventionalDelta closes the loop the whole pack exists for: the file `bank-apply`
|
||
// writes is the file `translate` reads. Both halves default the path by the same convention, and a book
|
||
// that declares NEITHER key still gets its owner's decisions into the bank.
|
||
//
|
||
// Without this the two sides could each be correct about their own path and still never meet — which is
|
||
// exactly the state the seam was in before D39.156, one level up.
|
||
func TestTheRunReadsTheConventionalDelta(t *testing.T) {
|
||
rec := &reqRec{}
|
||
srv := newJSONProvider(rec, draftEdit)
|
||
defer srv.Close()
|
||
bookPath := setupProject(t, srv.URL)
|
||
dir := filepath.Dir(bookPath)
|
||
|
||
// Written the way the verb writes it, through the verb's own renderer.
|
||
res := membank.ApplyDecisions(membank.ApplyInput{Decisions: []membank.Decision{
|
||
{Action: membank.ActionApprove, Src: "図書館", Dst: "библиотека"}}})
|
||
body, err := membank.RenderSeedFile(res.Delta)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
writeFile(t, filepath.Join(dir, "test-book.mined-delta.yaml"), string(body))
|
||
|
||
r := newRunner(t, bookPath)
|
||
defer r.Close()
|
||
if err := r.seedGlossary(context.Background()); err != nil {
|
||
t.Fatalf("seed the bank: %v", err)
|
||
}
|
||
rows, err := r.Store.GlossaryForBook(r.Book.BookID)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
for _, e := range rows {
|
||
if e.Src != "図書館" {
|
||
continue
|
||
}
|
||
if e.Dst != "библиотека" || e.Status != "approved" {
|
||
t.Fatalf("the decision reached the bank malformed: %+v", e)
|
||
}
|
||
// Source "mined" is what keeps the promise the report's `depth` field makes: the term folds into
|
||
// the ENRICHED bank the editor sees and NOT into the base the draft wave selects over, so a
|
||
// correction costs one edit-wave re-pin rather than the whole draft.
|
||
if e.Source != "mined" {
|
||
t.Fatalf("source = %q, want \"mined\" — a seed-sourced row would move the BASE snapshot and re-pay the draft wave", e.Source)
|
||
}
|
||
return
|
||
}
|
||
t.Fatalf("the conventional mined-delta was not read by the run; bank has %d rows", len(rows))
|
||
}
|
||
|
||
// refusalClassOf extracts the refusal class, or "" when the error is not one.
|
||
func refusalClassOf(err error) RefusalClass {
|
||
var r *Refusal
|
||
if errors.As(err, &r) {
|
||
return r.Class
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// --- the dofix pack ---------------------------------------------------------------------------------
|
||
|
||
func TestBankApplyRefusesAProjectAN_RUNIsHolding(t *testing.T) {
|
||
// The arbiter, held the way a RUN holds it. The sibling test takes the lock with store.LockProject —
|
||
// the same function bank-apply calls — so it proved that LockProject conflicts with LockProject and
|
||
// nothing about the property the law states (17-seam-inbound-law п.2: «the SAME flock of the
|
||
// project»). Renaming the lock file inside LockProject left it green while the door and the run
|
||
// stopped excluding each other entirely, which is the state in which a decision lands inside a live
|
||
// run's snapshot non-deterministically.
|
||
cfg := decideProject(t)
|
||
dir := filepath.Dir(cfg)
|
||
db := filepath.Join(dir, "decide-book.db")
|
||
run, err := store.Open(db) // exactly what a run does, and the only honest way to hold it
|
||
if err != nil {
|
||
t.Fatalf("open the project as a run does: %v", err)
|
||
}
|
||
defer run.Close()
|
||
|
||
done := make(chan error, 1)
|
||
go func() {
|
||
_, aerr := ApplyBankDecisions(t.Context(), cfg, decisionsDoc(t, dir, approveDecision("方源", "Фан Юань")), false)
|
||
done <- aerr
|
||
}()
|
||
select {
|
||
case err = <-done:
|
||
case <-time.After(10 * time.Second):
|
||
t.Fatal("the verb is waiting for a project a RUN holds — the flock must be taken non-blocking")
|
||
}
|
||
if got := refusalClassOf(err); got != RefusalProjectLocked {
|
||
t.Fatalf("a project a run holds must refuse with %q, got %q (err=%v)", RefusalProjectLocked, got, err)
|
||
}
|
||
if _, serr := os.Stat(filepath.Join(dir, "decide-book"+configMinedDeltaSuffix)); serr == nil {
|
||
t.Error("the refused call wrote a decision file anyway")
|
||
}
|
||
}
|
||
|
||
func TestTheReportCarriesTheTwoFieldsThePackMitigatesItsRisksWith(t *testing.T) {
|
||
// Two published fields, each the whole mitigation of a named risk: canonical_rewrite is the only
|
||
// warning that applying will DESTROY an operator's comments (the rewrite is irreversible at the engine
|
||
// level), and preexisting_problems is the only thing that stops the verb answering «applied, exit 0»
|
||
// about a book whose next run is already going to die. Zeroing both left the battery green.
|
||
cfg := decideProject(t)
|
||
dir := filepath.Dir(cfg)
|
||
// A hand-authored delta: an operator's comment and a term that ALREADY cannot load (approved, no dst).
|
||
writeFile(t, filepath.Join(dir, "decide-book"+configMinedDeltaSuffix),
|
||
"# решения владельца, правлено вручную\nterms:\n - src: 甲\n dst: \"\"\n status: approved\n")
|
||
rep, err := ApplyBankDecisions(t.Context(), cfg, decisionsDoc(t, dir, approveDecision("方源", "Фан Юань")), true)
|
||
if err != nil {
|
||
t.Fatalf("a pre-existing fault must not refuse an unrelated decision: %v (%+v)", err, rep.Rejected)
|
||
}
|
||
if !rep.CanonicalRewrite || !rep.CanonicalRewriteDelta {
|
||
t.Errorf("applying will re-render a hand-formatted file this call WRITES; the projection must say so: %+v", rep)
|
||
}
|
||
if rep.CanonicalRewriteRejects {
|
||
t.Error("the reject list is untouched by this call and must not raise the warning")
|
||
}
|
||
if len(rep.PreexistingProblems) == 0 {
|
||
t.Error("the fault the file already has must be reported, or exit 0 is a lie about the next run")
|
||
}
|
||
// …and a book whose files the ENGINE wrote raises neither, so the fields cannot read as noise.
|
||
clean := decideProject(t)
|
||
cleanDir := filepath.Dir(clean)
|
||
if _, err := ApplyBankDecisions(t.Context(), clean, decisionsDoc(t, cleanDir, approveDecision("方源", "Фан Юань")), false); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
rep2, err := ApplyBankDecisions(t.Context(), clean, decisionsDoc(t, cleanDir, approveDecision("花月", "Хуа Юэ")), true)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if rep2.CanonicalRewrite || len(rep2.PreexistingProblems) != 0 {
|
||
t.Errorf("a book the engine wrote must raise neither field: %+v", rep2)
|
||
}
|
||
}
|
||
|
||
func TestAnAbsentDecisionFileIsUndecidedAndAnUnreadableOneIsAnError(t *testing.T) {
|
||
// The declared-path era is retired (§4.9(б)): every decision path is conventional, so ABSENT is one
|
||
// state only — «nobody has decided anything yet». What must NOT blur into it is UNREADABLE: a file
|
||
// that exists and cannot be stat-ed is a host problem, not an empty decision history.
|
||
missing := filepath.Join(t.TempDir(), "gone.yaml")
|
||
if present, err := decisionFilePresent(missing); err != nil || present {
|
||
t.Errorf("an absent conventional path is «nothing decided yet», got present=%v err=%v", present, err)
|
||
}
|
||
there := filepath.Join(t.TempDir(), "here.yaml")
|
||
writeFile(t, there, "rejects: []\n")
|
||
if present, err := decisionFilePresent(there); err != nil || !present {
|
||
t.Errorf("an existing file is present, got %v / %v", present, err)
|
||
}
|
||
locked := filepath.Join(t.TempDir(), "dir", "under.yaml")
|
||
if err := os.MkdirAll(filepath.Dir(locked), 0o755); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
writeFile(t, locked, "rejects: []\n")
|
||
if err := os.Chmod(filepath.Dir(locked), 0o000); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
t.Cleanup(func() { _ = os.Chmod(filepath.Dir(locked), 0o755) })
|
||
if present, err := decisionFilePresent(locked); err == nil {
|
||
t.Errorf("an unreadable path must stay a loud error, got present=%v err=nil", present)
|
||
}
|
||
}
|
||
|
||
func TestTheDecisionDocumentIsCapped(t *testing.T) {
|
||
// The one input to this engine whose size a USER chooses. Uncapped, the engine's answer to running out
|
||
// of memory is `fatal error: out of memory` and exit 2 — and the platform reads 2 as «the command DID
|
||
// its work», so the death of the engine reads as a successful run. Measured on this machine:
|
||
// 20 000 decisions is ~1.7 MB of document, ~50 s and ~745 MB of RSS, already past the platform's
|
||
// 60-second budget for one engine call.
|
||
cfg := decideProject(t)
|
||
dir := filepath.Dir(cfg)
|
||
var b strings.Builder
|
||
b.WriteString(`{"decisions_version":"tm-bank-decisions-v1","book_id":"decide-book","decisions":[`)
|
||
for i := 0; b.Len() <= maxDecisionsBytes; i++ {
|
||
if i > 0 {
|
||
b.WriteString(",")
|
||
}
|
||
fmt.Fprintf(&b, `{"action":"approve","src":"t%d","dst":"d%d","note":"%s"}`, i, i, strings.Repeat("x", 200))
|
||
}
|
||
b.WriteString("]}")
|
||
p := filepath.Join(dir, "big.json")
|
||
writeFile(t, p, b.String())
|
||
|
||
_, err := ApplyBankDecisions(t.Context(), cfg, p, false)
|
||
if got := refusalClassOf(err); got != RefusalDecisionsRejected {
|
||
t.Fatalf("an over-sized document must be refused IN THE BAND, got class %q err=%v", got, err)
|
||
}
|
||
if !strings.Contains(err.Error(), "split it") {
|
||
t.Errorf("the refusal must say what to do about it: %v", err)
|
||
}
|
||
if _, serr := os.Stat(filepath.Join(dir, "decide-book"+configMinedDeltaSuffix)); serr == nil {
|
||
t.Error("the refused call wrote a decision file")
|
||
}
|
||
// A document just UNDER the cap is applied, so the bound is a bound and not a ban.
|
||
small := decisionsDoc(t, dir, approveDecision("方源", "Фан Юань"))
|
||
if _, err := ApplyBankDecisions(t.Context(), cfg, small, false); err != nil {
|
||
t.Fatalf("an ordinary document must still apply: %v", err)
|
||
}
|
||
}
|
||
|
||
func TestTheDecisionCOUNTIsCappedBecauseTheBYTESCannotBound(t *testing.T) {
|
||
// The bound that actually holds the call inside a caller's timeout. A byte cap cannot: the cheapest
|
||
// lawful decision is ~35 bytes (`{"action":"decline","src":"t1"}`) against ~85 for an approval with a
|
||
// note, so 1 MiB admits ~29 000 declines — MEASURED at 308 s, five times the platform's 60-second
|
||
// budget for one engine call, and killed on every retry forever with no refusal ever printed. Found
|
||
// by review of the first version of this cap, which claimed the bytes bounded the time.
|
||
cfg := decideProject(t)
|
||
dir := filepath.Dir(cfg)
|
||
var b strings.Builder
|
||
b.WriteString(`{"decisions_version":"tm-bank-decisions-v1","book_id":"decide-book","decisions":[`)
|
||
for i := 0; i <= maxDecisions; i++ {
|
||
if i > 0 {
|
||
b.WriteString(",")
|
||
}
|
||
fmt.Fprintf(&b, `{"action":"decline","src":"t%d"}`, i)
|
||
}
|
||
b.WriteString("]}")
|
||
if b.Len() > maxDecisionsBytes {
|
||
t.Fatalf("test premise broken: %d minimal decisions are %d bytes, over the byte cap — the two bounds no longer overlap the way this pin needs", maxDecisions+1, b.Len())
|
||
}
|
||
p := filepath.Join(dir, "many.json")
|
||
writeFile(t, p, b.String())
|
||
|
||
_, err := ApplyBankDecisions(t.Context(), cfg, p, false)
|
||
if got := refusalClassOf(err); got != RefusalDecisionsRejected {
|
||
t.Fatalf("a document over the decision COUNT must be refused in the band, got class %q err=%v", got, err)
|
||
}
|
||
if !strings.Contains(err.Error(), "split it") {
|
||
t.Errorf("the refusal must say what to do: %v", err)
|
||
}
|
||
if _, serr := os.Stat(filepath.Join(dir, "decide-book"+configMinedDeltaSuffix)); serr == nil {
|
||
t.Error("the refused call wrote a decision file")
|
||
}
|
||
}
|
||
|
||
func TestAStopRequestWritesNothing(t *testing.T) {
|
||
// SIGTERM means stop, and for an all-or-nothing $0 verb that is the whole of it: drop the work, let go
|
||
// of the lock, write nothing. Before this the verb ignored the signal entirely — it ran to completion
|
||
// and WROTE, which is the one thing a process asked to stop must not do. tmctl maps a cancelled
|
||
// context to exit 5 (graceful stop), which the platform records as `stopped` when it has a stop
|
||
// request on file and as `failed` otherwise — NOT `paused`, which is exit 4
|
||
// (platform/internal/runs/reconcile.go:700-712, :756).
|
||
cfg := decideProject(t)
|
||
dir := filepath.Dir(cfg)
|
||
// TWO cases, and only the second is about the check that matters. A context cancelled BEFORE the call
|
||
// is caught by the early check and never reaches the write at all — a pin built on it alone would go
|
||
// green with the last check removed, which is the state in which a stop request still writes. The
|
||
// double is what puts the cancellation exactly in the window between deciding and writing.
|
||
for _, tc := range []struct {
|
||
name string
|
||
ctx func() context.Context
|
||
}{
|
||
{"cancelled before the call", func() context.Context {
|
||
c, cancel := context.WithCancel(t.Context())
|
||
cancel()
|
||
return c
|
||
}},
|
||
{"cancelled while the decision is being computed", func() context.Context {
|
||
return &cancelAfterNChecks{Context: t.Context(), after: 1, seen: new(int)}
|
||
}},
|
||
} {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
cfg := decideProject(t)
|
||
dir := filepath.Dir(cfg)
|
||
_, err := ApplyBankDecisions(tc.ctx(), cfg, decisionsDoc(t, dir, approveDecision("方源", "Фан Юань")), false)
|
||
if !errors.Is(err, context.Canceled) {
|
||
t.Fatalf("a cancelled call must come back cancelled (exit 5), got %v", err)
|
||
}
|
||
for _, name := range []string{"decide-book" + configMinedDeltaSuffix, "decide-book" + configMinedRejectsSuffix} {
|
||
if _, serr := os.Stat(filepath.Join(dir, name)); serr == nil {
|
||
t.Errorf("a stopped call wrote %s", name)
|
||
}
|
||
}
|
||
})
|
||
}
|
||
_, err := ApplyBankDecisions(t.Context(), cfg, decisionsDoc(t, dir, approveDecision("方源", "Фан Юань")), false)
|
||
if err != nil {
|
||
t.Fatalf("an ordinary call must still work: %v", err)
|
||
}
|
||
}
|
||
|
||
func TestAStoppedCallDoesNotPRINTThatItApplied(t *testing.T) {
|
||
// The exit code was the ONLY thing separating «applied» from «stopped, wrote nothing». The report —
|
||
// which the CLI prints on this path too and whose own doc calls it «the whole of its answer» — went on
|
||
// saying mode:"apply", changed:true with every decision state:"applied". A consumer that keys on the
|
||
// report rather than on the code records the owner's approvals as delivered and never re-sends them.
|
||
cfg := decideProject(t)
|
||
dir := filepath.Dir(cfg)
|
||
rep, err := ApplyBankDecisions(&cancelAfterNChecks{Context: t.Context(), after: 1, seen: new(int)},
|
||
cfg, decisionsDoc(t, dir, approveDecision("方源", "Фан Юань")), false)
|
||
if !errors.Is(err, context.Canceled) {
|
||
t.Fatalf("want a cancelled call, got %v", err)
|
||
}
|
||
if rep.Mode == "apply" || rep.Changed {
|
||
t.Fatalf("a call that wrote nothing must not report that it applied: mode=%q changed=%v", rep.Mode, rep.Changed)
|
||
}
|
||
// …and the ACCEPTED LIST is what a consumer iterates. Leaving it full of entries stamped
|
||
// `state: applied` is the same claim in the field that actually carries it, and the refused path
|
||
// already clears it for exactly this reason.
|
||
if len(rep.Accepted) != 0 {
|
||
t.Fatalf("a stopped call must not list decisions as applied: %+v", rep.Accepted)
|
||
}
|
||
if _, serr := os.Stat(filepath.Join(dir, "decide-book"+configMinedDeltaSuffix)); serr == nil {
|
||
t.Error("…and it must not have written")
|
||
}
|
||
}
|
||
|
||
func TestARefusedCallDoesNotWarnAboutARewriteThatWillNotHappen(t *testing.T) {
|
||
// canonical_rewrite says «applying will re-render this file and destroy its comments», and its own doc
|
||
// says it is actionable only BEFORE the mutation. On a refused call there is no mutation and no moment
|
||
// — but the rollback leaves *Touched standing, so the warning fired anyway. A destructive-action dialog
|
||
// raised for a call that wrote nothing is how a real warning gets trained out of an operator.
|
||
cfg := decideProject(t)
|
||
dir := filepath.Dir(cfg)
|
||
// A hand-formatted delta (so the warning WOULD be true of a writing call) plus a set that is refused:
|
||
// the second decision decides the same surface twice, which is ill-formed.
|
||
writeFile(t, filepath.Join(dir, "decide-book"+configMinedDeltaSuffix),
|
||
"# правлено вручную\nterms:\n - src: 花月\n dst: Хуа Юэ\n status: approved\n")
|
||
doc := decisionsDoc(t, dir, approveDecision("方源", "Фан Юань"), approveDecision("方源", "Другое"))
|
||
rep, err := ApplyBankDecisions(t.Context(), cfg, doc, false)
|
||
if refusalClassOf(err) != RefusalDecisionsRejected {
|
||
t.Fatalf("test premise broken: want the set refused, got %v", err)
|
||
}
|
||
if rep.CanonicalRewrite || rep.CanonicalRewriteDelta || rep.CanonicalRewriteRejects {
|
||
t.Fatalf("a refused call writes nothing, so it must warn about no rewrite: %+v", rep)
|
||
}
|
||
// …and the warning still fires for a call that WILL write.
|
||
ok, err := ApplyBankDecisions(t.Context(), cfg, decisionsDoc(t, dir, approveDecision("方源", "Фан Юань")), true)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if !ok.CanonicalRewriteDelta {
|
||
t.Fatalf("the warning must still be raised for a projection of a call that would re-render: %+v", ok)
|
||
}
|
||
}
|
||
|
||
// cancelAfterNChecks is a context that answers "not cancelled" a fixed number of times and "cancelled"
|
||
// after that — a stop arriving at a chosen point INSIDE the call.
|
||
//
|
||
// ApplyBankDecisions asks the context exactly once per decision point, so `after` selects which one the
|
||
// signal lands on. A test double rather than a race with a real goroutine: the window this is about is
|
||
// microseconds wide and a timing-based pin for it would be a flake, not a gate.
|
||
type cancelAfterNChecks struct {
|
||
context.Context
|
||
after int
|
||
seen *int
|
||
}
|
||
|
||
func (c *cancelAfterNChecks) Err() error {
|
||
*c.seen++
|
||
if *c.seen > c.after {
|
||
return context.Canceled
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func TestChangedDocIsTheSecondFenceAndItsContractIsPinnedDirectly(t *testing.T) {
|
||
// ⚠ This pin is on the FUNCTION, not on a call path, and that is a statement about the design rather
|
||
// than a shortcut. The acceptance asked for a behavioural pin on the byte gate and there cannot be
|
||
// one, because the gate is UNREACHABLE behind the first one — proved, not assumed:
|
||
//
|
||
// deltaChanged = DeltaTouched && changedDoc(disk, next, empty)
|
||
//
|
||
// DeltaTouched is set only when a decision actually changed the document, and `next` is the canonical
|
||
// render of the CHANGED document. For changedDoc to return false the bytes on disk would have to
|
||
// already BE that render — but then the document parsed from them would already be the result and no
|
||
// decision could have changed anything, so DeltaTouched would be false. (Normalization does not open a
|
||
// gap either: a file whose values carry surrounding whitespace does not render back to itself.)
|
||
//
|
||
// So the second fence is defence in depth against a FUTURE `*Touched` that is coarser than today's,
|
||
// and it is kept for that. What can still rot is the function's own contract, and that is what this
|
||
// pins: in particular «an absent file plus a document that says nothing is NOT created», which is the
|
||
// file-in-advance this whole door exists to abolish.
|
||
body := []byte("rejects:\n - src: X\n")
|
||
for _, tc := range []struct {
|
||
name string
|
||
raw, next []byte
|
||
resultEmpty bool
|
||
want bool
|
||
}{
|
||
{"absent file, empty result: do not create it", nil, []byte("rejects: []\n"), true, false},
|
||
{"absent file, a result that says something", nil, body, false, true},
|
||
{"same bytes: a retry must not rewrite", body, body, false, false},
|
||
{"different bytes", body, []byte("rejects: []\n"), true, true},
|
||
} {
|
||
if got := changedDoc(tc.raw, tc.next, tc.resultEmpty); got != tc.want {
|
||
t.Errorf("%s: changedDoc = %v, want %v", tc.name, got, tc.want)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestASignatureMapThatCannotBeReadDoesNotReportAsAllDecided(t *testing.T) {
|
||
// `signature.undecided == 0` is the one answer this field must never give by accident. A map that
|
||
// does not parse used to come back as {surfaces: 0, undecided: 0} — byte-identical to «everything is
|
||
// decided», which is exactly the reading the field exists to make honest. Found by review.
|
||
cfg := decideProject(t)
|
||
dir := filepath.Dir(cfg)
|
||
writeFile(t, filepath.Join(dir, "decide-book.db.mined-signature.yaml"), "terms:\n - src: 方源\n bad indent\n")
|
||
rep, err := ApplyBankDecisions(t.Context(), cfg, decisionsDoc(t, dir, approveDecision("花月", "Хуа Юэ")), true)
|
||
if err != nil {
|
||
t.Fatalf("a broken sidecar must not refuse the decisions: %v", err)
|
||
}
|
||
if !rep.Signature.Unreadable {
|
||
t.Fatalf("a map that cannot be read must say so: %+v", rep.Signature)
|
||
}
|
||
// A map that exists but cannot be READ is the same class and used to slip through: the read error
|
||
// returned the ZERO state, which is byte-identical to «no map at all» AND to «nothing left undecided».
|
||
sig := filepath.Join(dir, "decide-book.db.mined-signature.yaml")
|
||
writeFile(t, sig, "terms:\n - src: 方源\n dst: \"\"\n status: auto\n")
|
||
if err := os.Chmod(sig, 0); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
t.Cleanup(func() { _ = os.Chmod(sig, 0o644) })
|
||
if os.Geteuid() != 0 { // root reads it anyway, and then this case cannot be produced
|
||
unreadable, uerr := ApplyBankDecisions(t.Context(), cfg, decisionsDoc(t, dir, approveDecision("花月", "Хуа Юэ")), true)
|
||
if uerr != nil {
|
||
t.Fatalf("an unreadable sidecar must not refuse the decisions: %v", uerr)
|
||
}
|
||
if !unreadable.Signature.Unreadable {
|
||
t.Fatalf("a map that cannot be read must say so rather than report «nothing undecided»: %+v", unreadable.Signature)
|
||
}
|
||
}
|
||
if err := os.Chmod(sig, 0o644); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
// …and a map that CAN be read still answers the real question.
|
||
writeFile(t, filepath.Join(dir, "decide-book.db.mined-signature.yaml"),
|
||
"terms:\n - src: 方源\n dst: \"\"\n status: auto\n - src: 花月\n dst: \"\"\n status: auto\n")
|
||
rep, err = ApplyBankDecisions(t.Context(), cfg, decisionsDoc(t, dir, approveDecision("花月", "Хуа Юэ")), true)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if rep.Signature.Unreadable || rep.Signature.Surfaces != 2 || rep.Signature.Undecided != 1 {
|
||
t.Fatalf("one of two surfaces decided: %+v", rep.Signature)
|
||
}
|
||
}
|