textmachine/backend/cmd/tmctl/render_test.go

292 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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, '<27>') {
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())
}
}