textmachine/platform/internal/runner/manifest_test.go

153 lines
6.7 KiB
Go

package runner
import (
"bytes"
"errors"
"go/parser"
"go/printer"
"go/token"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
// The intake channel is the one engine command the platform runs before a book has ever been
// translated. These tests run a REAL process — a shell script standing in for tmctl — because what
// the caller decides on is the difference between "the engine answered no" and "the engine could not
// be run", and that difference is a type from os/exec rather than a value the platform chooses.
// fakeEngine writes an executable that behaves as told and returns its path.
func fakeEngine(t *testing.T, script string) string {
t.Helper()
path := filepath.Join(t.TempDir(), "tmctl")
if err := os.WriteFile(path, []byte("#!/bin/sh\n"+script+"\n"), 0o755); err != nil {
t.Fatal(err)
}
return path
}
func TestTheManifestCommandIsTheZeroCostOneAndCarriesTheBooksConfig(t *testing.T) {
got := ManifestArgs("/srv/books/bk_1")
want := []string{"manifest", "--config", "/srv/books/bk_1/book.yaml", "--json"}
if strings.Join(got, " ") != strings.Join(want, " ") {
t.Fatalf("argv %v, want %v", got, want)
}
}
func TestAManifestIsReadForTheFourThingsIntakeDecidesWith(t *testing.T) {
// A document with far more in it than the platform takes: the per-chapter and per-unit arrays are
// the engine's and have no reader on this side.
bin := fakeEngine(t, `cat <<'JSON'
{"manifest_version":"tm-manifest-v2","book_id":"gu","key":"abcdef",
"chunker_version":"chunk-2026.07","source_sha256":"`+strings.Repeat("ab", 32)+`",
"source_bytes":24117248,"source_lang":"zh","target_lang":"ru","encoding":"utf-8",
"chapters_total":2283,"units_total":4402,"chunks_total":9130,
"chapters":[{"id":"0011223344556677","number":1,"heading":"Глава 1","units_total":2,
"chunks_total":4,"units":[{"id":"0011223344556677:aabbccdd:0","first_chunk_idx":0,
"chunk_count":2,"edit_unit_id":1}]}]}
JSON`)
r := New(nil)
m, err := r.Manifest(t.Context(), bin, t.TempDir())
if err != nil {
t.Fatal(err)
}
if m.ChaptersTotal != 2283 || m.UnitsTotal != 4402 {
t.Errorf("counts %d/%d", m.ChaptersTotal, m.UnitsTotal)
}
if m.Version != "tm-manifest-v2" || m.ChunkerVersion != "chunk-2026.07" {
t.Errorf("identity %q/%q", m.Version, m.ChunkerVersion)
}
if len(m.SourceSHA256Bytes()) != 32 {
t.Errorf("the source digest did not decode: %q", m.SourceSHA256)
}
}
// The classifier the intake keys on: a process that RAN and refused carries *exec.ExitError, and one
// that could not be run does not. Getting this backwards either retries a book forever or rejects a
// user's file because a host was misconfigured.
func TestARefusalAndAnAbsenceAreDifferentErrors(t *testing.T) {
r := New(nil)
refused := fakeEngine(t, `echo "chunk: ingest txt: not a book" >&2; exit 1`)
_, err := r.Manifest(t.Context(), refused, t.TempDir())
var exit *exec.ExitError
if !errors.As(err, &exit) {
t.Fatalf("a refusal came back as %T: %v", err, err)
}
_, err = r.Manifest(t.Context(), filepath.Join(t.TempDir(), "no-such-binary"), t.TempDir())
if errors.As(err, &exit) {
t.Fatalf("a missing binary came back as an exit status: %v", err)
}
// The engine's own words stay out of the platform's error, which is what a product phrase is
// eventually written from (contract §Problem).
if _, err := r.Manifest(t.Context(), refused, t.TempDir()); !strings.Contains(err.Error(), "tmctl manifest") {
t.Errorf("the error does not name what failed: %v", err)
}
}
func TestAManifestThatIsNotJSONIsAnError(t *testing.T) {
r := New(nil)
bin := fakeEngine(t, `echo "=== MANIFEST: gu ==="`)
if _, err := r.Manifest(t.Context(), bin, t.TempDir()); err == nil {
t.Fatal("a human summary was accepted as a manifest document")
}
}
// FP5-7 (acceptance): the gate on these tests must ask whether this host CAN run them, not whether
// systemd phrased its refusal the way it did in 2026. It skipped on "Failed to connect to bus" and
// systemd 259 answers "Failed to connect to user scope bus", so on a host with no user manager the
// tests failed instead of skipping — a gate that stopped gating when somebody else edited a string.
func TestTheSystemdGateAsksAboutTheCapabilityAndNotAMessage(t *testing.T) {
src, err := os.ReadFile("systemd_test.go")
if err != nil {
t.Fatal(err)
}
// The CODE only: the comments there explain the history of this very defect and quote the message
// the gate used to match, which a plain text scan would read as the defect itself.
file, err := parser.ParseFile(token.NewFileSet(), "systemd_test.go", src, 0)
if err != nil {
t.Fatal(err)
}
var code bytes.Buffer
if err := printer.Fprint(&code, token.NewFileSet(), file); err != nil {
t.Fatal(err)
}
// ⚠ The assertion is on the WORDS systemd could change, not on one spelling of the old check:
// pinning two exact expressions is a gate that a third spelling walks straight past (found by
// cross-family review of the acceptance dofix). Any occurrence of systemd's refusal text in this
// file means the gate is reading messages again.
if bytes.Contains(code.Bytes(), []byte("Failed to connect")) ||
bytes.Contains(code.Bytes(), []byte("No medium found")) {
t.Fatal("the systemd gate matches a message again: it must skip on the failure to reach the manager, whatever systemd calls it")
}
if !bytes.Contains(code.Bytes(), []byte("systemctl")) {
t.Fatal("the systemd gate no longer asks the manager anything: a gate that probes nothing cannot know whether these tests can run")
}
}
// The intake channel reads the same rule about exit 2 as the settlement channel: the command
// completed and something needs a human. Today's engine cannot produce it from `manifest` — the
// flagged sentinel has no path through renderManifest — and that is exactly why this is pinned here
// rather than left as a comment: an engine that later flags something during a cut would otherwise
// spend every book's intake budget on a document it had already printed (seam lens of the dofix
// review).
func TestAManifestThatCompletesWithFlagsIsStillAManifest(t *testing.T) {
bin := fakeEngine(t, `cat <<'JSON'
{"manifest_version":"tm-manifest-v2","chapters_total":12,"units_total":24}
JSON
echo "tmctl: 1 unit needs attention" >&2
exit 2`)
m, err := New(nil).Manifest(t.Context(), bin, t.TempDir())
if err != nil {
t.Fatalf("a manifest printed with the flagged disposition was refused: %v", err)
}
if m.ChaptersTotal != 12 {
t.Errorf("manifest = %+v", m)
}
// And the refusals stay refusals.
refused := fakeEngine(t, `echo '{"manifest_version":"tm-manifest-v2","chapters_total":12}'; exit 11`)
if _, err := New(nil).Manifest(t.Context(), refused, t.TempDir()); err == nil {
t.Error("exit 11 with a document on stdout was read as an answer")
}
}