textmachine/backend/internal/pipeline/bankexport_test.go

192 lines
9.3 KiB
Go
Raw 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 pipeline
import (
"reflect"
"testing"
)
// projectBankProposals is a pure fold, and the property that matters about it — no deciding field is
// dropped, swapped with its neighbour or rescaled on the way to the sidecar — is a property of the fold
// alone. It gets its own test here rather than riding on the stop's integration fixture, because that
// fixture's values are whatever one run happens to produce: 0 for the counters a small corpus never
// raises, empty for both string lists, false for a rendering the drafts did propose. Compared against a
// row of that shape, a swap reads as `0 != 0` and a tenfold rescale as `!= 0` — the assertion passes
// while the projection lies. Measured on 08.09: four mutations planted at once (Freq×10, Invented forced
// false, Contradicts↔BankHolds swapped) left the whole package green in 16.8 s.
//
// So the first row below gives EVERY field a value that is distinguishable from every other field's: the
// two string lists differ in length AND in content, Freq/Spread/Conf are three different non-zero numbers,
// Kind and Channel are two different strings, and the rendering is one no draft proposed.
//
// And the table is TWO rows, not one, for two reasons a single row cannot carry. ORDER: the section's own
// contract (bankexport.go — "Order is the stop's own RANKING") says which row an owner reads first, and a
// one-row table makes a reversal unobservable by construction — measured 08.09, reversing the fold left
// the whole package green. NOTHING CONSOLIDATED: the second row's Dst is "", the state the field's comment
// names in as many words, and no fixture in this package carried one — so a fold substituting the source
// surface for the missing rendering published 青茅山 → 青茅山, a decision where there is none, and stayed
// green too.
func TestEveryDecidingFieldSurvivesTheProjection(t *testing.T) {
decided := BankStopRow{
Src: "方源",
Dst: "Фан-Юань",
Type: "name",
Origin: "banknote",
Freq: 37,
Spread: 4,
Conf: 61,
Invented: true,
Contradicts: []string{
`this run also renders 方 as "Фан"`,
},
BankHolds: []string{
`unsigned draft "方源"→"Странник" [ch 1..20]`,
`approved "方源"→"Фан Юань" [ch 21..40]`,
},
Variants: []BankStopVariant{
{Dst: "Фан Юань", Chunks: 9},
{Dst: "Фан", Chunks: 2, Via: "方"},
},
// Fields the sidecar deliberately does NOT carry, set anyway: a projection that started copying
// them into the wrong slot would move a value that is supposed to stay behind.
Contexts: []string{"方源来到青茅山"},
Evidence: []string{"ch 3"},
Conventions: 2,
Signals: []string{"majority"},
}
// The row where the role consolidated NOTHING. Dst stays empty all the way to the sheet: an owner
// reading 青茅山 → 青茅山 would be reading a decision that was never made. Conf is negative because
// the role stated none, which the field's comment calls a different fact from "0% sure".
undecided := BankStopRow{
Src: "青茅山",
Dst: "",
Type: "place",
Origin: "mined",
Freq: 5,
Spread: 2,
Conf: -1,
Variants: []BankStopVariant{
{Dst: "гора Цинмао", Chunks: 3},
},
}
// The row the SETTLED BASIS answered: a real rendering, carried over from an earlier purchase. It is a
// third state rather than a flag on one of the two above, because the combination is production's own:
// `Invented` is deliberately NOT computed for such a row (it is a finding about THIS run's drafts, which
// were never offered the term), so a fixture that set both would encode a state the engine cannot reach.
settledEarlier := BankStopRow{
Src: "元石", Dst: "юаньши", Type: "name", Origin: "mined",
Freq: 9, Spread: 1, Conf: -1, SettledByBasis: true,
Variants: []BankStopVariant{{Dst: "юаньши", Chunks: 2}},
}
got := projectBankProposals([]BankStopRow{decided, undecided, settledEarlier})
want := []BankExportProposal{
{
Src: "方源", Dst: "Фан-Юань", Kind: "name", Channel: "banknote",
Freq: 37, Spread: 4, Conventions: 2, Conf: 61, Invented: true,
Contradicts: []string{`this run also renders 方 as "Фан"`},
BankHolds: []string{
`unsigned draft "方源"→"Странник" [ch 1..20]`,
`approved "方源"→"Фан Юань" [ch 21..40]`,
},
Variants: []string{"Фан Юань ×9", "Фан ×2 (proposed for 方)"},
},
{
Src: "青茅山", Dst: "", Kind: "place", Channel: "mined",
Freq: 5, Spread: 2, Conf: -1,
Variants: []string{"гора Цинмао ×3"},
},
{
Src: "元石", Dst: "юаньши", Kind: "name", Channel: "mined",
Freq: 9, Spread: 1, Conf: -1, SettledEarlier: true,
Variants: []string{"юаньши ×2"},
},
}
// Compared as a WHOLE SLICE, not row by row found by Src: the ranking is the information, so the
// position each row holds is part of what is being asserted.
if !reflect.DeepEqual(got, want) {
t.Fatalf("the projection is not the table it projects:\n got %+v\n want %+v", got, want)
}
// AND: no field of the proposal is left at its zero value — read off the FIRST row, the one built to
// have every field set (the second is deliberately half empty, that being its whole point). The
// comparison above pins the fields that exist today; this pins the ones added tomorrow — a field
// appended to BankExportProposal and never filled by the fold ships as an absent key in the sidecar,
// and every assertion written against today's fields stays green while the signing screen loses a
// column.
//
// ⚠ Its limit, named: this catches a field the PROPOSAL grew and the fold ignored. A field BankStopRow
// grows that ought to be projected and is not cannot be caught mechanically — whether a new fact is
// one an owner signs on is a judgement, and it belongs to whoever adds the field.
//
// ⚠ ACROSS THE ROWS AND NOT WITHIN ONE, for the same reason its sibling over the consolidation section
// gives: some pairs of fields cannot both be set on one row. `Invented` and `SettledEarlier` are such a
// pair — the engine refuses to compute the first for a row the basis answered — so a single all-fields
// fixture would have to encode an unreachable state or carry an exclusion list, and an exclusion list is
// the thing that goes stale in silence. Requiring each field in AT LEAST ONE row needs no list.
rows := make([]reflect.Value, 0, len(got))
for _, p := range got {
rows = append(rows, reflect.ValueOf(p))
}
for i := range rows[0].NumField() {
carried := false
for _, v := range rows {
if !v.Field(i).IsZero() {
carried = true
break
}
}
if !carried {
t.Errorf("field %s is zero in EVERY projected row, including the ones built to set it — the fold does not carry it",
rows[0].Type().Field(i).Name)
}
}
}
// An empty table must project to nil rather than to an empty slice: `omitempty` then keeps the key out of
// the sidecar entirely, and a reader that finds `proposed` present with zero rows cannot tell "the stop
// presented nothing" from "this boundary is not a stop".
func TestATableWithNoRowsProjectsToNoSection(t *testing.T) {
if got := projectBankProposals(nil); got != nil {
t.Errorf("no rows projected to %#v, want nil so the key is omitted entirely", got)
}
if got := projectBankProposals([]BankStopRow{}); got != nil {
t.Errorf("an empty table projected to %#v, want nil", got)
}
}
// TestTheProjectionPublishesConventionsBesideSpread pins the field whose absence produced two wrong readings
// of the same run. `spread` counts raw renderings and `conventions` counts decisions, they differ whenever a
// draft wrote one decision two ways, and the variants list has one entry per CONVENTION — so a consumer
// holding only `spread` and the list is holding two different measurements and no way to know it.
//
// The fixture is a candidate on which the two numbers MUST differ; on equal numbers the pin would pass
// against a projection that published the wrong field, which is how it would have passed before the fix.
func TestTheProjectionPublishesConventionsBesideSpread(t *testing.T) {
rows := []BankStopRow{{
Src: "开窍大典", Dst: "церемония открытия апертуры", Freq: 10,
Spread: 4, Conventions: 3,
Variants: []BankStopVariant{
{Dst: "церемония открытия апертуры", Chunks: 3},
{Dst: "Великая церемония открытия апертуры", Chunks: 1},
{Dst: "Обряд отверзания", Chunks: 1},
},
}}
got := projectBankProposals(rows)
if len(got) != 1 {
t.Fatalf("one row in, one proposal out: %+v", got)
}
p := got[0]
if p.Spread == p.Conventions {
t.Fatalf("premise broken: the fixture must be a row where the two numbers DIFFER, got spread=%d conventions=%d", p.Spread, p.Conventions)
}
if p.Spread != 4 || p.Conventions != 3 {
t.Errorf("the projection must carry BOTH of the stop table's numbers: got spread=%d conventions=%d, want 4 and 3", p.Spread, p.Conventions)
}
// The relation that makes the list readable: its length is `conventions`, never `spread`. This is the
// arithmetic two sessions could not check because the second number was not published.
if p.Conventions != len(p.Variants) {
t.Errorf("conventions=%d but the variants list has %d entries — the list is per convention, and a consumer comparing it to spread is comparing different measurements", p.Conventions, len(p.Variants))
}
}