textmachine/backend/cmd/tmctl/invocation_test.go

239 lines
10 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"
"io"
"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) and then `manifest` (backlog row 100
// — the $0 producer of the chapter/chunk manifest); both are deliberate contract extensions and the
// rest of the usage text stays frozen.
if err == nil || err.Error() != "usage: tmctl <translate|report|status|export|redrive|manifest> --config book.yaml" {
t.Fatalf("usage error text is frozen, got: %v", err)
}
}
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)
}
}
}