textmachine/backend/cmd/tmctl/render_observability_test.go

238 lines
11 KiB
Go
Raw Permalink 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 (
"strings"
"testing"
"textmachine/backend/internal/pipeline"
"textmachine/backend/internal/store"
)
// render_observability_test.go pins the two counters the engine had always computed but no surface
// printed (found by the coldrun-b readiness pass): the pack-19 voice flagger and the pack-20 banknote
// channel. Both are pure observability — no gate, no snapshot fold, no verdict — so each test also
// pins the SILENCE case: a run without the channel keeps its byte-identical output.
func TestRenderQualityPrintsVoiceAxesWithDenominators(t *testing.T) {
var b strings.Builder
q := &pipeline.QualityReport{
VoiceFlags: 2, VoiceReplies: 40, VoiceAttributed: 31, VoicePairRegister: 5,
SpoilerLeaks: 1, VoiceCheckVersion: "voice-v1",
}
if err := renderQuality(&b, q); err != nil {
t.Fatal(err)
}
out := b.String()
// The count alone is unreadable — 2 of 31 attributed replies is a different fact from 2 of 3.
for _, want := range []string{"VOICE (axes AC", "flags=2", "attributed=31", "replies=40",
"axis-D pair-register=5", "spoiler-leaks=1", "rules=voice-v1"} {
if !strings.Contains(out, want) {
t.Fatalf("the voice line must carry %q, got:\n%s", want, out)
}
}
}
func TestRenderQualityVoiceLinePrintsZeroWhenTheGateRan(t *testing.T) {
// «Not measured» and «measured, clean» are different answers (coldrun-a §7 reported the first as
// the second). The version string is the discriminator: it is set iff gates.voice was on.
var b strings.Builder
if err := renderQuality(&b, &pipeline.QualityReport{VoiceCheckVersion: "voice-v1"}); err != nil {
t.Fatal(err)
}
if !strings.Contains(b.String(), "flags=0 over attributed=0 of replies=0") {
t.Fatalf("a gate that ran and found nothing must say so with its denominators:\n%s", b.String())
}
}
func TestRenderQualityStaysSilentWithoutTheVoiceGate(t *testing.T) {
var b strings.Builder
if err := renderQuality(&b, &pipeline.QualityReport{TotalUnits: 3}); err != nil {
t.Fatal(err)
}
if strings.Contains(b.String(), "VOICE") {
t.Fatalf("a book whose voice gate never ran must not grow a voice line:\n%s", b.String())
}
}
func TestRenderReportPrintsBanknoteCoverageNotJustTheTotal(t *testing.T) {
// Chunk coverage is the signal a grand total hides: 12 lines can be 12 chunks × 1 or 1 chunk × 12,
// and only the second is a broken channel.
var b strings.Builder
states := []store.RetrievalState{
{Chapter: 1, ChunkIdx: 0, NBanknoteLines: 7},
{Chapter: 1, ChunkIdx: 1, NBanknoteLines: 0, BanknoteParseFail: 1},
{Chapter: 2, ChunkIdx: 0, NBanknoteLines: 5, BanknoteTruncated: 1},
}
if err := renderReport(&b,
func() ([]store.RequestLogView, error) { return nil, nil },
func() ([]store.ChunkStatus, error) { return nil, nil },
func() ([]store.RetrievalState, error) { return states, nil },
pricedByAnswerer,
okLedger); err != nil {
t.Fatal(err)
}
out := b.String()
for _, want := range []string{"=== BANKNOTE", "lines=12", "over 2/3 chunk(s)",
"parse-fail chunks=1", "truncated chunks=1"} {
if !strings.Contains(out, want) {
t.Fatalf("the banknote line must carry %q, got:\n%s", want, out)
}
}
}
func TestRenderReportStaysSilentWithoutTheBanknoteChannel(t *testing.T) {
var b strings.Builder
states := []store.RetrievalState{{Chapter: 1, ChunkIdx: 0, NExactHits: 2}}
if err := renderReport(&b,
func() ([]store.RequestLogView, error) { return nil, nil },
func() ([]store.ChunkStatus, error) { return nil, nil },
func() ([]store.RetrievalState, error) { return states, nil },
pricedByAnswerer,
okLedger); err != nil {
t.Fatal(err)
}
if strings.Contains(b.String(), "BANKNOTE") {
t.Fatalf("a banknote-off book must not grow a banknote section:\n%s", b.String())
}
}
// TestRenderQualityPrintsTheConsistencySectionWithItsDenominators pins the shape of the book-consistency
// section, and it pins the DENOMINATORS rather than the findings. The failure this guards against is the one
// the polygon's own instrument committed three times on this book: printing a zero that meant "I did not
// look" in the same words as a zero that meant "I looked and it is clean". Every count below therefore has to
// appear beside the population it came out of.
func TestRenderQualityPrintsTheConsistencySectionWithItsDenominators(t *testing.T) {
var b strings.Builder
q := &pipeline.QualityReport{Consistency: &pipeline.BookConsistency{
UnitsJudged: 4, BankRows: 69, Judged: 68, NoDst: 1, NeverFired: 2, BlockedOnly: 1,
FiredTotal: 231, ShippedTotal: 188,
Covered: 57, Absent: 5, Split: 3, UnsignedRows: 66, SuppressedSilent: 0,
CoveredStrict: 47, AbsentStrict: 14, SplitStrict: 7,
Terms: []pipeline.TermConsistency{{
Src: "家老", Dst: "старейшина рода", Status: "draft", Verdict: "absent",
Fired: 11, Shipped: 0, ShippedStrict: 0, WithoutForm: []int{1, 3},
}},
}}
if err := renderQuality(&b, q); err != nil {
t.Fatalf("renderQuality: %v", err)
}
out := b.String()
for _, want := range []string{
"=== BOOK CONSISTENCY",
"bank rows=69", "judgeable (a dst to look for)=68", "no dst=1", "shipped units read=4",
"key never fired in the source=2", "spoiler-window blocked=1",
"bank keys fired and judged=231", "accepted renderings found in the text=188",
"I1 ONE FORM PER TERM", "more than one rendering=3", "a single rendering=57",
"I2 THE BANK'S FORM IS THE ONE SHIPPED", "never reached the reader=5",
// The signing state is a fact about the BOOK, said once — there is no signed TERM to count.
"THIS BANK IS NOT SIGNED", "66 of 68 judgeable rows",
"NESTED ROWS EATEN BY A LONGER KEY", // printed at zero too: its silence is the finding it reports
"家老", "старейшина рода", "without it: 1,3",
} {
if !strings.Contains(out, want) {
t.Errorf("the consistency section is missing %q:\n%s", want, out)
}
}
// The second column has to be visible whenever it disagrees, or the report states one matcher's answer
// as the book's condition.
if !strings.Contains(out, "absent=14") {
t.Errorf("the strict-matcher column must be printed where it disagrees (absent 5 vs 14):\n%s", out)
}
}
// TestRenderQualityStaysSilentWithoutABank is the negative twin: a book with no glossary must produce no
// consistency section at all. A section reading "0 problems over 0 rows" is worse than none — it is the
// reassuring answer to a question nobody asked.
func TestRenderQualityStaysSilentWithoutABank(t *testing.T) {
var b strings.Builder
if err := renderQuality(&b, &pipeline.QualityReport{}); err != nil {
t.Fatalf("renderQuality: %v", err)
}
if strings.Contains(b.String(), "BOOK CONSISTENCY") {
t.Errorf("a bankless book must print no consistency section:\n%s", b.String())
}
}
// TestTheReportNamesADeviationOnAChunkWhoseConfirmedCountIsZero pins the condition that decides whether the
// memory section prints a chunk at all, and the fixture is the shape that made it matter: a chunk whose
// CONFIRMED miss count is zero and whose detail names four deviations anyway.
//
// That is not a corner case — it is what an unsigned bank looks like. A miss on an unsigned row is
// deliberately not counted (counting it would invert the contract), so on the paid run of 11.09 every one of
// the six chunks had `n_postcheck_miss = 0` while the detail beside them named eighteen terms. Listing only
// chunks with a non-zero count therefore printed a zero and nothing else, and the names of the eighteen —
// already written, already in the column — reached no reader.
func TestTheReportNamesADeviationOnAChunkWhoseConfirmedCountIsZero(t *testing.T) {
detail := `[{"src":"家老","dst":"старейшина рода","disp":"ambiguous"},{"src":"正道","dst":"праведный путь","disp":"ambiguous"}]`
states := []store.RetrievalState{
{Chapter: 1, ChunkIdx: 0, NPostcheckMiss: 0, PostcheckDetail: detail},
{Chapter: 2, ChunkIdx: 1, NPostcheckMiss: 0, PostcheckDetail: ""}, // nothing recorded: must stay unlisted
}
var b strings.Builder
err := renderReport(&b,
func() ([]store.RequestLogView, error) { return nil, nil },
func() ([]store.ChunkStatus, error) { return nil, nil },
func() ([]store.RetrievalState, error) { return states, nil },
pricedByAnswerer,
func() (float64, float64, error) { return 0, 0, nil })
if err != nil {
t.Fatalf("renderReport: %v", err)
}
out := b.String()
// PREMISE: the aggregate above still reports zero CONFIRMED misses. If it did not, the section would be
// printing for the ordinary reason and this fixture would pin nothing.
if !strings.Contains(out, "post-check-misses=0") {
t.Fatalf("premise broken: the confirmed-miss total must be 0 here, so that the listing below cannot be explained by it:\n%s", out)
}
for _, want := range []string{"家老", "старейшина рода", "正道", "праведный путь"} {
if !strings.Contains(out, want) {
t.Errorf("a deviation recorded on a chunk with zero CONFIRMED misses was not named: %q missing from\n%s", want, out)
}
}
// And a chunk with nothing recorded must still not be listed — otherwise the section grows a line per
// chunk and the names are buried again, this time in noise rather than in a zero.
if strings.Contains(out, "2 1") {
t.Errorf("a chunk with no recorded deviation was listed anyway:\n%s", out)
}
}
// TestRenderQualityPrintsOneWaveInjectionLinePerWave pins the surface that answers "did the bank reach this
// wave's prompt". The two waves select over different banks — the draft over the BASE bank with mined rows
// excluded, the editor over the enriched one — so the lines are not expected to agree, and a report that
// printed one line for both would state the draft's numbers as the book's.
//
// A run made before the v17 table has no rows and must print NOTHING here: "this wave injected nothing" and
// "this run predates the table" are different facts, and a row of zeroes would assert the first.
func TestRenderQualityPrintsOneWaveInjectionLinePerWave(t *testing.T) {
var b strings.Builder
q := &pipeline.QualityReport{Waves: []pipeline.WaveInjection{
{Wave: "draft", Positions: 6, ExactHits: 11, Sticky: 2, Ambiguous: 9, Blocked: 1, Evicted: 0},
{Wave: "edit", Positions: 4, ExactHits: 25, Sticky: 0, Ambiguous: 24, Blocked: 0, Evicted: 3},
}}
if err := renderQuality(&b, q); err != nil {
t.Fatalf("renderQuality: %v", err)
}
out := b.String()
for _, want := range []string{
"WAVE INJECTION [draft", "positions=6", "exact=11", "sticky=2",
"WAVE INJECTION [edit", "positions=4", "exact=25", "evicted-by-budget=3",
} {
if !strings.Contains(out, want) {
t.Errorf("the per-wave line is missing %q:\n%s", want, out)
}
}
// The editor's evictions are the number that lived nowhere before v17 — chunkrun.go recorded the
// draft's and said in as many words that the editor's went unrecorded. If it ever stops printing, the
// table is being written and not read.
if strings.Count(out, "WAVE INJECTION") != 2 {
t.Errorf("want exactly one line per wave, got %d:\n%s", strings.Count(out, "WAVE INJECTION"), out)
}
var empty strings.Builder
if err := renderQuality(&empty, &pipeline.QualityReport{}); err != nil {
t.Fatalf("renderQuality: %v", err)
}
if strings.Contains(empty.String(), "WAVE INJECTION") {
t.Errorf("a run with no wave rows must print no wave line at all:\n%s", empty.String())
}
}