textmachine/backend/cmd/tmctl/invocation_test.go

363 lines
16 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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. `build` is the seventh
// (backlog row 236 — the reader's copy of the book, EPUB/text, beside the database). 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|build|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)
}
}