textmachine/platform/internal/runner/bankapply_live_test.go

141 lines
4.9 KiB
Go

package runner
import (
"os"
"path/filepath"
"slices"
"testing"
"gopkg.in/yaml.v3"
"textmachine/platform/internal/ingest"
)
// `--dry-run` is the whole of what separates a preview from a write, so its presence is pinned at
// the argv — the one place a dropped flag is a one-token diff.
//
// Mutation caught: passing preview through without the flag (every call becomes a write), or
// passing the flag unconditionally (every write becomes a no-op).
func TestAPreviewIsTheDryRunFlagAndNothingElse(t *testing.T) {
if !slices.Contains(BankApplyArgs("/w", "/d.json", true), "--dry-run") {
t.Error("a preview call carries no --dry-run: it would WRITE")
}
if slices.Contains(BankApplyArgs("/w", "/d.json", false), "--dry-run") {
t.Error("an apply call carries --dry-run: it would never write")
}
}
// The canon's central promise about the preview, proven against the REAL engine: «"preview": true
// answers the same receipt without changing anything» (§applyBankCorrections). A regress of one
// constant turns the safe look into an irreversible write, and no unit test can make this claim —
// what writes is the engine, so the engine is what must be seen not writing.
//
// Gated like every live-engine test: a bare clone stays green and says why.
func TestALivePreviewWritesNothingAndALiveApplyWrites(t *testing.T) {
bin := os.Getenv("TM_PLATFORM_TEST_ENGINE_BIN")
tpl := os.Getenv("TM_PLATFORM_TEST_BOOK_TEMPLATE")
if bin == "" || tpl == "" {
t.Skip("TM_PLATFORM_TEST_ENGINE_BIN and TM_PLATFORM_TEST_BOOK_TEMPLATE not set: " +
"the preview's no-write promise is not checked against a real engine")
}
dir := t.TempDir()
writeProbeBook(t, tpl, dir, "bk_PREVIEWPROBE")
doc, err := ingest.EncodeDecisions("bk_PREVIEWPROBE", []ingest.BankDecision{
{Action: "approve", Src: "测试", Sense: "", Dst: "проба", Note: "preview pin"},
})
if err != nil {
t.Fatal(err)
}
decisions := filepath.Join(t.TempDir(), "decisions.json")
if err := os.WriteFile(decisions, doc, 0o600); err != nil {
t.Fatal(err)
}
before := dirListing(t, dir)
rn := New(nil)
out, err := rn.BankApply(t.Context(), bin, dir, decisions, true)
if err != nil || !out.Exited || out.ExitCode != 0 || !out.Decoded {
t.Fatalf("the live preview did not answer: %+v (%v; stderr %q)", out, err, out.Stderr)
}
if out.Report.Mode != "projection" || !out.Report.Changed {
t.Fatalf("the preview's report: mode %q changed %v, want a projection that WOULD change",
out.Report.Mode, out.Report.Changed)
}
// Nothing appeared and nothing moved — except the lock file, which the verb's own contract
// names: a projection computed without the arbiter is a projection of nothing.
after := dirListing(t, dir)
for name, sum := range after {
if filepath.Ext(name) == ".lock" {
continue
}
if prev, ok := before[name]; !ok {
t.Errorf("the preview CREATED %s", name)
} else if prev != sum {
t.Errorf("the preview CHANGED %s", name)
}
}
// The same document applied for real is the counter-proof: the engine both can and does write
// here, so the silence above was the preview's doing and not a fixture that cannot write.
out, err = rn.BankApply(t.Context(), bin, dir, decisions, false)
if err != nil || out.ExitCode != 0 || !out.Decoded || out.Report.Mode != "apply" || !out.Report.Changed {
t.Fatalf("the live apply did not land: %+v (%v; stderr %q)", out, err, out.Stderr)
}
applied := dirListing(t, dir)
grew := false
for name := range applied {
if _, ok := after[name]; !ok && filepath.Ext(name) != ".lock" {
grew = true
}
}
if !grew {
t.Error("the apply wrote no decision file: this fixture proves nothing about the preview")
}
}
// writeProbeBook renders a minimal live book: the deployment template with the identity, languages
// and source the intake would have filled in (the four keys books.Service.provision sets).
func writeProbeBook(t *testing.T, tpl, dir, bookID string) {
t.Helper()
raw, err := os.ReadFile(tpl)
if err != nil {
t.Fatal(err)
}
var cfg map[string]any
if err := yaml.Unmarshal(raw, &cfg); err != nil {
t.Fatal(err)
}
cfg["book_id"] = bookID
cfg["source_lang"] = "zh"
cfg["target_lang"] = "ru"
cfg["source_file"] = "source.txt"
out, err := yaml.Marshal(cfg)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, ConfigFile), out, 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "source.txt"), []byte("第1章 测试\n这是一个测试文本。\n"), 0o600); err != nil {
t.Fatal(err)
}
}
// dirListing is the book directory as facts: every file name with a digest of its bytes.
func dirListing(t *testing.T, dir string) map[string]string {
t.Helper()
out := map[string]string{}
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatal(err)
}
for _, e := range entries {
if e.IsDir() {
continue
}
b, err := os.ReadFile(filepath.Join(dir, e.Name()))
if err != nil {
t.Fatal(err)
}
out[e.Name()] = string(rune(len(b))) + "-" + string(b[:min(len(b), 64)])
}
return out
}