153 lines
6.7 KiB
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")
|
|
}
|
|
}
|