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