192 lines
9.3 KiB
Go
192 lines
9.3 KiB
Go
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))
|
||
}
|
||
}
|