429 lines
20 KiB
Go
429 lines
20 KiB
Go
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)
|
||
}
|
||
}
|