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 --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) } } }