392 lines
17 KiB
Go
392 lines
17 KiB
Go
package ingest
|
||
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
"os"
|
||
"testing"
|
||
|
||
"gopkg.in/yaml.v3"
|
||
)
|
||
|
||
// bankreadout_test.go: the two sections a signing stop publishes beside the bank — engine backlog
|
||
// rows 224 and 253. Until this build the reader took `terms` alone, which at a stop is EMPTY by
|
||
// construction, so the one screen in the product that asks a person for a decision was served a zero
|
||
// that meant "the reader does not look here", not "there is nothing to decide".
|
||
|
||
const bankHead = `"bank_version":"tm-bank-v1","book_id":"bk_1","terms":[]`
|
||
|
||
// ⛔ THE PIN THIS PACK EXISTS FOR, one floor down from the screen: a section that is not in the
|
||
// document and a section that is there and holds nothing are different answers, and a reader that
|
||
// returns an empty slice for both has rebuilt the false zero it was written to remove. At a stop the
|
||
// first means "this read-out cannot tell you"; the second means "the stop asked nothing".
|
||
//
|
||
// Mutation this must catch: decoding `proposed` into a plain []Suggestion — json writes nil for both
|
||
// cases and every caller downstream then reports "no suggestions" for a document that never carried
|
||
// the section.
|
||
func TestASectionTheDocumentDoesNotCarryIsNotASectionThatIsEmpty(t *testing.T) {
|
||
absent, err := DecodeBank([]byte(`{` + bankHead + `}`))
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if absent.Offered != nil {
|
||
t.Errorf("a document with no `proposed` member reported a section: %+v", absent.Offered)
|
||
}
|
||
if absent.Consolidation != nil {
|
||
t.Errorf("a document with no `consolidation` member reported one: %+v", absent.Consolidation)
|
||
}
|
||
|
||
empty, err := DecodeBank([]byte(`{` + bankHead + `,"proposed":[],"consolidation":{}}`))
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if empty.Offered == nil {
|
||
t.Fatal("a `proposed` section that is present and empty was reported as absent")
|
||
}
|
||
if len(*empty.Offered) != 0 {
|
||
t.Errorf("an empty section decoded %d rows", len(*empty.Offered))
|
||
}
|
||
if empty.Consolidation == nil {
|
||
t.Fatal("a `consolidation` section that is present and empty was reported as absent")
|
||
}
|
||
// The premise the assertion above stands on, asserted so the fixture cannot lose its power to
|
||
// tell the two apart: an empty consolidation errs toward warning the signer, the opposite
|
||
// direction from the numbers below, and if that ever flips the test is measuring nothing.
|
||
if empty.Consolidation.Complete {
|
||
t.Error("an empty consolidation section claimed the bank is complete")
|
||
}
|
||
}
|
||
|
||
// ⛔ The measured case, not a hypothetical. `conventions` has no `omitempty` on the engine's side, so
|
||
// it is written ALWAYS — and it is absent from all 69 rows of one bought stop read-out and present on
|
||
// all 66 of the other, because the field was added to the projection between the two runs. An absent
|
||
// mandatory number therefore means "an engine build without this field wrote the document", and zero
|
||
// is a measurement: `freq: 0` is the engine's own "only the draft side saw it".
|
||
//
|
||
// Mutation this must catch: decoding any of the four numbers into a plain int.
|
||
func TestAMandatoryNumberTheReadOutDoesNotCarryIsNotZero(t *testing.T) {
|
||
doc := `{` + bankHead + `,"proposed":[{"src":"丙等","dst":"третий разряд","kind":"title","channel":"banknote","variants":["третий ранг ×1"]}]}`
|
||
bank, err := DecodeBank([]byte(doc))
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
got := (*bank.Offered)[0]
|
||
for name, p := range map[string]*int{
|
||
"freq": got.Freq, "spread": got.Spread, "conventions": got.Conventions, "confidence": got.Confidence,
|
||
} {
|
||
if p != nil {
|
||
t.Errorf("%s was absent from the read-out and came back as %d, which is a figure nobody measured", name, *p)
|
||
}
|
||
}
|
||
|
||
stated := `{` + bankHead + `,"proposed":[{"src":"丙等","freq":0,"spread":0,"conventions":0,"conf":0}]}`
|
||
bank, err = DecodeBank([]byte(stated))
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
got = (*bank.Offered)[0]
|
||
for name, p := range map[string]*int{
|
||
"freq": got.Freq, "spread": got.Spread, "conventions": got.Conventions, "confidence": got.Confidence,
|
||
} {
|
||
if p == nil {
|
||
t.Errorf("%s was stated as 0 in the read-out and came back as «not stated»", name)
|
||
} else if *p != 0 {
|
||
t.Errorf("%s was stated as 0 and came back as %d", name, *p)
|
||
}
|
||
}
|
||
}
|
||
|
||
// ⛔ THE ENGINE'S SENTINEL NEVER REACHES A CLIENT. It spells "the reply carried no confidence" as a
|
||
// NEGATIVE number, which is a sentinel and not a measurement; the canon promises `0..100` or null, so
|
||
// a build that carried the minus through would show a person "-1 %" on the one screen that asks them
|
||
// to decide. Folded at the SEAM, where every other crossing in this file is folded, because a
|
||
// translation left to a later path is one a later path can forget — and this one WAS forgotten once,
|
||
// between a canon that said `minimum: 0` and a projection that passed the value straight out.
|
||
//
|
||
// ⚠ A stated ZERO must survive: "the service said it was 0 % sure" is the first row a person should
|
||
// look at, and "the service said nothing" is not a row about confidence at all. The two are one byte
|
||
// apart in the engine's encoding and opposite in meaning.
|
||
//
|
||
// Mutation this must catch: returning `conf` unchanged from statedConfidence, or testing `<= 0`.
|
||
func TestTheEnginesNoConfidenceSentinelNeverReachesAClient(t *testing.T) {
|
||
bank, err := DecodeBank([]byte(`{` + bankHead + `,"proposed":[{"src":"a","conf":-1},{"src":"b","conf":0},{"src":"c"},{"src":"d","conf":95}]}`))
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
rows := *bank.Offered
|
||
if rows[0].Confidence != nil {
|
||
t.Errorf("the engine's «no confidence stated» (-1) reached the wire as %d", *rows[0].Confidence)
|
||
}
|
||
if rows[1].Confidence == nil || *rows[1].Confidence != 0 {
|
||
t.Errorf("a stated confidence of 0 was folded into «not stated»: %v", rows[1].Confidence)
|
||
}
|
||
if rows[2].Confidence != nil {
|
||
t.Errorf("a row whose read-out carries no `conf` at all came back as %d", *rows[2].Confidence)
|
||
}
|
||
if rows[3].Confidence == nil || *rows[3].Confidence != 95 {
|
||
t.Errorf("a stated confidence of 95 came back as %v", rows[3].Confidence)
|
||
}
|
||
// The canon's own range, asserted over every row rather than over the one that motivated this:
|
||
// a value outside it is a document a generated client refuses.
|
||
for i, r := range rows {
|
||
if r.Confidence != nil && (*r.Confidence < 0 || *r.Confidence > 100) {
|
||
t.Errorf("row %d carries a confidence of %d, outside the canon's 0..100", i, *r.Confidence)
|
||
}
|
||
}
|
||
}
|
||
|
||
// `invented` is the class the engine names as the one to read FIRST — the consolidated rendering is
|
||
// not one any draft proposed. A reader that loses it shows a person a list in which nothing says the
|
||
// service made the name up. Measured: 11 of 69 rows in one bought read-out, 17 of 66 in the other.
|
||
func TestTheClassAPersonReadsFirstSurvivesTheReadOut(t *testing.T) {
|
||
bank, err := DecodeBank([]byte(`{` + bankHead + `,"proposed":[{"src":"a","invented":true},{"src":"b"}]}`))
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if !(*bank.Offered)[0].Invented {
|
||
t.Error("the engine said it invented this rendering and the reader dropped it")
|
||
}
|
||
if (*bank.Offered)[1].Invented {
|
||
t.Error("a row the engine did not mark came back marked")
|
||
}
|
||
}
|
||
|
||
// ⛔ A section is only ever read TOGETHER with the boundary that wrote it: the read-out is published
|
||
// at five of them and its own export warns three times over that the file may be a previous one's.
|
||
// "This document is not from a signing stop" and "the stop had nothing to ask" are different answers
|
||
// to a person staring at an empty screen.
|
||
//
|
||
// The engine's words are pipeline vocabulary and none passes through. Anything this build cannot name
|
||
// — including the empty string a document from before the freshness anchor carries — is the value
|
||
// that claims the LEAST, and the one thing it may never become is the stop.
|
||
func TestTheBoundaryIsTranslatedAndAnUnnameableOneIsNeverTheStop(t *testing.T) {
|
||
for engine, want := range map[string]string{
|
||
"run-start/seeded": BoundaryRunStarted,
|
||
"bank-mining/auto-continue": BoundaryTermsTakenUnsigned,
|
||
"bank-mining/signature-stop": BoundarySignatureRequested,
|
||
"run-finished": BoundaryRunFinished,
|
||
"redrive/re-seeded": BoundaryBookReseeded,
|
||
"bank-mining/some-boundary-added-later": BoundaryUnknown,
|
||
"": BoundaryUnknown,
|
||
} {
|
||
bank, err := DecodeBank([]byte(`{` + bankHead + `,"as_of":` + quote(engine) + `}`))
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if bank.Boundary != want {
|
||
t.Errorf("boundary %q read as %q, want %q", engine, bank.Boundary, want)
|
||
}
|
||
if engine != "bank-mining/signature-stop" && bank.Boundary == BoundarySignatureRequested {
|
||
t.Errorf("boundary %q was read as the signing stop", engine)
|
||
}
|
||
}
|
||
// The run identity is the anchor's other half and is read, not dropped: half an anchor is a
|
||
// freshness claim nothing supports.
|
||
bank, err := DecodeBank([]byte(`{` + bankHead + `,"run_id":"tm-stream-run_X-1"}`))
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if bank.RunID != "tm-stream-run_X-1" {
|
||
t.Errorf("the read-out's run identity came back as %q", bank.RunID)
|
||
}
|
||
}
|
||
|
||
// The detector's name is pipeline vocabulary — `mined` is the very word the contract renamed in 0.3.0
|
||
// so that it could not reach a client — so all four are translated and an unrecognised one becomes
|
||
// `unknown` rather than a guess. This axis is how a person judges how well corroborated a suggestion
|
||
// is, and inventing corroboration is the error reading further cannot undo.
|
||
func TestTheDetectorVocabularyDoesNotReachAClient(t *testing.T) {
|
||
for engine, want := range map[string]string{
|
||
"mined": ChannelSourceText, "banknote": ChannelTranslatedText,
|
||
"both": ChannelBoth, "alias": ChannelAlias, "surveyor": "", "": "",
|
||
} {
|
||
bank, err := DecodeBank([]byte(`{` + bankHead + `,"proposed":[{"src":"a","channel":` + quote(engine) + `}]}`))
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if got := (*bank.Offered)[0].Channel; got != want {
|
||
t.Errorf("channel %q read as %q, want %q", engine, got, want)
|
||
}
|
||
}
|
||
}
|
||
|
||
// The completeness numbers are COPIED and never re-derived: the engine computes `complete` off the
|
||
// render pass alone, and a classifier cut does not mean an incomplete bank. A second mechanism
|
||
// answering the same question on this side is how the two come to disagree — which is why the whole
|
||
// section is taken ready-made.
|
||
func TestTheCompletenessNumbersAreTakenReadyMade(t *testing.T) {
|
||
raw := `{"complete":false,"render_batches_dropped":5,"classify_batches_dropped":2,` +
|
||
`"consolidated":38,"declined":1,"unanswered":47,"never_asked":7}`
|
||
bank, err := DecodeBank([]byte(`{` + bankHead + `,"consolidation":` + raw + `}`))
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
var want wireConsolidation
|
||
if err := json.Unmarshal([]byte(raw), &want); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
got := *bank.Consolidation
|
||
if got.Complete != want.Complete || got.RenderBatchesDropped != want.RenderBatchesDropped ||
|
||
got.ClassifyBatchesDropped != want.ClassifyBatchesDropped || got.Consolidated != want.Consolidated ||
|
||
got.Declined != want.Declined || got.Unanswered != want.Unanswered || got.NeverAsked != want.NeverAsked {
|
||
t.Errorf("the section was rewritten on the way in:\n got %+v\nwant %+v", got, want)
|
||
}
|
||
}
|
||
|
||
// The ORACLE: a read-out a real run actually published, handed in by the operator. The fixtures above
|
||
// are this build's own idea of the document; this one is the engine's, and the two bought stop
|
||
// read-outs it was written against differ from each other in exactly the way that matters — one
|
||
// carries `conventions` on every row and the other on none.
|
||
//
|
||
// Gated like every test that needs something this host may not have, and it SAYS what is missing.
|
||
func TestARealReadOutIsReadTheWayThisBuildClaims(t *testing.T) {
|
||
path := os.Getenv("TM_PLATFORM_TEST_BANK_READOUT")
|
||
if path == "" {
|
||
t.Skip("TM_PLATFORM_TEST_BANK_READOUT not set: the reader is not checked against a read-out a real run published")
|
||
}
|
||
raw, err := os.ReadFile(path)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
bank, err := DecodeBank(raw)
|
||
if err != nil {
|
||
t.Fatalf("a read-out a real run published was refused: %v", err)
|
||
}
|
||
// What the document itself says, read independently of the type under test — otherwise the
|
||
// assertion is the decoder agreeing with itself.
|
||
var doc map[string]json.RawMessage
|
||
if err := json.Unmarshal(raw, &doc); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
section, present := doc["proposed"]
|
||
if present != (bank.Offered != nil) {
|
||
t.Fatalf("the document %s a `proposed` member and the reader reported the section %s",
|
||
ifElse(present, "carries", "does not carry"), ifElse(bank.Offered != nil, "present", "absent"))
|
||
}
|
||
if present {
|
||
var rows []map[string]any
|
||
if err := json.Unmarshal(section, &rows); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(rows) != len(*bank.Offered) {
|
||
t.Fatalf("the document carries %d suggestions and the reader returned %d", len(rows), len(*bank.Offered))
|
||
}
|
||
for i, row := range rows {
|
||
_, stated := row["conventions"]
|
||
if got := (*bank.Offered)[i].Conventions; stated != (got != nil) {
|
||
t.Fatalf("row %d: the document %s `conventions` and the reader reported it %s",
|
||
i, ifElse(stated, "states", "does not state"), ifElse(got != nil, "stated", "absent"))
|
||
}
|
||
if got := (*bank.Offered)[i].Channel; got == "" {
|
||
t.Errorf("row %d: this build cannot name the detector %q the engine wrote", i, row["channel"])
|
||
}
|
||
}
|
||
t.Logf("read-out %s: boundary=%s offered=%d terms=%d consolidation=%v",
|
||
path, bank.Boundary, len(*bank.Offered), len(bank.Terms), bank.Consolidation != nil)
|
||
}
|
||
if _, present := doc["consolidation"]; present != (bank.Consolidation != nil) {
|
||
t.Errorf("the document %s a `consolidation` member and the reader reported it %s",
|
||
ifElse(present, "carries", "does not carry"), ifElse(bank.Consolidation != nil, "present", "absent"))
|
||
}
|
||
if bank.Boundary == BoundaryUnknown {
|
||
t.Errorf("this build cannot name the boundary the engine wrote; the document's `as_of` is %s", doc["as_of"])
|
||
}
|
||
}
|
||
|
||
func quote(s string) string {
|
||
b, err := json.Marshal(s)
|
||
if err != nil {
|
||
panic(err)
|
||
}
|
||
return string(b)
|
||
}
|
||
|
||
func ifElse(cond bool, yes, no string) string {
|
||
if cond {
|
||
return yes
|
||
}
|
||
return no
|
||
}
|
||
|
||
// ⛔ EVERY RANGE THE CANON DECLARES IS ONE THIS READER ENFORCES — and the gate reads the canon rather
|
||
// than a copy of its numbers, so a field given a range tomorrow is covered without anybody
|
||
// remembering this test.
|
||
//
|
||
// It exists because the neighbouring gate proves MEMBERSHIP and stops there: it reads `required` and
|
||
// never `minimum`, which is the shape "take the form and not the guarantee" — in a gate. That gap let
|
||
// a canon promising `0..100` stand beside a chain that carried the engine's `-1` all the way out,
|
||
// pinned on both sides, for a whole delivery.
|
||
//
|
||
// Mutation this must catch: any numeric field whose canon range is not honoured by the decoder.
|
||
func TestEveryRangeTheCanonDeclaresIsOneThisReaderEnforces(t *testing.T) {
|
||
raw, err := os.ReadFile("../../../docs/architecture/14-api-contract/openapi.yaml")
|
||
if err != nil {
|
||
// Not skipped: a missing canon would leave these ranges with nothing to be checked against.
|
||
t.Fatalf("the ratified canon could not be read: %v", err)
|
||
}
|
||
var doc struct {
|
||
Components struct {
|
||
Schemas map[string]struct {
|
||
Properties map[string]struct {
|
||
Minimum *int `yaml:"minimum"`
|
||
Maximum *int `yaml:"maximum"`
|
||
} `yaml:"properties"`
|
||
} `yaml:"schemas"`
|
||
} `yaml:"components"`
|
||
}
|
||
if err := yaml.Unmarshal(raw, &doc); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
// The engine's field names, against the contract's — the reader is the thing that maps between
|
||
// them, so a gate over both has to carry the pair.
|
||
engineName := map[string]string{"freq": "freq", "spread": "spread",
|
||
"conventions": "conventions", "confidence": "conf"}
|
||
ranged := 0
|
||
for member, spec := range doc.Components.Schemas["OfferedTerm"].Properties {
|
||
if spec.Minimum == nil && spec.Maximum == nil {
|
||
continue
|
||
}
|
||
ranged++
|
||
engine, ok := engineName[member]
|
||
if !ok {
|
||
t.Errorf("the canon gives `%s` a range and this gate does not know which engine field feeds it", member)
|
||
continue
|
||
}
|
||
for _, outside := range outsideOf(spec.Minimum, spec.Maximum) {
|
||
bank, err := DecodeBank([]byte(fmt.Sprintf(`{%s,"proposed":[{"src":"x","%s":%d}]}`,
|
||
bankHead, engine, outside)))
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
got := map[string]*int{
|
||
"freq": (*bank.Offered)[0].Freq, "spread": (*bank.Offered)[0].Spread,
|
||
"conventions": (*bank.Offered)[0].Conventions, "confidence": (*bank.Offered)[0].Confidence,
|
||
}[member]
|
||
if got == nil {
|
||
continue // read as "not stated", which is inside the contract
|
||
}
|
||
if (spec.Minimum != nil && *got < *spec.Minimum) || (spec.Maximum != nil && *got > *spec.Maximum) {
|
||
t.Errorf("the canon bounds `%s` and the reader passed %d straight through from the engine's %d",
|
||
member, *got, outside)
|
||
}
|
||
}
|
||
}
|
||
// The gate's own denominator: a canon whose ranges this gate silently found none of would pass
|
||
// while proving nothing.
|
||
if ranged == 0 {
|
||
t.Fatal("the canon's OfferedTerm declares no ranges at all: this gate is reading the wrong schema")
|
||
}
|
||
t.Logf("ranges read from the canon and exercised: %d", ranged)
|
||
}
|
||
|
||
// outsideOf returns values just outside a declared range — one below the floor, one above the ceiling.
|
||
func outsideOf(min, max *int) []int {
|
||
var out []int
|
||
if min != nil {
|
||
out = append(out, *min-1)
|
||
}
|
||
if max != nil {
|
||
out = append(out, *max+1)
|
||
}
|
||
return out
|
||
}
|