362 lines
16 KiB
Go
362 lines
16 KiB
Go
package main
|
||
|
||
import (
|
||
"bytes"
|
||
"errors"
|
||
"fmt"
|
||
"go/ast"
|
||
"go/parser"
|
||
"go/token"
|
||
"io"
|
||
"strconv"
|
||
"strings"
|
||
"testing"
|
||
|
||
"textmachine/backend/internal/pipeline"
|
||
)
|
||
|
||
// invocation_test.go pins the frozen CLI parsing contract (package №4): exit code 2
|
||
// is exclusive to completed-with-flags (a bad flag → 1), validation order,
|
||
// the redrive selector, the stdlib flag's diagnostics sink.
|
||
|
||
func TestParseNoArgsUsage(t *testing.T) {
|
||
_, err := parseInvocation(nil, &bytes.Buffer{})
|
||
// The command list gained `export` (D39 слой 6 read-only surface), then `manifest` (backlog row 100
|
||
// — the $0 producer of the chapter/chunk manifest), and then the three the line had silently never
|
||
// learned: `backup`/`migrate` (the deploy step, row 174) and `seed-lint`, which gets its own clause
|
||
// because it takes --seed rather than --config (row 176, sanctioned by D39.134 п.3). `bank-apply` is
|
||
// the sixth (D39.156 — the inbound door for the owner's bank decisions) and gets its own clause for
|
||
// the same reason seed-lint does: it needs --decisions on top of --config. Every one is a deliberate
|
||
// contract extension and the rest of the usage text stays frozen.
|
||
if err == nil || err.Error() != "usage: tmctl <translate|report|status|export|redrive|manifest|backup|migrate> --config book.yaml | tmctl bank-apply --config book.yaml --decisions decisions.json | tmctl seed-lint --seed glossary.yaml" {
|
||
t.Fatalf("usage error text is frozen, got: %v", err)
|
||
}
|
||
// The line and the dispatch switch are the same list: a command reachable in run() and missing here
|
||
// is exactly the defect row 176 recorded, and it grew back three times.
|
||
for _, cmd := range dispatchCommands {
|
||
if !strings.Contains(err.Error(), cmd) {
|
||
t.Errorf("the usage line must name every dispatchable command; %q is missing from: %s", cmd, err.Error())
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestParseMissingConfig(t *testing.T) {
|
||
// Validation order is frozen: `tmctl bogus` without --config complains about config,
|
||
// NOT about the unknown command (that's caught later, in run()'s dispatch switch).
|
||
for _, args := range [][]string{{"translate"}, {"bogus"}} {
|
||
_, err := parseInvocation(args, &bytes.Buffer{})
|
||
if err == nil || err.Error() != "--config book.yaml is required" {
|
||
t.Fatalf("args %v: missing-config error text is frozen, got: %v", args, err)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestParseBadFlagIsExit1NotExit2(t *testing.T) {
|
||
var diag bytes.Buffer
|
||
_, err := parseInvocation([]string{"status", "--no-such-flag"}, &diag)
|
||
if err == nil {
|
||
t.Fatal("a bad flag must be an error")
|
||
}
|
||
// The collision guard: a parse failure maps to exit 1 — NEVER to 2, which is
|
||
// exclusive to completed-with-flags (the reason for ContinueOnError).
|
||
if code := exitCode(err); code != 1 {
|
||
t.Fatalf("bad flag must exit 1, got %d", code)
|
||
}
|
||
// The stdlib's diagnostics ("flag provided but not defined" + usage) must land
|
||
// in the provided writer (main passes os.Stderr).
|
||
if !strings.Contains(diag.String(), "flag provided but not defined") {
|
||
t.Fatalf("flag diagnostics must go to the provided writer, got: %q", diag.String())
|
||
}
|
||
}
|
||
|
||
func TestParseUnknownCommandPassesThrough(t *testing.T) {
|
||
inv, err := parseInvocation([]string{"bogus", "--config", "b.yaml"}, &bytes.Buffer{})
|
||
if err != nil {
|
||
t.Fatalf("command name is validated in the dispatch switch, not the parser: %v", err)
|
||
}
|
||
if inv.cmd != "bogus" || inv.cfgPath != "b.yaml" {
|
||
t.Fatalf("inv = %+v", inv)
|
||
}
|
||
}
|
||
|
||
func TestParseRedriveSelectorDefaults(t *testing.T) {
|
||
inv, err := parseInvocation([]string{"redrive", "--config", "b.yaml"}, &bytes.Buffer{})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
want := pipeline.RedriveSelector{Chapter: -1, ChunkIdx: -1, Reason: "", DryRun: false}
|
||
if inv.sel != want {
|
||
t.Fatalf("default selector must be «any» (-1/-1/\"\"/false), got %+v", inv.sel)
|
||
}
|
||
if inv.resnapshot {
|
||
t.Fatal("resnapshot must default to false")
|
||
}
|
||
}
|
||
|
||
func TestParseRedriveSelectorExplicit(t *testing.T) {
|
||
inv, err := parseInvocation([]string{"redrive", "--config", "b.yaml",
|
||
"--chapter", "3", "--chunk", "7", "--reason", "soft_refusal", "--dry-run", "--resnapshot"}, &bytes.Buffer{})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
want := pipeline.RedriveSelector{Chapter: 3, ChunkIdx: 7, Reason: "soft_refusal", DryRun: true}
|
||
if inv.sel != want {
|
||
t.Fatalf("selector wiring lost a flag (regression class D20.4 «parsed but ignored»): %+v", inv.sel)
|
||
}
|
||
// D20.4 regression class: --resnapshot was once parsed but never wired.
|
||
if !inv.resnapshot {
|
||
t.Fatal("--resnapshot must reach the invocation")
|
||
}
|
||
}
|
||
|
||
// TestParseAcceptRebillForms pins the OPTIONAL-VALUE contract of --accept-rebill[=usd] (D20.2-Q2):
|
||
// bare = consent to the whole projected amount, `=usd` = consent only up to that ceiling, absent =
|
||
// no consent. The three states are distinct because the gate treats them differently.
|
||
func TestParseAcceptRebillForms(t *testing.T) {
|
||
for _, tc := range []struct {
|
||
args []string
|
||
want pipeline.RebillConsent
|
||
}{
|
||
{[]string{"translate", "--config", "b.yaml"}, pipeline.RebillConsent{}},
|
||
{[]string{"translate", "--config", "b.yaml", "--accept-rebill"}, pipeline.RebillConsent{Given: true}},
|
||
{[]string{"translate", "--config", "b.yaml", "--accept-rebill=1.50"}, pipeline.RebillConsent{Given: true, Capped: true, CapUSD: 1.50}},
|
||
// An explicit ZERO ceiling is a real answer ("only if it costs nothing"), not "no flag".
|
||
{[]string{"translate", "--config", "b.yaml", "--accept-rebill=0"}, pipeline.RebillConsent{Given: true, Capped: true, CapUSD: 0}},
|
||
{[]string{"redrive", "--config", "b.yaml", "--accept-rebill=0.25", "--resnapshot"}, pipeline.RebillConsent{Given: true, Capped: true, CapUSD: 0.25}},
|
||
} {
|
||
inv, err := parseInvocation(tc.args, &bytes.Buffer{})
|
||
if err != nil {
|
||
t.Fatalf("%v: %v", tc.args, err)
|
||
}
|
||
if inv.acceptRebill != tc.want {
|
||
t.Errorf("%v → %+v, want %+v", tc.args, inv.acceptRebill, tc.want)
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestParseAcceptRebillSpaceFormRefused is the money trap of an IsBoolFlag optional value: written with
|
||
// a SPACE the amount does not bind, the flag reads as bare, and a capped consent silently becomes an
|
||
// unlimited one. It must be an error, not a $-sized surprise.
|
||
func TestParseAcceptRebillSpaceFormRefused(t *testing.T) {
|
||
_, err := parseInvocation([]string{"translate", "--config", "b.yaml", "--accept-rebill", "1.50"}, &bytes.Buffer{})
|
||
if err == nil {
|
||
t.Fatal("`--accept-rebill 1.50` must not silently read as an UNLIMITED consent")
|
||
}
|
||
if !strings.Contains(err.Error(), "--accept-rebill=1.50") {
|
||
t.Errorf("the error must show the correct spelling; got: %v", err)
|
||
}
|
||
if code := exitCode(err); code != 1 {
|
||
t.Errorf("a parse error is exit 1, got %d", code)
|
||
}
|
||
// The same stray argument WITHOUT the bare flag stays tolerated (the frozen contract is untouched).
|
||
if _, err := parseInvocation([]string{"translate", "--config", "b.yaml", "stray"}, &bytes.Buffer{}); err != nil {
|
||
t.Errorf("a stray argument outside the --accept-rebill spelling must stay tolerated: %v", err)
|
||
}
|
||
}
|
||
|
||
// TestParseAcceptRebillRejectsBadCeiling: a ceiling that is not a non-negative number is a typo about
|
||
// MONEY — refuse rather than fall back to some default consent.
|
||
func TestParseAcceptRebillRejectsBadCeiling(t *testing.T) {
|
||
for _, bad := range []string{"abc", "-1", "1,50", "NaN", "Inf"} {
|
||
var diag bytes.Buffer
|
||
_, err := parseInvocation([]string{"translate", "--config", "b.yaml", "--accept-rebill=" + bad}, &diag)
|
||
if err == nil {
|
||
t.Errorf("--accept-rebill=%s must be refused", bad)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestExitCodeContract(t *testing.T) {
|
||
if exitCode(nil) != 0 {
|
||
t.Fatal("nil → 0")
|
||
}
|
||
if exitCode(errors.New("boom")) != 1 {
|
||
t.Fatal("generic error → 1")
|
||
}
|
||
sentinel := &pipeline.CompletedWithFlags{Flagged: 2, Total: 5}
|
||
if exitCode(sentinel) != 2 {
|
||
t.Fatal("sentinel → 2")
|
||
}
|
||
// The sentinel must survive %w-wrapping (errors.As, not a type switch).
|
||
if exitCode(fmt.Errorf("outer: %w", sentinel)) != 2 {
|
||
t.Fatal("wrapped sentinel → 2")
|
||
}
|
||
// R1-FL-A: a bank-mining signature stop is a DISTINCT exit code (3), not conflated with a flag (2) or a
|
||
// crash (1), so exit-code automation can tell a sign-boundary pause from a real failure.
|
||
sigStop := &pipeline.WaveSignatureStop{Terms: 4, SignaturePath: "/tmp/book.db.mined-signature.yaml"}
|
||
if exitCode(sigStop) != 3 {
|
||
t.Fatalf("signature-stop sentinel → 3, got %d", exitCode(sigStop))
|
||
}
|
||
if exitCode(fmt.Errorf("outer: %w", sigStop)) != 3 {
|
||
t.Fatal("wrapped signature-stop → 3")
|
||
}
|
||
// The two sentinels must NOT collide: a flag stays 2 even though both are typed sentinels.
|
||
if exitCode(sentinel) == exitCode(sigStop) {
|
||
t.Fatal("CompletedWithFlags and WaveSignatureStop must map to different exit codes")
|
||
}
|
||
}
|
||
|
||
// TestParseVerifyBank pins the pack-20 mode flag: CLI-only, binary, off by default, accepted on the two
|
||
// commands that run the durable loop. Off-by-default IS the contract (D39.42 п.5) — the run carries an
|
||
// unsigned bank forward unless a human asks to be stopped.
|
||
func TestParseVerifyBank(t *testing.T) {
|
||
inv, err := parseInvocation([]string{"translate", "--config", "b.yaml"}, io.Discard)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if inv.verifyBank {
|
||
t.Fatal("the bank stop must be OFF unless asked for — a default pause is what D39.42 removed")
|
||
}
|
||
for _, cmd := range []string{"translate", "redrive"} {
|
||
inv, err := parseInvocation([]string{cmd, "--config", "b.yaml", "--verify-bank"}, io.Discard)
|
||
if err != nil {
|
||
t.Fatalf("%s --verify-bank: %v", cmd, err)
|
||
}
|
||
if !inv.verifyBank {
|
||
t.Fatalf("%s: the flag must be parsed", cmd)
|
||
}
|
||
}
|
||
// It is a BOOL: it never takes a value, so the "optional value" trap that --accept-rebill needed a
|
||
// guard for cannot exist here.
|
||
if _, err := parseInvocation([]string{"translate", "--config", "b.yaml", "--verify-bank=1.5"}, io.Discard); err == nil {
|
||
t.Fatal("--verify-bank must not accept a non-boolean value")
|
||
}
|
||
}
|
||
|
||
// --- backlog row 145: the per-run ceiling flag ----------------------------------------------------
|
||
|
||
// TestParseCeilingUSD pins the flag's whole contract: absent means "the book's own ceiling stands", a
|
||
// positive amount is carried through, and every value that is not a spendable amount is REFUSED rather
|
||
// than defaulted. The refusal is the load-bearing half — Р7 forbids a ledger with no ceiling, and a
|
||
// silently-ignored `--ceiling-usd 0` would be exactly that with a flag in front of it.
|
||
func TestParseCeilingUSD(t *testing.T) {
|
||
inv, err := parseInvocation([]string{"translate", "--config", "b.yaml"}, &bytes.Buffer{})
|
||
if err != nil || inv.ceilingUSD != 0 {
|
||
t.Fatalf("an absent flag must leave the ceiling unset, got %v / %v", inv.ceilingUSD, err)
|
||
}
|
||
inv, err = parseInvocation([]string{"translate", "--config", "b.yaml", "--ceiling-usd=1.25"}, &bytes.Buffer{})
|
||
if err != nil || inv.ceilingUSD != 1.25 {
|
||
t.Fatalf("--ceiling-usd=1.25 → %v / %v", inv.ceilingUSD, err)
|
||
}
|
||
// The space spelling binds too (unlike --accept-rebill, which is a bool-flag with an optional value).
|
||
inv, err = parseInvocation([]string{"redrive", "--config", "b.yaml", "--ceiling-usd", "0.5"}, &bytes.Buffer{})
|
||
if err != nil || inv.ceilingUSD != 0.5 {
|
||
t.Fatalf("--ceiling-usd 0.5 → %v / %v", inv.ceilingUSD, err)
|
||
}
|
||
for _, bad := range []string{"0", "-1", "NaN", "Inf"} {
|
||
var diag bytes.Buffer
|
||
if _, err := parseInvocation([]string{"translate", "--config", "b.yaml", "--ceiling-usd=" + bad}, &diag); err == nil {
|
||
t.Fatalf("--ceiling-usd=%s must be refused: a run ceiling that is not a spendable amount is not a ceiling", bad)
|
||
} else if code := exitCode(err); code != 1 {
|
||
t.Fatalf("--ceiling-usd=%s must exit 1, got %d", bad, code)
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestDispatchCommandsCoversTheSwitch closes the direction the usage-line test cannot see. That test
|
||
// walks dispatchCommands and checks each name appears in the usage string, so DROPPING a command from
|
||
// the list keeps it green while the usage line silently stops mentioning a command tmctl still runs —
|
||
// exactly the defect of row 176, which grew back three times. This reads the dispatch switch itself.
|
||
func TestDispatchCommandsCoversTheSwitch(t *testing.T) {
|
||
fset := token.NewFileSet()
|
||
f, err := parser.ParseFile(fset, "main.go", nil, 0)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
inList := map[string]bool{}
|
||
for _, c := range dispatchCommands {
|
||
inList[c] = true
|
||
}
|
||
found := 0
|
||
ast.Inspect(f, func(n ast.Node) bool {
|
||
sw, ok := n.(*ast.SwitchStmt)
|
||
if !ok {
|
||
return true
|
||
}
|
||
sel, ok := sw.Tag.(*ast.SelectorExpr)
|
||
if !ok || sel.Sel.Name != "cmd" {
|
||
return true
|
||
}
|
||
for _, stmt := range sw.Body.List {
|
||
for _, expr := range stmt.(*ast.CaseClause).List {
|
||
lit, ok := expr.(*ast.BasicLit)
|
||
if !ok || lit.Kind != token.STRING {
|
||
continue
|
||
}
|
||
name, err := strconv.Unquote(lit.Value)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
found++
|
||
if !inList[name] {
|
||
t.Errorf("run() dispatches %q but dispatchCommands does not list it — the usage line will not name it", name)
|
||
}
|
||
}
|
||
}
|
||
return false
|
||
})
|
||
if found != len(dispatchCommands) {
|
||
t.Errorf("the dispatch switch has %d commands, dispatchCommands lists %d — the two must be one list", found, len(dispatchCommands))
|
||
}
|
||
}
|
||
|
||
// TestMaxUnitsIsRefusedWhereItWouldDoNothing pins the volume ceiling onto the rule the other four flags
|
||
// already follow: a command that does not act on a flag REFUSES it rather than ignoring it.
|
||
//
|
||
// The silent version of this one costs book rather than dollars, and `redrive` is the expensive case. It
|
||
// re-attacks the flagged chunks and then runs TranslateBook over the WHOLE book, so `redrive --max-units
|
||
// 10` accepted-and-ignored would bill a caller for every unit the book has left while their own logs said
|
||
// they had capped the work at ten.
|
||
func TestMaxUnitsIsRefusedWhereItWouldDoNothing(t *testing.T) {
|
||
for _, cmd := range dispatchCommands {
|
||
if cmd == "translate" {
|
||
continue
|
||
}
|
||
args := []string{cmd, "--config", "book.yaml", "--max-units", "10"}
|
||
if cmd == "bank-apply" {
|
||
args = append(args, "--decisions", "d.json")
|
||
}
|
||
_, err := parseInvocation(args, &bytes.Buffer{})
|
||
if err == nil {
|
||
t.Errorf("%s accepted --max-units; a flag a command does not act on must be refused, never ignored", cmd)
|
||
continue
|
||
}
|
||
if !strings.Contains(err.Error(), "--max-units is accepted by `translate` only") {
|
||
t.Errorf("%s: the refusal must name the flag and the one verb that takes it, got: %v", cmd, err)
|
||
}
|
||
// And the REASON must be true of the command it addresses — the discipline the --keys-file
|
||
// refusal already carries a test for. The first draft of this refusal told every command it
|
||
// "does not run a book wave whose volume this could bound", which is false of `redrive`: it
|
||
// calls TranslateBook after its reset. A refusal that misdescribes its reader teaches them the
|
||
// wrong thing about the engine, and the next session to wire this flag inherits it.
|
||
if strings.Contains(err.Error(), "neither runs nor pays for them") && cmd == "redrive" {
|
||
t.Errorf("redrive DOES drive a book wave (it calls TranslateBook); its refusal must not claim otherwise, got: %v", err)
|
||
}
|
||
}
|
||
// redrive gets its own reason, and that reason has to say the true thing.
|
||
_, err := parseInvocation([]string{"redrive", "--config", "book.yaml", "--max-units", "10"}, &bytes.Buffer{})
|
||
if err == nil || !strings.Contains(err.Error(), "redrive DOES drive a book wave") {
|
||
t.Fatalf("redrive's refusal must name the real reason (the reset is bounded by its selector, not by units), got: %v", err)
|
||
}
|
||
}
|
||
|
||
// TestMaxUnitsRefusesANonVolume pins the same "«off» is not a state" rule --ceiling-usd has needed since
|
||
// D39.110: a PRESENT flag with a meaningless value is a refusal, because the live trigger is an unset
|
||
// variable in a deployment's unit file, and defaulting it to «unbounded» would hand a caller a whole book
|
||
// while their own configuration said ten units.
|
||
func TestMaxUnitsRefusesANonVolume(t *testing.T) {
|
||
for _, v := range []string{"0", "-1"} {
|
||
_, err := parseInvocation([]string{"translate", "--config", "book.yaml", "--max-units", v}, &bytes.Buffer{})
|
||
if err == nil || !strings.Contains(err.Error(), "--max-units must be a positive number of output units") {
|
||
t.Fatalf("--max-units %s: want a refusal naming the rule, got %v", v, err)
|
||
}
|
||
}
|
||
// Absent is the ONE spelling that means "the whole book", and it must stay silent.
|
||
inv, err := parseInvocation([]string{"translate", "--config", "book.yaml"}, &bytes.Buffer{})
|
||
if err != nil {
|
||
t.Fatalf("an absent --max-units must parse cleanly: %v", err)
|
||
}
|
||
if inv.maxUnits != 0 {
|
||
t.Fatalf("an absent --max-units must leave the run unbounded, got %d", inv.maxUnits)
|
||
}
|
||
}
|