141 lines
4.9 KiB
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
|
|
}
|