238 lines
11 KiB
Go
238 lines
11 KiB
Go
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 A–C", "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())
|
||
}
|
||
}
|