167 lines
6.6 KiB
Go
167 lines
6.6 KiB
Go
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)
|
||
}
|
||
}
|
||
}
|