package main import ( "bytes" "encoding/json" "errors" "fmt" "strings" "testing" "unicode/utf8" "textmachine/backend/internal/llm" "textmachine/backend/internal/pipeline" "textmachine/backend/internal/store" ) // render_test.go pins the human/JSON output contracts of the extracted renders // (package №4): sentinel returns (exit 2), «what to do» advice, sections only when // data is non-empty, report err-tail, dry-run redrive. func okLedger() (float64, float64, error) { return 0.123456, 0, nil } func TestRenderTranslateOKAndLedger(t *testing.T) { var b strings.Builder res := &pipeline.BookResult{BookID: "b1", TotalUSD: 0.01, Chunks: []pipeline.ChunkOutcome{{ Chapter: 1, ChunkIdx: 0, Disposition: pipeline.DispOK, FinalText: "ТЕКСТ", Stages: []pipeline.StageResult{{Stage: "draft", Model: "m", Disposition: pipeline.DispOK, Usage: llm.Usage{PromptTokens: 10, CompletionTokens: 5}, Attempts: 1, FinishReason: "stop"}}, }}} if err := renderTranslate(&b, res, okLedger); err != nil { t.Fatal(err) } out := b.String() for _, want := range []string{ "=== CHAPTER 1 CHUNK 0 — ok ===", "ТЕКСТ", "TOTAL (this run): $0.010000 — chunks 1, flags 0", "Book ledger: committed=$0.123456 reserved=$0.000000", } { if !strings.Contains(out, want) { t.Fatalf("translate output must contain %q, got:\n%s", want, out) } } } func TestRenderTranslateFlaggedSentinel(t *testing.T) { var b strings.Builder res := &pipeline.BookResult{BookID: "b1", Flagged: 1, Chunks: []pipeline.ChunkOutcome{{ Chapter: 2, ChunkIdx: 1, Disposition: pipeline.DispFlagged, FlagReason: pipeline.FlagSoftRefusal, }}} err := renderTranslate(&b, res, okLedger) var flagged *pipeline.CompletedWithFlags if !errors.As(err, &flagged) || flagged.Flagged != 1 { t.Fatalf("flagged run must return the exit-2 sentinel, got: %v", err) } if !strings.Contains(b.String(), "[FLAG soft_refusal]") { t.Fatalf("flagged chunk banner missing:\n%s", b.String()) } } func TestRenderTranslateLedgerErrorAfterTotals(t *testing.T) { // Frozen ordering of the partial output: the totals line is already printed, // a ledger read error aborts AFTER it (hoisting the read ahead of the render would // change the bytes of the partial output — extraction risk-map, item 4). var b strings.Builder res := &pipeline.BookResult{BookID: "b1"} err := renderTranslate(&b, res, func() (float64, float64, error) { return 0, 0, errors.New("boom") }) if err == nil || err.Error() != "boom" { t.Fatalf("ledger error must propagate, got %v", err) } if !strings.Contains(b.String(), "TOTAL") || strings.Contains(b.String(), "Book ledger") { t.Fatalf("totals must be printed, ledger line must not:\n%s", b.String()) } } func TestRenderReportColumnsAndErrTail(t *testing.T) { var b strings.Builder rows := []store.RequestLogView{ {TS: "2026-07-10 17:00:00", Chapter: 3, ChunkIdx: 2, Stage: "draft", Role: "translator", ModelRequested: "fake-model", ModelActual: "", Err: "connect: refused", OK: 0}, {TS: "2026-07-10 17:00:05", Chapter: 3, ChunkIdx: 2, Stage: "draft", Role: "translator", ModelRequested: "fake-model", ModelActual: "fake-model", Degraded: "cjk_artifact", OK: 0}, } if err := renderReport(&b, func() ([]store.RequestLogView, error) { return rows, nil }, func() ([]store.ChunkStatus, error) { return nil, nil }, func() ([]store.RetrievalState, error) { return nil, nil }, okLedger); err != nil { t.Fatal(err) } out := b.String() // Package №4: for a failed call model_actual is empty — the row must carry // the requested model, its position, and the error text (post-mortem without sqlite3). if !strings.Contains(out, "fake-model(req)") { t.Fatalf("failed call must show the requested model, got:\n%s", out) } if !strings.Contains(out, "connect: refused") || !strings.Contains(out, "cjk_artifact") { t.Fatalf("err/degraded tail missing:\n%s", out) } // Flag/memory sections are not printed without data. if strings.Contains(out, "FLAGS") || strings.Contains(out, "MEMORY") { t.Fatalf("empty sections must not print headers:\n%s", out) } } // TestErrTailTruncatesOnRuneBoundary pins the D23.4 fix: errTail carries raw provider // body snippets (CJK/Cyrillic), so a >120-byte tail must be cut on a RUNE boundary — a // bare byte slice s[:120] would split a multibyte rune into invalid UTF-8. The existing // column test does not exercise the boundary (its strings are short/ASCII), so a revert of // the fix survived it — this closes that gap. Mutation: replace the RuneStart walk-back in // errTail with `s = s[:120] + "…"` and this goes red. func TestErrTailTruncatesOnRuneBoundary(t *testing.T) { // One ASCII byte then 100 Cyrillic runes (2 bytes each): rune starts fall at offset 0 // then the ODD offsets, so byte 120 (even) lands on a continuation byte — a mid-rune cut. long := "x" + strings.Repeat("я", 100) // 201 bytes, well over the 120 cap got := errTail("", long) if !utf8.ValidString(got) { t.Fatalf("errTail produced invalid UTF-8 — a byte-boundary cut split a rune: %q", got) } if strings.ContainsRune(got, '�') { t.Fatalf("errTail leaked a replacement char (broken rune): %q", got) } if !strings.HasSuffix(got, "…") { t.Fatalf("a truncated tail must end with the ellipsis, got %q", got) } // A short tail is returned verbatim (degraded + err joined, no truncation, no ellipsis). if s := errTail("cjk_artifact", "boom"); s != "cjk_artifact boom" { t.Fatalf("short tail must be returned verbatim, got %q", s) } } func TestRenderReportSections(t *testing.T) { var b strings.Builder flags := []store.ChunkStatus{ {Chapter: 1, ChunkIdx: 0, Stage: "draft", Disposition: "ok"}, {Chapter: 2, ChunkIdx: 1, Stage: "draft", Disposition: "flagged", FlagReason: "hard_refusal", Attempts: 1, Detail: "provider refused"}, } states := []store.RetrievalState{{Chapter: 2, ChunkIdx: 1, NExactHits: 3, NPostcheckMiss: 1, PostcheckDetail: `[{"src":"鈴木"}]`, NStyleFlags: 2, StyleDetail: `{"yo":2}`}} if err := renderReport(&b, func() ([]store.RequestLogView, error) { return nil, nil }, func() ([]store.ChunkStatus, error) { return flags, nil }, func() ([]store.RetrievalState, error) { return states, nil }, okLedger); err != nil { t.Fatal(err) } out := b.String() for _, want := range []string{"=== FLAGS (disposition ≠ ok) ===", "hard_refusal", "=== MEMORY (retrieval-state) ===", "post-check-misses=1", `[{"src":"鈴木"}]`, "— total 2 ===", "Book ledger"} { if !strings.Contains(out, want) { t.Fatalf("report must contain %q, got:\n%s", want, out) } } if strings.Contains(out, "1 0") && strings.Contains(out, "ok") && strings.Count(out, "draft") > 1 { t.Fatalf("ok rows must not enter the flag section:\n%s", out) } } func TestRenderReportPartialOutputOnMidAuditError(t *testing.T) { // Frozen behavior (self-review №4): store reads are interleaved with printing, // a failure AFTER the first section leaves it on stdout (the audit does not go silently empty). var b strings.Builder rows := []store.RequestLogView{{TS: "t", Chapter: 1, Stage: "draft", Role: "translator", ModelActual: "m", OK: 1}} err := renderReport(&b, func() ([]store.RequestLogView, error) { return rows, nil }, func() ([]store.ChunkStatus, error) { return nil, errors.New("chunk_status corrupted") }, func() ([]store.RetrievalState, error) { t.Fatal("must not be reached"); return nil, nil }, okLedger) if err == nil || err.Error() != "chunk_status corrupted" { t.Fatalf("mid-audit error must propagate, got %v", err) } if !strings.Contains(b.String(), "draft") { t.Fatalf("the request_log table printed before the failure must remain on the writer:\n%s", b.String()) } } func TestRenderStatusJSONSchemaAndSentinel(t *testing.T) { var b strings.Builder rep := &pipeline.StatusReport{BookID: "b1", TotalUnits: 4, Done: 3, Flagged: 1} err := renderStatusJSON(&b, rep) var flagged *pipeline.CompletedWithFlags if !errors.As(err, &flagged) { t.Fatalf("--json with flags must return the exit-2 sentinel (minor 1d), got %v", err) } var decoded map[string]any if jerr := json.Unmarshal([]byte(b.String()), &decoded); jerr != nil { t.Fatalf("output must be valid JSON: %v", jerr) } if decoded["book_id"] != "b1" { t.Fatalf("stable schema field book_id missing: %v", decoded) } // Clean book → nil error → exit 0. if err := renderStatusJSON(&strings.Builder{}, &pipeline.StatusReport{TotalUnits: 4, Done: 4}); err != nil { t.Fatalf("clean book must exit 0, got %v", err) } } func TestRenderStatusHumanDriftAndAdvice(t *testing.T) { var b strings.Builder rep := &pipeline.StatusReport{ BookID: "b1", Snapshot: "abcdef0123456789", TotalUnits: 10, Done: 7, Flagged: 3, GlossaryMissFlagged: 1, SnapshotDrift: true, ConfigDrift: true, } err := renderStatusHuman(&b, rep, "book.yaml") var flagged *pipeline.CompletedWithFlags if !errors.As(err, &flagged) || flagged.Flagged != 3 { t.Fatalf("flagged status must return the exit-2 sentinel, got %v", err) } out := b.String() for _, want := range []string{ "snapshot abcdef012345", "⚠ SNAPSHOT-DRIFT", "⚠ CONFIG-DRIFT", // The advice is split (minor 1d): re-drivable separately from glossary_miss (redrive is a no-op there). "2 flagged chunk(s) need attention — re-attack: tmctl redrive --config book.yaml", "1 chunk(s) flagged by the post-check gate (glossary_miss)", } { if !strings.Contains(out, want) { t.Fatalf("status must contain %q, got:\n%s", want, out) } } } func TestRenderRedriveDryRunAndSentinel(t *testing.T) { var b strings.Builder sum := &pipeline.RedriveSummary{DryRun: true, Targets: []pipeline.RedriveTarget{ {Chapter: 1, ChunkIdx: 2, FlagReason: "cjk_artifact", Stages: []string{"draft", "edit"}}}} if err := renderRedrive(&b, "b1", sum, nil, okLedger); err != nil { t.Fatalf("dry-run must exit 0, got %v", err) } if !strings.Contains(b.String(), "[dry-run: nothing reset, no calls made]") { t.Fatalf("dry-run banner missing:\n%s", b.String()) } // No targets → informational exit 0. b.Reset() if err := renderRedrive(&b, "b1", &pipeline.RedriveSummary{}, nil, okLedger); err != nil { t.Fatal(err) } if !strings.Contains(b.String(), "nothing to re-attack") { t.Fatalf("no-targets banner missing:\n%s", b.String()) } // Live re-run that still has flags → sentinel; a flaky ledger read is // swallowed (no ledger line, exit code unchanged) — frozen behavior. b.Reset() sum = &pipeline.RedriveSummary{Targets: []pipeline.RedriveTarget{{Chapter: 1, ChunkIdx: 2, FlagReason: "x", Stages: []string{"draft"}}}} res := &pipeline.BookResult{BookID: "b1", Flagged: 1, Chunks: make([]pipeline.ChunkOutcome, 3)} err := renderRedrive(&b, "b1", sum, res, func() (float64, float64, error) { return 0, 0, errors.New("flaky") }) var flagged *pipeline.CompletedWithFlags if !errors.As(err, &flagged) { t.Fatalf("re-run with flags must return the exit-2 sentinel, got %v", err) } if strings.Contains(b.String(), "Book ledger") { t.Fatalf("flaky ledger read must swallow the ledger line, not fail:\n%s", b.String()) } } // TestRenderSignatureStopShowsTheTable pins the operator surface D39.36 specified and the CLI did not // have: the stop prints the term rows (src · proposed dst · origin · freq · spread), the draft variants // that disagree, a source context, both artifact paths, and — when the list is long — how many rows it // did NOT show. A silent truncation would read as "that was the whole bank". func TestRenderSignatureStopShowsTheTable(t *testing.T) { var rows []pipeline.BankStopRow rows = append(rows, pipeline.BankStopRow{ Src: "方源", Dst: "Фан Юань", Origin: "both", Type: "name", Freq: 12, Spread: 2, Variants: []string{"Фан Юань ×3", "Фань Юань ×1"}, Contexts: []string{"方源来到青茅山"}, }) for i := 0; i < 25; i++ { rows = append(rows, pipeline.BankStopRow{Src: fmt.Sprintf("术%d", i), Origin: "mined", Freq: 5}) } var b bytes.Buffer renderSignatureStop(&b, &pipeline.WaveSignatureStop{ Terms: len(rows), SignaturePath: "/tmp/b.db.mined-signature.yaml", TablePath: "/tmp/b.db.bank-stop.txt", Rows: rows, }) out := b.String() for _, want := range []string{ "方源", "Фан Юань", "both", "Фань Юань ×1", "方源来到青茅山", "/tmp/b.db.mined-signature.yaml", "/tmp/b.db.bank-stop.txt", "6 more term(s)", "--verify-bank", } { if !strings.Contains(out, want) { t.Fatalf("the stop banner must carry %q:\n%s", want, out) } } // An unconsolidated term prints an explicit dash, never an empty column that reads as a rendering. var b2 bytes.Buffer renderSignatureStop(&b2, &pipeline.WaveSignatureStop{ Terms: 1, SignaturePath: "p", Rows: []pipeline.BankStopRow{{Src: "花海", Origin: "mined", Freq: 5}}, }) if !strings.Contains(b2.String(), "—") { t.Fatalf("a term with no proposed dst must show a dash:\n%s", b2.String()) } }