textmachine/backend/internal/pipeline/contourpreflight_test.go

281 lines
13 KiB
Go

package pipeline
import (
"bytes"
"errors"
"io"
"log/slog"
"os"
"path/filepath"
"strings"
"testing"
)
// contourpreflight_test.go: the WRITE-path half of "a configured bank contour cannot silently do nothing"
// (backlog row 140). The loader judges the KEY; whether the artifact is on this machine, and whether the
// book has a langpack at all, are facts only the runner has — and both decide whether a single bank-role
// call can ever happen.
//
// ⚠ BOTH GUARDS WERE UNPINNED AFTER BEING BUILT, which the acceptance found by planting: deleting either
// refusal left the full config, pipeline and tmctl suites green. The cost of the hole is precise: the run
// opens the contrast artifact at the bank-mining stop, which is AFTER the whole draft wave is bought, so a
// deployment that shipped the configs and forgot the artifact pays for a wave to be told.
// TestTheContourRefusesAWriteRunItCannotExecute covers both halves and, as importantly, the NEGATIVE: a $0
// read must stay possible on exactly the book a paid run refuses. A guard that also blocked `status` would
// be the D20.4 mistake — a book must stay inspectable on a host that cannot run it.
func TestTheContourRefusesAWriteRunItCannotExecute(t *testing.T) {
for _, tc := range []struct {
name string
mutate func(t *testing.T, bookPath string)
want []string
}{
{
// The artifact the deployment provides and git does not.
name: "the contrast artifact is not on this machine",
mutate: func(t *testing.T, bookPath string) {
if err := os.Remove(filepath.Join(filepath.Dir(bookPath), "mining-contrast.txt")); err != nil {
t.Fatal(err)
}
},
want: []string{"mining.contrast_path", "not readable", "draft wave"},
},
{
// The OTHER condition of the same early return, and the one a config loader structurally cannot
// see: a book with no langpack gets a nil pack and the stop returns before any candidate exists.
name: "the book has no langpack to mine with",
mutate: func(t *testing.T, bookPath string) {
raw, err := os.ReadFile(bookPath)
if err != nil {
t.Fatal(err)
}
var kept []string
for _, ln := range strings.Split(string(raw), "\n") {
if !strings.HasPrefix(ln, "langpack_root:") {
kept = append(kept, ln)
}
}
if err := os.WriteFile(bookPath, []byte(strings.Join(kept, "\n")), 0o644); err != nil {
t.Fatal(err)
}
},
want: []string{"cannot mine a bank", "langpack_root", "never executed"},
},
} {
t.Run(tc.name, func(t *testing.T) {
srv := newJSONProvider(&reqRec{}, draftEdit)
defer srv.Close()
bookPath := setupMiningStopProject(t, srv.URL, miningStopOpts{terminology: true})
// Premise: BEFORE the mutation the same book opens for writing. Without this the test would
// pass on a fixture that was broken for some entirely different reason.
r, err := NewRunner(bookPath, slog.New(slog.NewTextHandler(io.Discard, nil)))
if err != nil {
t.Fatalf("premise broken: the intact fixture must open for writing: %v", err)
}
r.Close()
tc.mutate(t, bookPath)
if _, err := NewRunner(bookPath, slog.New(slog.NewTextHandler(io.Discard, nil))); err == nil {
t.Fatal("a run that cannot execute one line of its configured contour must refuse BEFORE it buys a wave")
} else {
for _, want := range tc.want {
if !strings.Contains(err.Error(), want) {
t.Errorf("the refusal must name the cure, missing %q: %v", want, err)
}
}
var refusal *Refusal
if !errors.As(err, &refusal) || refusal.Class != RefusalBadConfig {
t.Errorf("a preflight refusal is a CONFIG refusal, not a crash: %T %v", err, err)
}
}
// ⛔ AND THE $0 READ STILL WORKS. The same book, the same defect, the read-only door.
ro, err := NewReadOnlyRunner(bookPath, slog.New(slog.NewTextHandler(io.Discard, nil)))
if err != nil {
t.Fatalf("a book must stay inspectable on a host that cannot run it (D20.4): %v", err)
}
ro.Close()
})
}
}
// TestTheBanknoteChannelSaysWhenNobodyWillReadIt is the sibling of the refusals above, and it is a WARNING
// because it is a different fault: the run is correct and merely wasteful. The gate's own job — slicing the
// ⟦TM-BANK-v1⟧ block off the draft so it never reaches the editor or the export — is done either way; what
// is bought and unread sits upstream of it, in the pair's prompt asking the translator for a term table on
// every call. Its only consumer is the bank-mining stop, and a book with no langpack or no contrast artifact
// returns from that stop before reading anything.
//
// Found by planting: the class the two refusals cover has this neighbour, and neither guard keys on it.
func TestTheBanknoteChannelSaysWhenNobodyWillReadIt(t *testing.T) {
srv := newJSONProvider(&reqRec{}, draftEdit)
defer srv.Close()
// The banknote gate ON, and NO mining inputs — the shape the fixtures of the banknote channel take.
bookPath := setupProjectOpts(t, srv.URL, projectOpts{banknote: true})
var log bytes.Buffer
r, err := NewRunner(bookPath, slog.New(slog.NewTextHandler(&log, &slog.HandlerOptions{Level: slog.LevelInfo})))
if err != nil {
t.Fatalf("this configuration is legal and must still open — it is wasteful, not broken: %v", err)
}
defer r.Close()
out := log.String()
for _, want := range []string{"banknote channel is ON", "billed", "mining.contrast_path"} {
if !strings.Contains(out, want) {
t.Errorf("the warning must name what is bought and what would fix it, missing %q:\n%s", want, out)
}
}
// ⛔ AND IT MUST NOT FIRE ON A BOOK THAT CAN READ THEM. A warning that cries on the healthy configuration
// is a warning an operator learns to ignore, which is worse than none.
var quiet bytes.Buffer
full := setupMiningStopProject(t, srv.URL, miningStopOpts{terminology: true})
r2, err := NewRunner(full, slog.New(slog.NewTextHandler(&quiet, &slog.HandlerOptions{Level: slog.LevelInfo})))
if err != nil {
t.Fatal(err)
}
defer r2.Close()
if strings.Contains(quiet.String(), "banknote channel is ON") {
t.Errorf("a book that CAN reach the bank-mining stop must not be warned:\n%s", quiet.String())
}
}
// TestEveryPromptLoadPathRefusesAnUnrenderableTemplate is the load gate asserted PER PATH, and the reason it
// exists is a planting result rather than a worry: every one of the four paths could be routed around the
// check without a single test noticing. The gate was pinned only as a unit property of LoadPromptTemplate —
// "the check stands once and covers all of them" rested on the funnel's SHAPE, and a shape is not a
// guarantee. Four paths load a prompt (a stage, a repair class, and each bank role) and each is a place a
// future edit could read a file directly.
//
// The cost of the hole is the one the gate exists to remove: the refusal returns to Render, mid-run, after
// the wave that stage sits behind has been bought.
func TestEveryPromptLoadPathRefusesAnUnrenderableTemplate(t *testing.T) {
const bad = "Роль {{chapter_title}}.\n---USER---\n{{text}}"
for _, tc := range []struct {
name string
setup func(t *testing.T, providerURL string) string
file []string // path segments under the book's directory
}{
// The stage fixture points its stages at `prompts/<role>.md` through prompt_override, which is the
// second of the two ways a stage prompt is resolved and therefore the one worth exercising here.
{"a stage prompt", func(t *testing.T, u string) string {
return setupProjectOpts(t, u, projectOpts{})
}, []string{"prompts", "translator.md"}},
{"the terminologist's prompt", func(t *testing.T, u string) string {
return setupMiningStopProject(t, u, miningStopOpts{terminology: true})
}, []string{"prompts", "zh-ru", "terminologist.md"}},
{"the classifier's prompt", func(t *testing.T, u string) string {
return setupMiningStopProject(t, u, miningStopOpts{terminology: true, classify: true})
}, []string{"prompts", "zh-ru", "classifier.md"}},
{"a repair class prompt", setupRepairProject, []string{"prompts", "zh-ru", "repair", "dc1_fractional.md"}},
} {
t.Run(tc.name, func(t *testing.T) {
srv := newJSONProvider(&reqRec{}, draftEdit)
defer srv.Close()
bookPath := tc.setup(t, srv.URL)
// Premise: the intact fixture opens. Without it a refusal proves nothing about the placeholder.
r, err := NewRunner(bookPath, slog.New(slog.NewTextHandler(io.Discard, nil)))
if err != nil {
t.Fatalf("premise broken: the intact fixture must open: %v", err)
}
r.Close()
path := filepath.Join(append([]string{filepath.Dir(bookPath)}, tc.file...)...)
if _, err := os.Stat(path); err != nil {
t.Fatalf("the fixture must actually lay down %s: %v", strings.Join(tc.file, "/"), err)
}
if err := os.WriteFile(path, []byte(bad), 0o644); err != nil {
t.Fatal(err)
}
_, err = NewRunner(bookPath, slog.New(slog.NewTextHandler(io.Discard, nil)))
if err == nil {
t.Fatal("a template the engine cannot render must be refused at LOAD, on THIS path, before the run buys anything")
}
for _, want := range []string{"{{chapter_title}}", tc.file[len(tc.file)-1]} {
if !strings.Contains(err.Error(), want) {
t.Errorf("the refusal must name the marker and the file, missing %q: %v", want, err)
}
}
})
}
}
// TestTheBanknoteWarningKeysOnEachConditionSeparately closes a WB41-class hole in the test above: its
// positive fixture has NEITHER mining input and its negative has BOTH, so every one of the guard's parts
// could be deleted and the pair still passed. Planted separately, three of them survived the whole package —
// dropping the pack half of the disjunction, dropping the contrast half, and dropping `forWrite` entirely.
//
// The guard is `forWrite && banknote.Enabled && (pack == nil || contrast == "")`. A disjunction pinned only
// at "neither" and "both" is pinned at neither of its arms.
func TestTheBanknoteWarningKeysOnEachConditionSeparately(t *testing.T) {
packRoot, err := filepath.Abs(filepath.Join("..", "..", "configs", "langpacks"))
if err != nil {
t.Fatal(err)
}
open := func(t *testing.T, bookPath string, readOnly bool) string {
t.Helper()
var log bytes.Buffer
h := slog.New(slog.NewTextHandler(&log, &slog.HandlerOptions{Level: slog.LevelInfo}))
var r *Runner
var oerr error
if readOnly {
r, oerr = NewReadOnlyRunner(bookPath, h)
} else {
r, oerr = NewRunner(bookPath, h)
}
if oerr != nil {
t.Fatalf("this configuration is legal and must open — it is wasteful, not broken: %v", oerr)
}
r.Close()
return log.String()
}
// ARM 1 — a pack, but no contrast artifact. The bank-mining stop returns before reading anything, so
// the banknote tokens are bought and unread; the pack half of the disjunction must not be what decides.
t.Run("a pack but no contrast artifact", func(t *testing.T) {
srv := newJSONProvider(&reqRec{}, draftEdit)
defer srv.Close()
bookPath := setupProjectOpts(t, srv.URL, projectOpts{banknote: true})
raw, rerr := os.ReadFile(bookPath)
if rerr != nil {
t.Fatal(rerr)
}
body := strings.Replace(string(raw), "source_lang: ja", "source_lang: zh\nlangpack_root: "+packRoot, 1)
if !strings.Contains(body, "langpack_root:") {
t.Fatalf("premise broken: the fixture's book.yaml no longer carries the anchor this test edits:\n%s", raw)
}
writeFile(t, bookPath, body)
if out := open(t, bookPath, false); !strings.Contains(out, "banknote channel is ON") {
t.Errorf("a book with a pack and NO contrast artifact still cannot reach the stop, and still pays "+
"for the term table on every draft call — it must be warned:\n%s", out)
}
})
// ARM 2 — a contrast artifact, but no pack. The mirror image, and the one a config loader structurally
// cannot see: the pack is book data, not pipeline data.
t.Run("a contrast artifact but no pack", func(t *testing.T) {
srv := newJSONProvider(&reqRec{}, draftEdit)
defer srv.Close()
bookPath := setupProjectOpts(t, srv.URL, projectOpts{banknote: true,
gatesYAML: "\nmining:\n contrast_path: mining-contrast.txt\n"})
dir := filepath.Dir(bookPath)
writeFile(t, filepath.Join(dir, "mining-contrast.txt"), miningContrastData)
if out := open(t, bookPath, false); !strings.Contains(out, "banknote channel is ON") {
t.Errorf("a book with a contrast artifact and NO langpack cannot mine either, and must be warned:\n%s", out)
}
})
// ARM 3 — `forWrite`. A $0 read path buys nothing, so warning there teaches an operator to ignore the
// line on the run where it matters. Planted, dropping the guard entirely stayed green.
t.Run("a read-only open is quiet", func(t *testing.T) {
srv := newJSONProvider(&reqRec{}, draftEdit)
defer srv.Close()
bookPath := setupProjectOpts(t, srv.URL, projectOpts{banknote: true})
// Premise: this very book WOULD be warned on a write open — otherwise the silence below proves nothing.
if out := open(t, bookPath, false); !strings.Contains(out, "banknote channel is ON") {
t.Fatalf("premise broken: the write open must warn for this fixture:\n%s", out)
}
if out := open(t, bookPath, true); strings.Contains(out, "banknote channel is ON") {
t.Errorf("a read-only open spends nothing, so it must not warn about what a write run would buy:\n%s", out)
}
})
}