textmachine/backend/cmd/tmctl/build_cli_test.go

167 lines
6.6 KiB
Go
Raw Permalink 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"
"encoding/json"
"errors"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
// build_cli_test.go: `tmctl build` at the shell — the flag contract, and the incomplete state driven
// through the REAL binary on the c-lite harness (setupGapProject), because the claim is about the bytes
// a reader gets and the exit code a supervisor gets.
func TestParseBuildFlags(t *testing.T) {
inv, err := parseInvocation([]string{"build", "--config", "b.yaml", "--format", "epub", "--out", "x.epub", "--partial"}, &bytes.Buffer{})
if err != nil {
t.Fatal(err)
}
if inv.cmd != "build" || len(inv.buildFormats) != 1 || inv.buildFormats[0] != "epub" || inv.buildOut != "x.epub" || !inv.buildPartial {
t.Fatalf("parsed: %+v", inv)
}
inv, err = parseInvocation([]string{"build", "--config", "b.yaml"}, &bytes.Buffer{})
if err != nil || inv.buildFormats != nil || inv.buildOut != "" || inv.buildPartial {
t.Fatalf("bare build: %+v %v", inv, err)
}
inv, err = parseInvocation([]string{"build", "--config", "b.yaml", "--format", "txt, epub"}, &bytes.Buffer{})
if err != nil || strings.Join(inv.buildFormats, ",") != "txt,epub" {
t.Fatalf("comma list: %+v %v", inv.buildFormats, err)
}
for name, args := range map[string][]string{
"export's --plaintext": {"build", "--config", "b.yaml", "--plaintext"},
"export's --pairs": {"build", "--config", "b.yaml", "--pairs"},
"status's --json": {"build", "--config", "b.yaml", "--json"},
"unknown format": {"build", "--config", "b.yaml", "--format", "pdf"},
"empty format": {"build", "--config", "b.yaml", "--format", ""},
"out without a format": {"build", "--config", "b.yaml", "--out", "x"},
"out with two formats": {"build", "--config", "b.yaml", "--format", "epub,txt", "--out", "x"},
"empty out": {"build", "--config", "b.yaml", "--format", "epub", "--out", ""},
} {
if _, err := parseInvocation(args, &bytes.Buffer{}); err == nil {
t.Errorf("%s: must be refused", name)
}
}
}
// TestBuildFlagsAreRefusedElsewhere: a command that does not act on a flag refuses it — the rule the
// other flags follow, enumerated from dispatchCommands.
func TestBuildFlagsAreRefusedElsewhere(t *testing.T) {
for _, cmd := range dispatchCommands {
if cmd == "build" {
continue
}
for _, flag := range [][]string{{"--partial"}, {"--format", "epub"}, {"--out", "x"}} {
args := append([]string{cmd, "--config", "b.yaml"}, flag...)
if cmd == "bank-apply" {
args = append(args, "--decisions", "d.json")
}
if cmd == "seed-lint" {
args = append([]string{cmd, "--seed", "s.yaml"}, flag...)
}
_, err := parseInvocation(args, &bytes.Buffer{})
if err == nil {
t.Errorf("%s must refuse %v", cmd, flag)
continue
}
rest := strings.Replace(err.Error(), `"`+cmd+`"`, "", 1)
if !strings.Contains(rest, cmd) && !strings.Contains(rest, "flag provided but not defined") {
t.Errorf("the reason given to `%s` names it only to refuse it: %v", cmd, err)
}
}
}
}
// runTmctlCode is runTmctl without the exit-code assertion: it returns what the process said and how it
// ended, for the commands whose refusal IS the thing under test.
func runTmctlCode(t *testing.T, bookPath string, args ...string) (stdout, stderr string, code int) {
t.Helper()
cmd := exec.Command(buildTmctl(t), append(args, "--config", bookPath)...)
var out, errb bytes.Buffer
cmd.Stdout, cmd.Stderr = &out, &errb
err := cmd.Run()
return out.String(), errb.String(), exitCodeOf(t, err)
}
func TestBuildRefusesTheGapAndPartialMarksIt(t *testing.T) {
const echoMarker = "禁"
srv := gapProvider(t, echoMarker)
bookPath := setupGapProject(t, srv.URL, echoMarker)
dir := filepath.Dir(bookPath)
runTmctl(t, bookPath, "translate")
// The state really is the one under test: pending 0, one INCOMPLETE unit.
var exp map[string]any
if err := json.Unmarshal([]byte(runTmctl(t, bookPath, "export")), &exp); err != nil {
t.Fatal(err)
}
if exp["pending_units"].(float64) != 0 {
t.Fatalf("fixture: pending must be 0: %v", exp)
}
stdout, stderr, code := runTmctlCode(t, bookPath, "build")
if code != exitBookIncomplete {
t.Fatalf("a book with a hole must be refused with exit %d, got %d\nstdout:%s\nstderr:%s", exitBookIncomplete, code, stdout, stderr)
}
if !strings.Contains(stderr, "chapter 1 unit 0: incomplete") || !strings.Contains(stderr, "cjk_artifact") {
t.Errorf("the refusal must name the hole and its cause:\n%s", stderr)
}
for _, f := range []string{"gap-book.db.book.epub", "gap-book.db.book.txt"} {
if _, err := os.Stat(filepath.Join(dir, f)); !errors.Is(err, os.ErrNotExist) {
t.Errorf("a refusal writes nothing, but %s exists (%v)", f, err)
}
}
stdout, stderr, code = runTmctlCode(t, bookPath, "build", "--partial")
if code != 0 {
t.Fatalf("--partial must write: exit %d\nstderr:%s", code, stderr)
}
var rep struct {
Version string `json:"build_version"`
Files map[string]string `json:"files"`
IncompleteUnits int `json:"incomplete_units"`
Complete bool `json:"complete"`
}
if err := json.Unmarshal([]byte(stdout), &rep); err != nil {
t.Fatalf("stdout is not the report: %v\n%s", err, stdout)
}
if rep.Version != "tm-build-v1" || rep.IncompleteUnits != 1 || rep.Complete {
t.Errorf("report: %+v", rep)
}
txt, err := os.ReadFile(rep.Files["txt"])
if err != nil {
t.Fatal(err)
}
shipped := string(txt)
t.Logf("READER'S TEXT:\n%s", shipped)
// No langpack → the non-verbal mark ahead of the surviving text; the edited text is there; the audit
// vocabulary of `export --plaintext` is not.
if !strings.Contains(shipped, "⚠ 1.0 1\n\nОтредактированный текст уцелевшей части.") {
t.Errorf("the mark must stand right before the surviving text:\n%s", shipped)
}
if !strings.HasPrefix(shipped, "Тест\n\n⚠ 1/1\n\n\n1\n\n") {
t.Errorf("the notice must open the book:\n%s", shipped)
}
for _, banned := range []string{"=== CHAPTER", "TEXT MISSING", "INCOMPLETE", "cjk_artifact", "flagged"} {
if strings.Contains(shipped, banned) {
t.Errorf("operator vocabulary %q reached the reader:\n%s", banned, shipped)
}
}
// status publishes the same places the build wrote to.
var st struct {
Artifacts struct {
BookFiles map[string]string `json:"book_files"`
} `json:"artifacts"`
}
if err := json.Unmarshal([]byte(runTmctl(t, bookPath, "status", "--json")), &st); err != nil {
t.Fatal(err)
}
for f, p := range rep.Files {
if st.Artifacts.BookFiles[f] != p {
t.Errorf("status artifacts.book_files[%s] = %q, build wrote %q", f, st.Artifacts.BookFiles[f], p)
}
}
}