281 lines
13 KiB
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)
|
|
}
|
|
})
|
|
}
|