package pipeline import ( "bytes" "context" "encoding/json" "log/slog" "os" "reflect" "strings" "testing" "textmachine/backend/internal/obs" ) // bankcompleteness_test.go: backlog row 253(б) — the bank an owner signs says whether it IS the whole bank. // // The engine has always computed this and always kept it in its own log; the artifact the signing screen // is built from carried no field for it, so «partially consolidated because a budget ran out» and «this is // the book's terminology» reached the far side of the seam as the same document. These fixtures assert the // three states apart, because two of them are easy and the third is the one that gets forgotten: a // boundary that never measured anything must not read as a bank with nothing missing. // runToBankExport runs a mining-configured book to completion and returns the read-out it left, plus the // raw bytes — the bytes matter because an ABSENT key and a zeroed section are the distinction under test // and they decode identically into a struct. func runToBankExport(t *testing.T, o miningStopOpts, reply func(string) (string, string)) (BankExport, []byte, *Runner) { t.Helper() rec := &reqRec{} srv := newJSONProvider(rec, reply) t.Cleanup(srv.Close) bookPath := setupMiningStopProject(t, srv.URL, o) r := newRunner(t, bookPath) t.Cleanup(func() { _ = r.Close() }) if _, err := r.TranslateBook(obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()})); err != nil { t.Fatal(err) } raw, err := os.ReadFile(r.bankExportPath()) if err != nil { t.Fatalf("the bank read-out must exist after a run: %v", err) } var exp BankExport if err := json.Unmarshal(raw, &exp); err != nil { t.Fatalf("the bank read-out must be readable JSON: %v", err) } return exp, raw, r } // bankReply answers both bank passes and the draft, so a fixture only has to say how it is configured. func bankReply(body string) (string, string) { switch { case isClassifierBody(body): return "方源\tname\n青茅山\tterm", "stop" case isTerminologyBody(body): return "方源\tФан Юань\n青茅山\tгора Цинмао", "stop" default: return "Фан Юань пришёл к горе Цинмао.\n" + bankBlockForMining, "stop" } } // TestABankCutByABudgetSaysSoInTheReadOut is the state the owner was never shown: the render pass ended // short, so terms in the unbought batches were never offered to the role at all — and the document he // signs against now says which, and how many. // // Mutation this catches: derive Complete from something other than the render pass's own cut and the // section reports a whole bank on a run that bought two thirds of one → RED. func TestABankCutByABudgetSaysSoInTheReadOut(t *testing.T) { // batch_runes 1 makes three render batches; the budget admits two of them. exp, _, r := runToBankExport(t, miningStopOpts{terminology: true, batchRunes: 1, budgetUSD: 0.0037}, bankReply) if r.lastTerminology == nil || r.lastTerminology.BatchesDropped == 0 { t.Fatalf("premise broken: this fixture must actually cut the render pass, got %+v", r.lastTerminology) } c := exp.Consolidation if c == nil { t.Fatal("a run that measured the bank must publish how complete it is — the field's absence is " + "reserved for the boundaries that measured nothing") } if c.Complete { t.Fatalf("the render pass was cut by a budget; the bank is NOT whole: %+v", *c) } if c.RenderBatchesDropped != r.lastTerminology.BatchesDropped { t.Fatalf("the read-out must carry the engine's own count of unbought render batches: %d vs %d", c.RenderBatchesDropped, r.lastTerminology.BatchesDropped) } if c.Consolidated != r.lastTerminology.Consolidated || c.Unanswered != r.lastTerminology.Unanswered { t.Fatalf("the counters must be the pass's own, not re-derived: read-out %+v vs pass %+v", *c, *r.lastTerminology) } } // TestAWholeBankRaisesNoAlarm is the other half of the same conditional, and it is not optional: a pin // that only ever asserts the alarm SOUNDS is green on a surface that raises it always. func TestAWholeBankRaisesNoAlarm(t *testing.T) { exp, _, r := runToBankExport(t, miningStopOpts{terminology: true}, bankReply) if r.lastTerminology == nil || r.lastTerminology.BatchesDropped != 0 { t.Fatalf("premise broken: this fixture must buy every render batch, got %+v", r.lastTerminology) } c := exp.Consolidation if c == nil { t.Fatal("a run that measured the bank must publish the measurement even when nothing is missing — " + "silence is the answer reserved for «not measured»") } if !c.Complete || c.RenderBatchesDropped != 0 { t.Fatalf("every render batch was bought; the bank is whole: %+v", *c) } if c.Consolidated == 0 { t.Fatalf("premise broken: nothing was consolidated, so «whole» here would be whole and empty: %+v", *c) } } // TestABoundaryThatMeasuredNothingSaysNothing is the third fixture and the one this class is named after // (D39.202): the read-out is written at five boundaries and the terminology pass runs at one of them, so a // zeroed section would answer «consolidated 0, unanswered 0» — «nothing is missing» — about a bank nobody // looked at. Mining is CONFIGURED here and the delta is non-empty; only the paid role is off. // // The assertion is on the BYTES, not on the struct: an absent key and a zero-valued section decode to the // same Go value, and it is the reader on the far side of the seam who has to tell them apart. // // Mutation this catches: publish the section unconditionally (drop the nil check in projectBankConsolidation) // and this run — which never asked the role anything — starts claiming a complete bank → RED. func TestABoundaryThatMeasuredNothingSaysNothing(t *testing.T) { exp, raw, r := runToBankExport(t, miningStopOpts{}, bankReply) if r.lastTerminology != nil { t.Fatalf("premise broken: the terminology gate is off in this fixture, got %+v", r.lastTerminology) } if len(exp.Terms) == 0 { t.Fatal("premise broken: the mining wire must still have written a bank, or this asserts nothing") } if exp.Consolidation != nil { t.Fatalf("no pass measured this bank, so the read-out must not describe its completeness: %+v", *exp.Consolidation) } if strings.Contains(string(raw), "consolidation") { t.Fatalf("the key itself must be absent — a present-but-zero section reads as «nothing is missing» "+ "to a consumer that cannot see this test:\n%s", raw) } // CONTROL, so the negative above is a measurement and not a spelling: the key IS in the document when // a pass did measure it. Without this line, renaming the JSON tag would leave the assertion green. whole, wholeRaw, _ := runToBankExport(t, miningStopOpts{terminology: true}, bankReply) if whole.Consolidation == nil || !strings.Contains(string(wholeRaw), `"consolidation"`) { t.Fatalf("control failed: the key must be present when a pass measured the bank:\n%s", wholeRaw) } } // TestAClassifierCutIsNotAnIncompleteBank is the false alarm the naive projection raises, and it is not // hypothetical: on the live run of 08.09 the render pass was INTACT (batches_dropped=0) while the // classifier's own budget cut one batch, and the engine's log warning — which fires on either counter — // said «PARTIALLY consolidated» about a bank that was whole. // // The two facts have different remedies (two budgets), and folding them into one bit would send an owner // to re-buy renderings he already has. // // Mutation this catches: make Complete read ClassifyBatchesDropped as well and this whole bank is reported // partial → RED. func TestAClassifierCutIsNotAnIncompleteBank(t *testing.T) { c := projectBankConsolidation(&terminologyResult{ Consolidated: 29, ClassifyBatchesDropped: 1, // the run of 08.09, to the counter }) if !c.Complete { t.Fatalf("the render pass ran whole: this bank IS complete, only its term types are unrefined: %+v", *c) } if c.ClassifyBatchesDropped != 1 { t.Fatalf("the classifier's cut must still be carried — it is the other budget's fact, not nothing: %+v", *c) } // And the render cut alone is what flips it, which is the same statement from the other side. if projectBankConsolidation(&terminologyResult{BatchesDropped: 1}).Complete { t.Fatal("a cut RENDER pass is exactly what makes a bank partial") } if projectBankConsolidation(nil) != nil { t.Fatal("a pass that did not run projects to no section at all") } } // TestEveryConsolidationFieldIsCarried is the reflective guard the proposal section already has, for the // same reason: a field added here tomorrow and never filled by the fold ships as an absent key, and every // assertion written against today's fields stays green while the signing screen loses a column. // // ⚠ TWO STATES AND NOT ONE, because `Complete` and `RenderBatchesDropped` cannot both be non-zero — the // first is the negation of the second. A single fixture would therefore have to EXCLUDE one field by name, // and an exclusion list is the thing that goes stale silently. Requiring each field to be carried in at // least one of the two states needs no list and covers the struct. // // Its limit, named: this catches a field the SECTION grew and the fold ignored. A counter the terminology // pass grows that ought to be projected and is not cannot be caught mechanically — that is a judgement, // and it belongs to whoever adds the counter. func TestEveryConsolidationFieldIsCarried(t *testing.T) { cut := projectBankConsolidation(&terminologyResult{ BatchesDropped: 1, ClassifyBatchesDropped: 2, Consolidated: 3, Declined: 4, Unanswered: 5, BankSettled: 6, BasisServed: 7, }) whole := projectBankConsolidation(&terminologyResult{ ClassifyBatchesDropped: 2, Consolidated: 3, Declined: 4, Unanswered: 5, BankSettled: 6, BasisServed: 7, }) a, b := reflect.ValueOf(*cut), reflect.ValueOf(*whole) for i := range a.NumField() { if a.Field(i).IsZero() && b.Field(i).IsZero() { t.Errorf("field %s is zero in BOTH projections of a pass where every counter was set — the fold "+ "does not carry it", a.Type().Field(i).Name) } } } // TestTheSignatureStopCarriesTheCompletenessToTheScreen is the end-to-end half of the same row: the CLI // renders what the stop hands it, so a screen that can print the state proves nothing until the engine // actually puts the state on the stop. Both live states are asserted on real runs, because the difference // between them is the whole point and a fixture that can only produce one of them tests a constant. func TestTheSignatureStopCarriesTheCompletenessToTheScreen(t *testing.T) { stopWith := func(t *testing.T, o miningStopOpts) *WaveSignatureStop { t.Helper() rec := &reqRec{} srv := newJSONProvider(rec, bankReply) t.Cleanup(srv.Close) r := newVerifyRunner(t, setupMiningStopProject(t, srv.URL, o)) t.Cleanup(func() { _ = r.Close() }) return runToSignatureStop(t, r) } // (1) A cut render pass reaches the screen as a cut render pass. cut := stopWith(t, miningStopOpts{terminology: true, batchRunes: 1, budgetUSD: 0.0037}) if cut.Consolidation == nil { t.Fatal("the stop must carry how complete the bank in front of the owner is") } if cut.Consolidation.Complete || cut.Consolidation.RenderBatchesDropped == 0 { t.Fatalf("this run's budget cut the render pass; the stop must say so: %+v", *cut.Consolidation) } // (2) And a stop with the paid role OFF carries NO claim about completeness — the state the boundary's // own name cannot express, since mining is what decides the pause and the terminology gate is a // separate switch. This is the case a projection keyed on the boundary would get wrong. unmeasured := stopWith(t, miningStopOpts{}) if unmeasured.Consolidation != nil { t.Fatalf("no pass measured this bank, so the stop must claim nothing about it: %+v", *unmeasured.Consolidation) } } // TestTheSigningBoundaryPublishesTheCompleteness closes the gap the other fixtures here leave open, and it // is the boundary the whole row is about. // // The three runs above finish, so they exercise `bank-mining/auto-continue` and `run-finished`. The one // boundary the signing screen is built from is `bank-mining/signature-stop` (mining.go, «the boundary the // signing screen reads at») — and until this fixture nothing read the ARTIFACT there at all: a projection // published at every boundary EXCEPT that one would have been green everywhere. // // Mutation this catches: skip the section at the signature stop (or publish it only on run-finished) and // the document the owner signs against goes back to saying nothing about how complete the bank is → RED. func TestTheSigningBoundaryPublishesTheCompleteness(t *testing.T) { rec := &reqRec{} srv := newJSONProvider(rec, bankReply) defer srv.Close() r := newVerifyRunner(t, setupMiningStopProject(t, srv.URL, miningStopOpts{terminology: true, batchRunes: 1, budgetUSD: 0.0037})) defer func() { _ = r.Close() }() stop := runToSignatureStop(t, r) raw, err := os.ReadFile(r.bankExportPath()) if err != nil { t.Fatalf("the stop refreshes the read-out the signing screen is built from: %v", err) } var exp BankExport if err := json.Unmarshal(raw, &exp); err != nil { t.Fatal(err) } // The premise that makes this test about the STOP and not about some later boundary: the document on // disk must be the one the stop wrote. Without it a passing assertion below could be describing // `run-finished`, which a stopped run never reaches anyway — and that is exactly the kind of accident // that leaves a boundary untested while the file looks right. if exp.AsOf != "bank-mining/signature-stop" { t.Fatalf("premise broken: the artifact must be the stop's own projection, got as_of=%q", exp.AsOf) } if exp.Consolidation == nil { t.Fatal("the document the owner signs against must say how complete the bank in it is — this is the " + "one boundary opened FOR that decision") } if exp.Consolidation.Complete { t.Fatalf("this run's budget cut the render pass; the signed document must not call the bank whole: %+v", *exp.Consolidation) } // The screen and the artifact are ONE projection: a reader of either must not be able to get a // different answer from the other about the same stop. if stop.Consolidation == nil || *stop.Consolidation != *exp.Consolidation { t.Fatalf("the screen and the artifact must carry the same measurement: screen %+v, artifact %+v", stop.Consolidation, *exp.Consolidation) } } // TestEachConsolidationCounterComesFromItsOwnSource pins the WIRING, which the reflective guard above // cannot: with every source non-zero, a fold that read the wrong counter into a field is still non-zero // everywhere and stays green. Every number here is DIFFERENT, so any swap moves a value that is compared. // // It matters most for NeverAsked, whose source is the only one that is zero in every end-to-end fixture in // this package — so nothing else in the tree would have noticed it reading Declined instead. func TestEachConsolidationCounterComesFromItsOwnSource(t *testing.T) { got := projectBankConsolidation(&terminologyResult{ BatchesDropped: 11, ClassifyBatchesDropped: 22, Consolidated: 33, Declined: 44, Unanswered: 55, BankSettled: 66, }) want := &BankConsolidation{ Complete: false, RenderBatchesDropped: 11, ClassifyBatchesDropped: 22, Consolidated: 33, Declined: 44, Unanswered: 55, NeverAsked: 66, } if !reflect.DeepEqual(got, want) { t.Fatalf("every field must come from its OWN counter — and a field added since this list was "+ "written has to be added to it, deliberately, by whoever adds the counter:\n got %+v\nwant %+v", *got, *want) } } // TestTheLogAndTheReadOutAgreeAboutWhatIsPartial closes the last place the engine contradicted itself // about the object an owner signs. // // One warning fired by EITHER counter said «this bank is PARTIALLY consolidated» on a run whose render // pass was intact and whose classifier alone was cut — measured 08.09 (`batches_dropped=0`, // `classify_batches_dropped=1`, `consolidated=29`). The read-out this pack added reports the bank's // completeness from the render pass alone, so the same run shipped two opposite verdicts: «PARTIALLY // consolidated» in the log and «WHOLE» on the screen. A pair that argues with itself is worse than either // half, so the log now says the same thing the projection does. // // ⚠ BOTH HALVES OF BOTH MESSAGES, which is what a conditional message costs: each is asserted where it // MUST sound and where it MUST stay silent. A pin that only checks the silence is vacuous exactly where // the sentence is false. func TestTheLogAndTheReadOutAgreeAboutWhatIsPartial(t *testing.T) { const ( bankPartial = "this bank is PARTIALLY consolidated" typesCutShort = "the TYPE classifier ended short" ) run := func(t *testing.T, o miningStopOpts) (string, *terminologyResult) { t.Helper() rec := &reqRec{} srv := newJSONProvider(rec, bankReply) t.Cleanup(srv.Close) r := newRunner(t, setupMiningStopProject(t, srv.URL, o)) t.Cleanup(func() { _ = r.Close() }) var logBuf bytes.Buffer r.Log = slog.New(slog.NewTextHandler(&logBuf, &slog.HandlerOptions{Level: slog.LevelWarn})) if _, err := r.TranslateBook(obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()})); err != nil { t.Fatal(err) } return logBuf.String(), r.lastTerminology } // (1) The RENDER pass was cut: the bank IS partial, and the log has to say so. out, res := run(t, miningStopOpts{terminology: true, batchRunes: 1, budgetUSD: 0.0037}) if res == nil || res.BatchesDropped == 0 { t.Fatalf("premise broken: this fixture must cut the render pass, got %+v", res) } if !strings.Contains(out, bankPartial) { t.Errorf("a cut RENDER pass is exactly what makes a bank partial, and the log must say it:\n%s", out) } // (2) Only the CLASSIFIER was cut: the bank is whole, and the log must NOT call it partial — this is // the false alarm, and it is the half a silence-only pin would have missed. out2, res2 := run(t, miningStopOpts{terminology: true, classify: true, batchRunes: 1, classifyBudgetUSD: 0.0037}) if res2 == nil || res2.ClassifyBatchesDropped == 0 { t.Fatalf("premise broken: this fixture must cut the CLASSIFY pass, got %+v", res2) } if res2.BatchesDropped != 0 { t.Fatalf("premise broken: the render pass must be INTACT here, or the two facts are not separated: %+v", res2) } if strings.Contains(out2, bankPartial) { t.Errorf("the render pass ran whole: calling this bank partially consolidated is the false alarm "+ "this fix removes, and it disagrees with the read-out of the same run:\n%s", out2) } if !strings.Contains(out2, typesCutShort) { t.Errorf("the classifier's own cut is still a fact an operator acts on — on the OTHER budget:\n%s", out2) } // (3) Nothing was cut: neither sentence appears. Without this the two above are satisfied by a log // that prints both messages always. out3, res3 := run(t, miningStopOpts{terminology: true, classify: true}) if res3 == nil || res3.BatchesDropped != 0 || res3.ClassifyBatchesDropped != 0 { t.Fatalf("premise broken: this fixture must buy every batch of both passes, got %+v", res3) } if strings.Contains(out3, bankPartial) || strings.Contains(out3, typesCutShort) { t.Errorf("nothing was cut in this run; neither warning may fire:\n%s", out3) } }