426 lines
19 KiB
Go
426 lines
19 KiB
Go
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)
|
||
}
|
||
}
|