textmachine/backend/cmd/tmctl/invocation_test.go

110 lines
4 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"
"strings"
"testing"
"textmachine/backend/internal/pipeline"
)
// invocation_test.go pins the frozen CLI parsing contract (пакет №4): exit-код 2
// эксклюзивен для completed-with-flags (плохой флаг → 1), порядок валидации,
// селектор redrive, приёмник диагностики стдлибовского flag.
func TestParseNoArgsUsage(t *testing.T) {
_, err := parseInvocation(nil, &bytes.Buffer{})
// The command list gained `export` (D39 слой 6 read-only surface, a deliberate contract extension);
// the rest of the usage text stays frozen.
if err == nil || err.Error() != "usage: tmctl <translate|report|status|export|redrive> --config book.yaml" {
t.Fatalf("usage error text is frozen, got: %v", err)
}
}
func TestParseMissingConfig(t *testing.T) {
// Порядок валидации заморожен: `tmctl bogus` без --config жалуется на config,
// НЕ на неизвестную команду (та ловится позже, в dispatch-свиче run()).
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 (класс регресса 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")
}
}
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")
}
}