textmachine/backend/cmd/tmctl/render_test.go

590 lines
28 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: []pipeline.BankStopVariant{{Dst: "Фан Юань", Chunks: 3}, {Dst: "Фань Юань", Chunks: 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())
}
}
// TestRenderSignatureStopOrdersTheReviewLeastSureFirst pins the ONE use the role's stated confidence is
// allowed to have (D39.102): it orders the review list, an ordinal inside one model's reply, and nothing
// else. Without it the number is printed and never used — a column that claims to answer «what do I read
// first» and does not. The full sidecar keeps source-key order; only this capped view is the review surface.
func TestRenderSignatureStopOrdersTheReviewLeastSureFirst(t *testing.T) {
rows := []pipeline.BankStopRow{
{Src: "уверенный", Dst: "д", Origin: "mined", Freq: 9, Conf: 95},
{Src: "неконсолидированный", Origin: "mined", Freq: 9, Conf: -1},
{Src: "неуверенный", Dst: "д", Origin: "mined", Freq: 9, Conf: 20},
{Src: "безмолвный", Dst: "д", Origin: "mined", Freq: 9, Conf: -1},
}
var b bytes.Buffer
renderSignatureStop(&b, &pipeline.WaveSignatureStop{Terms: len(rows), SignaturePath: "p", Rows: rows})
out := b.String()
order := []string{"неуверенный", "уверенный", "безмолвный", "неконсолидированный"}
at := -1
for _, name := range order {
i := strings.Index(out, name)
if i < 0 {
t.Fatalf("row %q missing:\n%s", name, out)
}
if i < at {
t.Fatalf("review order broken at %q — want least-confident first, then silence, then unconsolidated:\n%s", name, out)
}
at = i
}
if !strings.Contains(out, "least-confident first") {
t.Fatalf("the banner must say what its order means, or a sorted table reads as arbitrary:\n%s", out)
}
}
// TestRenderTranslateNamesTheVolumeCeiling pins the operator-facing half of the "the stop must NAME its
// ceiling" rule. A volume-bounded run exits 0 like any completed run, so without this line an operator
// looking at "chunks 2" on a hundred-unit book cannot tell "the volume I bought is finished" from
// "something went quietly wrong" — the silent-limit complaint the design took from OpenHands. The line
// has to carry all three actionable numbers and to say it is a completion, not a pause.
func TestRenderTranslateNamesTheVolumeCeiling(t *testing.T) {
var b strings.Builder
res := &pipeline.BookResult{BookID: "b1", TotalUSD: 0.01,
Volume: &pipeline.VolumeStop{MaxUnits: 2, Delivered: 1, Reworked: 1, Free: 3, LeftFresh: 5, LeftRework: 2}}
if err := renderTranslate(&b, res, okLedger); err != nil {
t.Fatal(err)
}
out := b.String()
for _, want := range []string{"VOLUME CEILING", "--max-units 2", "2 paying output unit(s)",
"1 NEW unit(s) delivered", "1 already-delivered unit(s) re-made",
"3 rode along at $0", "5 unit(s) NEVER delivered", "2 already delivered but not yet re-made",
"COMPLETION, not a pause",
// The invitation must be about the units that were never delivered, and only those.
"5 output unit(s) of this book have never been delivered"} {
if !strings.Contains(out, want) {
t.Errorf("the volume stop must say %q; got:\n%s", want, out)
}
}
// A run that was NOT bounded must say nothing at all — otherwise every ordinary run reads as cut short.
var b2 strings.Builder
if err := renderTranslate(&b2, &pipeline.BookResult{BookID: "b1"}, okLedger); err != nil {
t.Fatal(err)
}
if strings.Contains(b2.String(), "VOLUME CEILING") {
t.Errorf("an unbounded run must not mention a ceiling:\n%s", b2.String())
}
}
// TestRenderTranslateNeverSellsWhatIsAlreadyOwned pins the operator-facing half of the same defect. On a
// book every unit of which has been delivered, the stop must not invite a purchase: the units it held
// back are unrefreshed, not unbought, and the first version of this line offered them as "run again with
// --max-units to buy more".
func TestRenderTranslateNeverSellsWhatIsAlreadyOwned(t *testing.T) {
var b strings.Builder
res := &pipeline.BookResult{BookID: "b1",
Volume: &pipeline.VolumeStop{MaxUnits: 1, Delivered: 0, Reworked: 1, LeftFresh: 0, LeftRework: 3}}
if err := renderTranslate(&b, res, okLedger); err != nil {
t.Fatal(err)
}
out := b.String()
if strings.Contains(out, "have never been delivered") {
t.Fatalf("the stop offered chapters the reader already owns:\n%s", out)
}
for _, want := range []string{
"every unit of this book HAS been delivered",
"That is a re-pass, not a purchase of new book",
"delivered NO new unit",
} {
if !strings.Contains(out, want) {
t.Errorf("want %q in:\n%s", want, out)
}
}
}
// TestRenderTranslateReportsBothRemaindersWhenBothExist pins the case an if/else would have swallowed:
// a book with BOTH never-delivered units and delivered-but-superseded ones. Printing only the first
// leaves an operator who has both believing the one number was the whole picture.
func TestRenderTranslateReportsBothRemaindersWhenBothExist(t *testing.T) {
var b strings.Builder
res := &pipeline.BookResult{BookID: "b1",
Volume: &pipeline.VolumeStop{MaxUnits: 2, Delivered: 2, LeftFresh: 4, LeftRework: 6}}
if err := renderTranslate(&b, res, okLedger); err != nil {
t.Fatal(err)
}
out := b.String()
if !strings.Contains(out, "4 output unit(s) of this book have never been delivered") {
t.Errorf("the buyable remainder is missing:\n%s", out)
}
if !strings.Contains(out, "6 already-delivered unit(s) carry a superseded snapshot") {
t.Errorf("the re-make remainder was dropped — an if/else over the two would do exactly this:\n%s", out)
}
// A run that DID deliver must not carry the "delivered NO new unit" warning.
if strings.Contains(out, "delivered NO new unit") {
t.Errorf("this run delivered 2 units; it must not claim otherwise:\n%s", out)
}
}
// TestTheRePaymentHintDoesNotPromiseWhatTheGrantWillNotDo pins the acceptance hunter's finding 6. The
// hint offers rebill_output_units as "the granularity --max-units counts in", which is true and, on its
// own, misleading: the grant goes to NEVER-DELIVERED units first, so on a book that still has any, sizing
// --max-units from the re-payment figure delivers new units and re-makes nothing. The caveat lives in the
// code; it has to live where the operator reads.
func TestTheRePaymentHintDoesNotPromiseWhatTheGrantWillNotDo(t *testing.T) {
var b strings.Builder
rep := &pipeline.StatusReport{BookID: "b1", ConfigDrift: true,
RebillUnits: 9, RebillOutputUnits: 3, RebillUSD: 0.02, RebillBasis: pipeline.RebillBasisPending}
if err := renderStatusHuman(&b, rep, ""); err != nil {
t.Fatal(err)
}
out := b.String()
if !strings.Contains(out, "3 OUTPUT unit(s)") {
t.Fatalf("the sale-sized figure must be shown:\n%s", out)
}
if !strings.Contains(out, "does NOT buy those units back") {
t.Fatalf("the hint must say the grant goes to never-delivered units first, or it promises a re-pass it will not perform:\n%s", out)
}
}
// TestTheRePaymentLineCarriesItsBasis pins the figure's basis on the surface an operator reads: what
// rebill_usd is made of, that it errs upward, and how much of it is priced across a model move or carried
// at last season's money — in the consent gate's own words (StatusReport.RebillFigureBasis), so the status
// line and the refusal `translate` prints describe one figure in one wording (D39.150 п.1, D39.181 п.1).
//
// Mutation this catches: drop RebillFigureBasis() from the RE-PAYMENT line and both assertions fire.
func TestTheRePaymentLineCarriesItsBasis(t *testing.T) {
var b strings.Builder
rep := &pipeline.StatusReport{BookID: "b1", ConfigDrift: true,
RebillUnits: 9, RebillOutputUnits: 3, RebillUSD: 0.02, RebillBasis: pipeline.RebillBasisPending,
RebillHistoricalRows: 2, RebillModelMovedRows: 1}
if err := renderStatusHuman(&b, rep, ""); err != nil {
t.Fatal(err)
}
out := b.String()
if !strings.Contains(out, rep.RebillFigureBasis()) {
t.Fatalf("the RE-PAYMENT line must carry the figure's basis in the gate's own words:\n%s", out)
}
for _, want := range []string{"errs upward", "1 of the 9 unit(s) were bought from a model their stage no longer resolves to", "2 of the 9 unit(s) carry, wholly or in part, the amount they were originally billed at"} {
if !strings.Contains(out, want) {
t.Errorf("the RE-PAYMENT line must say %q:\n%s", want, out)
}
}
}
// TestDriftDoesNotClaimARePaymentItNeverComputed is the trap the orchestrator named when he ratified A7:
// the config-drift flag used to end its own sentence with «= re-paying for the book», which is a claim
// about MONEY made by a boolean that computes none.
//
// The two come apart exactly when status learned to see a DROPPED stage (backlog row 239): projectRebill
// skips rows whose stage the current pipeline no longer runs, so that drift is real and its re-payment is
// genuinely zero. Under the old sentence the operator was told he would re-pay for a book that will not be
// re-paid for — one lie replaced by another, which is what «правка A7 идёт ВМЕСТЕ с базисом» was about.
//
// Mutation this catches: put the money clause back on the drift line and the first assertion fires.
func TestDriftDoesNotClaimARePaymentItNeverComputed(t *testing.T) {
rep := &pipeline.StatusReport{
BookID: "b", TotalUnits: 2, Done: 2,
ConfigDrift: true, ConfigDriftBasis: pipeline.DriftBasisDrift,
RebillUnits: 0, RebillUSD: 0, RebillBasis: pipeline.RebillBasisPending,
}
var b strings.Builder
_ = renderStatusHuman(&b, rep, "book.yaml")
out := b.String()
if !strings.Contains(out, "⚠ CONFIG-DRIFT") {
t.Fatalf("the drift itself must still be announced:\n%s", out)
}
if strings.Contains(out, "re-paying for the book") {
t.Fatalf("the drift flag must not assert a spend it never computed — the projection says zero "+
"here, and the money lines are what speak about money:\n%s", out)
}
if !strings.Contains(out, "NOTHING already billed is re-paid") {
t.Fatalf("drift with a zero re-payment is a real state and must be named, not left to be inferred "+
"from a missing line:\n%s", out)
}
}
// TestAnUnknownDriftBasisIsSaidOutLoud is the A12 half at the surface an operator actually reads: a
// `config_drift:false` that means «could not check» must not read as «checked, clean».
func TestAnUnknownDriftBasisIsSaidOutLoud(t *testing.T) {
rep := &pipeline.StatusReport{
BookID: "b", TotalUnits: 2, Done: 1,
ConfigDrift: false, ConfigDriftBasis: pipeline.DriftBasisUnknown,
RebillBasis: pipeline.RebillBasisPending,
}
var b strings.Builder
_ = renderStatusHuman(&b, rep, "book.yaml")
out := b.String()
if !strings.Contains(out, "CONFIG-DRIFT UNKNOWN") {
t.Fatalf("an unestablished drift verdict must say so; silence here reads as «no drift»:\n%s", out)
}
clean := &pipeline.StatusReport{
BookID: "b", TotalUnits: 2, Done: 1,
ConfigDrift: false, ConfigDriftBasis: pipeline.DriftBasisNone,
RebillBasis: pipeline.RebillBasisPending,
}
var cb strings.Builder
_ = renderStatusHuman(&cb, clean, "book.yaml")
if strings.Contains(cb.String(), "CONFIG-DRIFT UNKNOWN") {
t.Fatal("a book whose drift WAS checked and is clean must not carry the unknown caveat")
}
}
// TestTheHumanManifestCallsTheBookLevelFigureACeiling pins the one line an operator reads before
// deciding to buy, and it exists because acceptance (F3) found the previous wording claiming the
// opposite of what the number is.
//
// ⛔ THE BOOK-LEVEL PART IS A BOUND, NOT A FORECAST. The terminology passes are limited by their
// configured budgets and their input does not exist until the drafts do, so the engine publishes what it
// will NOT exceed. On a book of a few chapters that figure can dominate the total, and an operator
// reading «$X of it is book-level and paid once» as a bill concludes a short book is dearer per chapter
// than a long one — which is backwards, and is a conclusion drawn from OUR sentence, not from the number.
//
// ⚠ The line is also ABSENT when there is nothing to bound: a configuration with no terminology phase
// prices no book-level spend, and printing «$0.000000 is a ceiling» would invent a phase that never runs.
func TestTheHumanManifestCallsTheBookLevelFigureACeiling(t *testing.T) {
price := &pipeline.BookPrice{ExpectedUSD: 3.5, BookOnceUSD: 2.0, StepMaxUSD: 0.25, SourceChars: 4000}
m := &pipeline.BookManifest{Structure: "detected", Price: price}
var b bytes.Buffer
if err := renderManifest(&b, m, "book.manifest.json", false); err != nil {
t.Fatalf("renderManifest: %v", err)
}
out := b.String()
// The total says what it is and nothing more: the old form folded the bound into the same sentence
// as the expectation, which is where the two got confused.
if !strings.Contains(out, "expected cost: $3.500000 for the whole book (4000 source chars)") {
t.Errorf("the total must name itself and the size it was computed from:\n%s", out)
}
if strings.Contains(out, "paid once)") {
t.Errorf("the bound must not ride inside the total's parentheses — that is the wording F3 refused:\n%s", out)
}
// The bound says it is a bound, in the operator's own words rather than by implication.
if !strings.Contains(out, "$2.000000") || !strings.Contains(out, "CEILING") {
t.Errorf("the book-level figure must be named a ceiling:\n%s", out)
}
if !strings.Contains(out, "not a forecast") {
t.Errorf("a reader must not have to infer that a bound is not a bill:\n%s", out)
}
// And the number that decides whether ANY ceiling admits work stays on its own line.
if !strings.Contains(out, "smallest workable ceiling: $0.250000") {
t.Errorf("the largest indivisible reservation must survive the rewording:\n%s", out)
}
// ⛔ NOTHING TO BOUND ⇒ NOTHING SAID. A run with no terminology phase must not be told about a
// ceiling on passes it will never make.
var b2 bytes.Buffer
m2 := &pipeline.BookManifest{Structure: "none", Price: &pipeline.BookPrice{ExpectedUSD: 1.5, StepMaxUSD: 0.25, SourceChars: 4000}}
if err := renderManifest(&b2, m2, "book.manifest.json", false); err != nil {
t.Fatalf("renderManifest: %v", err)
}
if strings.Contains(b2.String(), "CEILING") {
t.Errorf("a book with no book-level spend must not be told about a ceiling on it:\n%s", b2.String())
}
}
// TestRenderSignatureStopShowsBothDisagreements pins the review screen's two markers together. They are
// different decisions for the owner — the run contradicting itself, and the book already rendering the
// surface another way — and a screen that prints one and hides the other sends him to the sidecar to find
// out that the term he is signing is already in the bank.
func TestRenderSignatureStopShowsBothDisagreements(t *testing.T) {
rows := []pipeline.BankStopRow{{
Src: "方源", Dst: "Фан Юань", Origin: "banknote", Freq: 9, Conf: 10,
Contradicts: []string{`方源空窍→"Пустота Фан Юаня"`},
BankHolds: []string{`unsigned draft "方源"→"Странник" [ch 1..20]`},
}}
var b bytes.Buffer
renderSignatureStop(&b, &pipeline.WaveSignatureStop{Terms: len(rows), SignaturePath: "p", Rows: rows})
out := b.String()
for _, want := range []string{"contradicts:", "Пустота Фан Юаня", "bank holds:", `unsigned draft "方源"→"Странник"`} {
if !strings.Contains(out, want) {
t.Errorf("the review screen must carry %q:\n%s", want, out)
}
}
// CONTROL: a row with neither marker prints neither, so the assertions above are the fields answering.
var quiet bytes.Buffer
renderSignatureStop(&quiet, &pipeline.WaveSignatureStop{Terms: 1, SignaturePath: "p",
Rows: []pipeline.BankStopRow{{Src: "方源", Dst: "Фан Юань", Origin: "banknote", Freq: 9, Conf: 10}}})
if strings.Contains(quiet.String(), "contradicts:") || strings.Contains(quiet.String(), "bank holds:") {
t.Errorf("control: an unmarked row must print neither marker:\n%s", quiet.String())
}
}