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