textmachine/backend/internal/pipeline/bankreport_test.go

429 lines
20 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package pipeline
import (
"context"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"textmachine/backend/internal/config"
"textmachine/backend/internal/membank"
)
// bankreport_test.go: the single commit point of the report (§4.5), the write-incomplete class
// (§4.7/§4.8) and the cap-refusal report — fix-pack-2.
// TestEveryOutcomeStampsItsOwnMode enumerates the verb's outcomes and checks the outcome half of the
// report against each — the pin §4.5 asks for: a branch cannot forget a field it never owns, and this
// test is where forgetting would surface.
func TestEveryOutcomeStampsItsOwnMode(t *testing.T) {
// PROJECTION: measured, nothing touched, `changed` speaks in the subjunctive.
cfg := decideProject(t)
dir := filepath.Dir(cfg)
doc := decisionsDoc(t, dir, approveDecision("方源", "Фан Юань"))
rep, err := ApplyBankDecisions(t.Context(), cfg, doc, true)
if err != nil {
t.Fatal(err)
}
if rep.Mode != "projection" || !rep.Changed || rep.WrittenDelta || rep.WrittenRejects {
t.Fatalf("projection: %+v", rep)
}
// WRITTEN: the world moved and the report says which file.
rep, err = ApplyBankDecisions(t.Context(), cfg, doc, false)
if err != nil {
t.Fatal(err)
}
if rep.Mode != "apply" || !rep.Changed || !rep.WrittenDelta || rep.WrittenRejects {
t.Fatalf("written: mode=%s changed=%v written_delta=%v written_rejects=%v", rep.Mode, rep.Changed, rep.WrittenDelta, rep.WrittenRejects)
}
// NOOP: the same document again — nothing to write, and `written_*` must not claim otherwise.
rep, err = ApplyBankDecisions(t.Context(), cfg, doc, false)
if err != nil {
t.Fatal(err)
}
if rep.Mode != "apply" || rep.Changed || rep.WrittenDelta || rep.WrittenRejects {
t.Fatalf("noop: %+v", rep)
}
// REFUSED: the mode says so now, instead of an `apply` that did not happen.
bad := decisionsDoc(t, dir, membank.Decision{Action: membank.ActionApprove, Src: "花月"}) // no dst
rep, err = ApplyBankDecisions(t.Context(), cfg, bad, false)
if refusalClassOf(err) != RefusalDecisionsRejected {
t.Fatalf("want the decisions class, got %v", err)
}
if rep.Mode != "refused" || rep.Changed || len(rep.Accepted) != 0 || rep.WrittenDelta || rep.WrittenRejects {
t.Fatalf("refused: %+v", rep)
}
// STOPPED: cancelled in the window between deciding and writing.
stopCtx := &cancelAfterNChecks{Context: t.Context(), after: 1, seen: new(int)}
rep, err = ApplyBankDecisions(stopCtx, cfg, decisionsDoc(t, dir, approveDecision("花月", "Хуа Юэ")), false)
if !errors.Is(err, context.Canceled) {
t.Fatal(err)
}
if rep.Mode != "stopped" || rep.Changed || len(rep.Accepted) != 0 || rep.WrittenDelta || rep.WrittenRejects {
t.Fatalf("stopped: %+v", rep)
}
}
// TestAStoppedCallMeasuresTheSignatureAgainstTheDiskNotTheIntent is the fourth branch of the one hole
// four review rounds kept finding: the interrupted call used to keep the signature block computed from
// the bytes it INTENDED to write, so a call that wrote nothing printed «не решено: 0». The post-state
// of an outcome that touched nothing is the disk's.
func TestAStoppedCallMeasuresTheSignatureAgainstTheDiskNotTheIntent(t *testing.T) {
cfg := decideProject(t)
dir := filepath.Dir(cfg)
book, err := config.LoadBook(cfg)
if err != nil {
t.Fatal(err)
}
// A signature map with one surface, and a decision set that would decide it.
writeFile(t, signatureMapPath(book.ProjectDB), "terms:\n - src: 方源\n status: auto\n")
stopCtx := &cancelAfterNChecks{Context: t.Context(), after: 1, seen: new(int)}
rep, err := ApplyBankDecisions(stopCtx, cfg, decisionsDoc(t, dir, approveDecision("方源", "Фан Юань")), false)
if !errors.Is(err, context.Canceled) {
t.Fatal(err)
}
if rep.Signature.Unreadable || rep.Signature.Surfaces != 1 || rep.Signature.Undecided != 1 {
t.Fatalf("a stopped call wrote nothing, so the surface is still undecided ON DISK: %+v", rep.Signature)
}
// …and the same call allowed to finish reports the surface decided — from the RE-READ files.
rep, err = ApplyBankDecisions(t.Context(), cfg, decisionsDoc(t, dir, approveDecision("方源", "Фан Юань")), false)
if err != nil {
t.Fatal(err)
}
if rep.Signature.Undecided != 0 {
t.Fatalf("after the write the surface is decided: %+v", rep.Signature)
}
}
// TestNothingLandedIsTheWriteIncompleteClass: an environment failure met while both documents are still
// staged refuses with the new class, a report, and ZERO bytes moved — the acceptance's repro (EXIT=1
// with a success-shaped report over a written delta) is dead in both halves.
func TestNothingLandedIsTheWriteIncompleteClass(t *testing.T) {
cfg := decideProject(t)
dir := filepath.Dir(cfg)
book, err := config.LoadBook(cfg)
if err != nil {
t.Fatal(err)
}
// The lock file must pre-exist: creating it needs a writable directory, and the point of this fixture
// is that the directory stops being writable only at the WRITE phase.
writeFile(t, book.ProjectDB+".lock", "")
doc := decisionsDoc(t, t.TempDir(), approveDecision("方源", "Фан Юань"))
if err := os.Chmod(dir, 0o555); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.Chmod(dir, 0o755) })
rep, err := ApplyBankDecisions(t.Context(), cfg, doc, false)
if refusalClassOf(err) != RefusalWriteIncomplete {
t.Fatalf("want %s, got %v", RefusalWriteIncomplete, err)
}
if rep.Mode != "write_incomplete" || rep.Changed || rep.WrittenDelta || rep.WrittenRejects || len(rep.Accepted) != 0 {
t.Fatalf("nothing landed and the report must say so per file: %+v", rep)
}
if _, serr := os.Stat(book.MinedDelta); serr == nil {
t.Fatal("the staged write leaked a document into a directory it could not finish in")
}
// …and after the environment heals, the SAME document converges.
if err := os.Chmod(dir, 0o755); err != nil {
t.Fatal(err)
}
rep, err = ApplyBankDecisions(t.Context(), cfg, doc, false)
if err != nil || !rep.WrittenDelta {
t.Fatalf("the retry must land: %+v / %v", rep, err)
}
}
// TestHalfLandedIsNamedPerFile drives the write phase directly into its between-the-renames failure —
// the FIRST document commits, the SECOND rename hits an existing directory — and pins that the per-file
// truth and the single class survive it. One class for both halves; the report carries the difference.
//
// ⚠ THE FIXTURE'S TARGET MOVED, THE PROPERTY DID NOT. This test used to occupy the REJECTS path,
// because the commit order was delta-first. That order is now rejects-first and the reason is argued in
// writeDecisionFiles: delta-first stranded a decline forever (dropping the delta row is exactly what
// makes refuseSeedConflicts fire on the re-send, and no predicate over the two files can tell that
// half-state from a genuinely inert decline). So the surviving half is now the rejects and the failing
// rename is the delta — the same between-the-renames failure, seen from the other side. What is asserted
// is unchanged and unweakened: exactly one half landed, it is the PROVEN bytes, and the report names
// which. The order itself is pinned, with its reason, by TestTheRenameOrderIsWhatMakesADeclineRecover.
func TestHalfLandedIsNamedPerFile(t *testing.T) {
dir := t.TempDir()
book := &config.Book{BookID: "half-book",
MinedDelta: filepath.Join(dir, "half-book"+config.MinedDeltaSuffix),
MinedRejects: filepath.Join(dir, "half-book"+config.MinedRejectsSuffix),
ProjectDB: filepath.Join(dir, "half-book.db"),
}
// The DELTA target IS an existing non-empty directory: staging beside it succeeds, the rename fails.
if err := os.MkdirAll(filepath.Join(book.MinedDelta, "occupied"), 0o755); err != nil {
t.Fatal(err)
}
res := membank.ApplyResult{DeltaBytes: []byte("terms: []\n"), RejectBytes: []byte("rejects: []\n")}
wrote, err := writeDecisionFiles(book, res, true, true)
if err == nil {
t.Fatal("the delta rename cannot succeed onto a directory")
}
if wrote.delta || !wrote.rejects {
t.Fatalf("per-file truth: rejects landed, delta did not — got %+v", wrote)
}
if raw, rerr := os.ReadFile(book.MinedRejects); rerr != nil || string(raw) != "rejects: []\n" {
t.Fatalf("the landed half must be the proven bytes: %q / %v", raw, rerr)
}
rep := finishReport(decisionVerdict(book, membank.ApplyResult{}), outcomeWriteFailed, book, bookState{}, membank.ApplyResult{}, wrote)
if rep.Mode != "write_incomplete" || rep.WrittenDelta || !rep.WrittenRejects || !rep.Changed || len(rep.Accepted) != 0 {
t.Fatalf("half landed and the report must say which half: %+v", rep)
}
}
// TestTheRenameOrderIsWhatMakesADeclineRecover pins the ORDER itself, and its REASON, so it cannot be
// flipped back as a tidy-up. The comment it replaces called the order arbitrary; it is not.
func TestTheRenameOrderIsWhatMakesADeclineRecover(t *testing.T) {
dir := t.TempDir()
book := &config.Book{BookID: "order-book",
MinedDelta: filepath.Join(dir, "order-book"+config.MinedDeltaSuffix),
MinedRejects: filepath.Join(dir, "order-book"+config.MinedRejectsSuffix),
}
// Make the SECOND rename fail, whichever it is, by occupying the delta path.
if err := os.MkdirAll(filepath.Join(book.MinedDelta, "occupied"), 0o755); err != nil {
t.Fatal(err)
}
res := membank.ApplyResult{DeltaBytes: []byte("terms: []\n"), RejectBytes: []byte("rejects: []\n")}
wrote, _ := writeDecisionFiles(book, res, true, true)
// The DECIDING document — the one refuseSeedConflicts reads — must be the one still unwritten, so
// the interrupted decline's precondition survives and the re-send converges. If the delta had landed
// first, the row would be gone, the reject absent, and the identical document refused forever.
if wrote.delta {
t.Fatal("the delta must NOT be the first document to land: a decline killed after it is unrecoverable")
}
if !wrote.rejects {
t.Fatal("the rejects must land first, so an interrupted decline leaves the delta row intact for the re-send")
}
}
// TestBothDocumentsAreStagedBeforeEitherRename pins the §4.8 order itself: a failure to STAGE the
// second document must leave the FIRST unmoved — under the old interleaved write the delta was already
// committed by then, which is how «half landed» happened for a failure that staging would have caught
// with zero bytes moved.
func TestBothDocumentsAreStagedBeforeEitherRename(t *testing.T) {
dirA := t.TempDir()
dirB := filepath.Join(t.TempDir(), "sealed")
if err := os.MkdirAll(dirB, 0o555); err != nil { // rejects cannot even be STAGED here
t.Fatal(err)
}
t.Cleanup(func() { _ = os.Chmod(dirB, 0o755) })
book := &config.Book{BookID: "stage-book",
MinedDelta: filepath.Join(dirA, "stage-book"+config.MinedDeltaSuffix),
MinedRejects: filepath.Join(dirB, "stage-book"+config.MinedRejectsSuffix),
}
res := membank.ApplyResult{DeltaBytes: []byte("terms: []\n"), RejectBytes: []byte("rejects: []\n")}
wrote, err := writeDecisionFiles(book, res, true, true)
if err == nil {
t.Fatal("staging into a sealed directory cannot succeed")
}
if wrote.delta || wrote.rejects {
t.Fatalf("nothing may land when staging fails: %+v", wrote)
}
if _, serr := os.Stat(book.MinedDelta); serr == nil {
t.Fatal("the delta moved before the rejects were staged — the §4.8 order is broken")
}
}
// TestAnUnsyncableDirectoryIsWriteIncompleteWithTheFilesLanded pins the durability tail of §4.8: the
// renames land, the directory cannot be opened to flush them, and the verb answers with the
// write-incomplete class while the per-file truth still names what is visibly on disk.
func TestAnUnsyncableDirectoryIsWriteIncompleteWithTheFilesLanded(t *testing.T) {
dir := t.TempDir()
book := &config.Book{BookID: "sync-book",
MinedDelta: filepath.Join(dir, "sync-book"+config.MinedDeltaSuffix),
MinedRejects: filepath.Join(dir, "sync-book"+config.MinedRejectsSuffix),
}
res := membank.ApplyResult{DeltaBytes: []byte("terms: []\n"), RejectBytes: []byte("rejects: []\n")}
// Write+search but NO read permission: staging and both renames work, os.Open(dir) for the sync does
// not.
if err := os.Chmod(dir, 0o333); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.Chmod(dir, 0o755) })
wrote, err := writeDecisionFiles(book, res, true, true)
if err == nil {
t.Fatal("an unsyncable directory must be reported, not passed over")
}
if !strings.Contains(err.Error(), "durable") {
t.Fatalf("the failure must name durability, not masquerade as a write error: %v", err)
}
if !wrote.delta || !wrote.rejects {
t.Fatalf("the renames landed and the per-file truth must say so: %+v", wrote)
}
}
// TestACapRefusalPrintsAReportToo closes the contract's second carrier (§4.7): every refusal of the
// DECISIONS class carries a report, the two caps included — they used to speak on stderr alone.
func TestACapRefusalPrintsAReportToo(t *testing.T) {
cfg := decideProject(t)
book, err := config.LoadBook(cfg)
if err != nil {
t.Fatal(err)
}
// A map exists, so the unmeasured signature block must say «unreadable», never zeroes that spell
// «everything is decided».
writeFile(t, signatureMapPath(book.ProjectDB), "terms:\n - src: 方源\n status: auto\n")
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(",")
}
b.WriteString(`{"action":"decline","src":"t` + itoa(i) + `"}`)
}
b.WriteString("]}")
docPath := filepath.Join(t.TempDir(), "over-count.json")
writeFile(t, docPath, b.String())
rep, err := ApplyBankDecisions(t.Context(), cfg, docPath, false)
if refusalClassOf(err) != RefusalDecisionsRejected {
t.Fatalf("want the decisions class, got %v", err)
}
if rep.Version == "" || rep.Mode != "refused" || rep.BookID != "decide-book" {
t.Fatalf("a cap refusal owes the caller a report: %+v", rep)
}
if len(rep.Rejected) != 1 || rep.Rejected[0].Index != -1 || !strings.Contains(rep.Rejected[0].Reason, "split it") {
t.Fatalf("the report must carry the cap reason as a whole-document rejection: %+v", rep.Rejected)
}
if !rep.Signature.Unreadable {
t.Fatalf("unmeasured must not read as «all decided»: %+v", rep.Signature)
}
}
// itoa avoids fmt in a tight fixture loop.
func itoa(i int) string {
if i == 0 {
return "0"
}
var d []byte
for ; i > 0; i /= 10 {
d = append([]byte{byte('0' + i%10)}, d...)
}
return string(d)
}
// TestTheCapBoundariesSitExactlyOnTheCap pins both `>` guards at their boundary — the acceptance's
// `>` → `>=` plantings survived the battery, meaning a lawful document AT the cap had no pin.
func TestTheCapBoundariesSitExactlyOnTheCap(t *testing.T) {
// The byte cap, on the reader itself.
dir := t.TempDir()
at := filepath.Join(dir, "at.bin")
writeFile(t, at, strings.Repeat("x", maxDecisionsBytes))
if _, err := readDecisions(at); err != nil {
t.Fatalf("a document of exactly %d bytes is read (the parse judges it later): %v", maxDecisionsBytes, err)
}
over := filepath.Join(dir, "over.bin")
writeFile(t, over, strings.Repeat("x", maxDecisionsBytes+1))
if _, err := readDecisions(over); refusalClassOf(err) != RefusalDecisionsRejected {
t.Fatalf("one byte over must refuse with the decisions class, got %v", err)
}
// The count cap, on the gate itself (the gate runs before the Θ(N²) pass, so the boundary is
// testable without paying the ~11 s the full apply of 5 000 costs).
cfg := decideProject(t)
docAt := filepath.Join(dir, "at-count.json")
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(",")
}
b.WriteString(`{"action":"decline","src":"t` + itoa(i) + `"}`)
}
b.WriteString("]}")
writeFile(t, docAt, b.String())
if _, _, err := openDecisionRequest(cfg, docAt); err != nil {
t.Fatalf("exactly %d decisions pass the gate: %v", maxDecisions, err)
}
}
// TestBankApplyReportsAbsolutePathsEvenWhenTheConfigIsNot is the bank-apply half of the guarantee the
// status report already pins: with a RELATIVE --config, `files` must still come back absolute, because
// the consumer runs in its own working directory.
func TestBankApplyReportsAbsolutePathsEvenWhenTheConfigIsNot(t *testing.T) {
cfg := decideProject(t)
dir := filepath.Dir(cfg)
doc := decisionsDoc(t, dir, approveDecision("方源", "Фан Юань"))
t.Chdir(dir)
rep, err := ApplyBankDecisions(t.Context(), filepath.Base(cfg), filepath.Base(doc), true)
if err != nil {
t.Fatal(err)
}
for name, got := range map[string]string{"mined_delta": rep.Files.MinedDelta, "mined_rejects": rep.Files.MinedRejects} {
if !filepath.IsAbs(got) {
t.Errorf("files.%s = %q — a consumer in another working directory reads the wrong file", name, got)
}
}
}
// TestARetryAfterAFailedDirectorySyncReProvesDurability closes the convergence hole the review workflow
// found: the retry the write-incomplete class prescribes lands on the byte-no-op branch, and without a
// sync there the one thing exit 15 warned about — unflushed directory entries — was never re-proven,
// with the retry answering 0.
func TestARetryAfterAFailedDirectorySyncReProvesDurability(t *testing.T) {
cfg := decideProject(t)
dir := filepath.Dir(cfg)
doc := decisionsDoc(t, t.TempDir(), approveDecision("方源", "Фан Юань"))
if _, err := ApplyBankDecisions(t.Context(), cfg, doc, false); err != nil {
t.Fatal(err)
}
// The directory can still be written and traversed but not opened for the sync.
if err := os.Chmod(dir, 0o333); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.Chmod(dir, 0o755) })
rep, err := ApplyBankDecisions(t.Context(), cfg, doc, false)
if refusalClassOf(err) != RefusalWriteIncomplete {
t.Fatalf("a no-op over an unflushable directory must keep refusing with the class, got %v", err)
}
if rep.WrittenDelta || rep.WrittenRejects || rep.Changed {
t.Fatalf("the no-op retry wrote nothing and must say so: %+v", rep)
}
if !strings.Contains(err.Error(), "durability") {
t.Fatalf("the refusal must name what is still unproven: %v", err)
}
// The environment heals → the same document converges to 0.
if err := os.Chmod(dir, 0o755); err != nil {
t.Fatal(err)
}
rep, err = ApplyBankDecisions(t.Context(), cfg, doc, false)
if err != nil || rep.Changed {
t.Fatalf("after the heal the retry is an ordinary no-op: %+v / %v", rep, err)
}
}
// TestAWriteFailedCallMeasuresTheSignatureAgainstTheDiskNotTheIntent is §4.2 of fix3 — the write-failed
// sibling of the stopped-call pin above. A call that accepted a document deciding the map's surface and
// then FAILED to land it must not print «undecided: 0»: that is the intent, not the world, and it is a
// success-shaped block on exactly the outcome the honesty work was done for. Reproduced before fixing:
// swapping the outcome's post-state source to the call's result kept the whole battery green — the
// existing constructor pin feeds empty states, where the two sources are indistinguishable.
func TestAWriteFailedCallMeasuresTheSignatureAgainstTheDiskNotTheIntent(t *testing.T) {
cfg := decideProject(t)
dir := filepath.Dir(cfg)
book, err := config.LoadBook(cfg)
if err != nil {
t.Fatal(err)
}
writeFile(t, signatureMapPath(book.ProjectDB), "terms:\n - src: 方源\n status: auto\n")
writeFile(t, book.ProjectDB+".lock", "")
doc := decisionsDoc(t, t.TempDir(), approveDecision("方源", "Фан Юань"))
if err := os.Chmod(dir, 0o555); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.Chmod(dir, 0o755) })
rep, err := ApplyBankDecisions(t.Context(), cfg, doc, false)
if refusalClassOf(err) != RefusalWriteIncomplete {
t.Fatalf("fixture premise: want the write-incomplete class, got %v", err)
}
if rep.Signature.Unreadable || rep.Signature.Surfaces != 1 || rep.Signature.Undecided != 1 {
t.Fatalf("nothing landed, so the surface is still undecided ON DISK — the intent must not leak into the report: %+v", rep.Signature)
}
}