textmachine/backend/cmd/tmctl/render_test.go

226 lines
10 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 (
"encoding/json"
"errors"
"strings"
"testing"
"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
// (пакет №4): sentinel-возвраты (exit 2), советы «что делать», секции только при
// непустых данных, err-хвост report, 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{
"=== ГЛАВА 1 ЧАНК 0 — ok ===", "ТЕКСТ",
"ИТОГО (этот запуск): $0.010000 — чанков 1, флагов 0",
"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(), "[ФЛАГ soft_refusal]") {
t.Fatalf("flagged chunk banner missing:\n%s", b.String())
}
}
func TestRenderTranslateLedgerErrorAfterTotals(t *testing.T) {
// Замороженный порядок частичного вывода: итоговая строка уже напечатана,
// ошибка чтения ledger обрывает ПОСЛЕ неё (подъём чтения до рендера менял бы
// байты частичного вывода — риск-карта извлечения, п.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(), "ИТОГО") || strings.Contains(b.String(), "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()
// Пакет №4: у упавшего вызова model_actual пуст — строка обязана нести
// запрошенную модель, позицию и текст ошибки (пост-мортем без 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)
}
// Секции флагов/памяти не печатаются без данных.
if strings.Contains(out, "ФЛАГИ") || strings.Contains(out, "ПАМЯТЬ") {
t.Fatalf("empty sections must not print headers:\n%s", out)
}
}
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{"=== ФЛАГИ (disposition ≠ ok) ===", "hard_refusal",
"=== ПАМЯТЬ (retrieval-state) ===", "post-check-промахов=1", `[{"src":"鈴木"}]`,
"— всего 2 ===", "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) {
// Замороженное поведение (селфревью №4): чтения store интерливятся с печатью,
// провал ПОСЛЕ первой секции оставляет её на stdout (аудит не «пустеет» молча).
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", TotalChunks: 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{TotalChunks: 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", TotalChunks: 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",
// Совет расщеплён (minor 1d): re-drivable отдельно от glossary_miss (redrive там no-op).
"2 флагнутых чанк(ов) ждут внимания — переатаковать: tmctl redrive --config book.yaml",
"1 чанк(ов) флагнуты post-check-гейтом (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: ничего не сброшено, вызовов не сделано]") {
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(), "нечего переатаковать") {
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) — замороженное поведение.
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(), "Ledger книги") {
t.Fatalf("flaky ledger read must swallow the ledger line, not fail:\n%s", b.String())
}
}