110 lines
4 KiB
Go
110 lines
4 KiB
Go
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")
|
||
}
|
||
}
|