273 lines
14 KiB
Go
273 lines
14 KiB
Go
package membank
|
||
|
||
import (
|
||
"reflect"
|
||
"testing"
|
||
|
||
"textmachine/backend/internal/seed"
|
||
"textmachine/backend/internal/store"
|
||
)
|
||
|
||
// decisions_fuzz_test.go: the decision document is the ONE place where bytes produced by another
|
||
// process become engine state — that is the whole of D39.156 — so it is fuzzed rather than merely
|
||
// exampled, on the same rule the sibling module states for its own decoder.
|
||
//
|
||
// The oracles are phrased as "if the door said yes, then …", because a REFUSAL is always an acceptable
|
||
// answer to arbitrary bytes and only an ACCEPTANCE can be wrong. They are the invariants the rest of
|
||
// this door is built on, and each of them is one a table of examples can hold while the property
|
||
// underneath is false:
|
||
//
|
||
// 1. nothing panics, whatever arrives;
|
||
// 2. ALL OR NOTHING — if anything was rejected, both documents come back exactly as they went in.
|
||
// This is the invariant that protects a user's existing decisions from a half-applied call;
|
||
// 3. NOTHING IS SILENTLY DROPPED — every input decision appears exactly once, as accepted or as
|
||
// rejected. A decision that vanishes is the worst outcome available: the caller is told the call
|
||
// succeeded and the user's correction is simply not there;
|
||
// 4. an accepted result is a FIXED POINT — applying the same decisions again changes nothing and says
|
||
// `already_applied`. This is the retry contract as a caller experiences it, and it is the one property
|
||
// of the four that no single call inside ApplyDecisions already checks. (The oracle here used to be
|
||
// "the result loads", which the door had itself just verified two lines earlier — a tautology dressed
|
||
// as a check, and it was locked behind the same condition that made it unreachable when it mattered.)
|
||
|
||
// applyOracles runs one decoded document through the door and asserts the four invariants.
|
||
func applyOracles(t *testing.T, in ApplyInput) {
|
||
t.Helper()
|
||
res := ApplyDecisions(in) // oracle 1: a panic fails the fuzz run by itself
|
||
|
||
if len(res.Rejected) > 0 {
|
||
// Oracle 2. Compared through the canonical rendering, which is what the caller writes.
|
||
beforeD, _ := RenderSeedFile(in.Delta)
|
||
afterD, _ := RenderSeedFile(res.Delta)
|
||
beforeR, _ := RenderRejectFile(in.Rejects)
|
||
afterR, _ := RenderRejectFile(res.Rejects)
|
||
if string(beforeD) != string(afterD) || string(beforeR) != string(afterR) {
|
||
t.Fatalf("a refused call changed a document\ndelta before:\n%s\ndelta after:\n%s\nrejects before:\n%s\nrejects after:\n%s",
|
||
beforeD, afterD, beforeR, afterR)
|
||
}
|
||
if len(res.Accepted) != 0 {
|
||
t.Fatalf("a refused call reported %d accepted decisions", len(res.Accepted))
|
||
}
|
||
}
|
||
|
||
// Oracle 3. Index -1 is the document-level verdict and is not one of the input decisions.
|
||
seen := make([]int, len(in.Decisions))
|
||
for _, a := range res.Accepted {
|
||
if a.Index < 0 || a.Index >= len(in.Decisions) {
|
||
t.Fatalf("accepted decision carries index %d, outside the request of %d", a.Index, len(in.Decisions))
|
||
}
|
||
seen[a.Index]++
|
||
}
|
||
for _, r := range res.Rejected {
|
||
if r.Index == -1 {
|
||
continue
|
||
}
|
||
if r.Index < 0 || r.Index >= len(in.Decisions) {
|
||
t.Fatalf("rejected decision carries index %d, outside the request of %d", r.Index, len(in.Decisions))
|
||
}
|
||
seen[r.Index]++
|
||
}
|
||
if len(res.Rejected) == 0 {
|
||
// On an accepted call every decision must be accounted for exactly once. On a REFUSED call the
|
||
// accepted list is deliberately empty (oracle 2), so the count is only asserted here.
|
||
for i, n := range seen {
|
||
if n != 1 {
|
||
t.Fatalf("decision %d appears %d times in the report — a decision must never be silently dropped", i, n)
|
||
}
|
||
}
|
||
// Oracle 4: the RESULT is a fixed point. Re-applied through the bytes the door would actually
|
||
// write — which is the state the next call really starts from, not the in-memory document.
|
||
deltaRaw, rejectRaw := res.DeltaBytes, res.RejectBytes
|
||
nextDelta, derr := seed.DecodeFile(deltaRaw)
|
||
nextRejects, rerr := seed.DecodeRejects(rejectRaw)
|
||
if derr != nil || rerr != nil {
|
||
t.Fatalf("an accepted result would not read back: delta=%v rejects=%v\n%s\n%s", derr, rerr, deltaRaw, rejectRaw)
|
||
}
|
||
second := in
|
||
second.Delta, second.Rejects = nextDelta, nextRejects
|
||
again := ApplyDecisions(second)
|
||
if len(again.Rejected) != 0 {
|
||
t.Fatalf("re-applying an accepted set was refused — a retry must converge, not flip: %+v", again.Rejected)
|
||
}
|
||
for _, a := range again.Accepted {
|
||
if a.State != StateAlreadyApplied {
|
||
t.Fatalf("decision %d reports %q on a retry of a set already applied: %+v", a.Index, a.State, a)
|
||
}
|
||
}
|
||
if again.DeltaTouched || again.RejectsTouched {
|
||
t.Fatalf("a retry touched a document: delta=%v rejects=%v", again.DeltaTouched, again.RejectsTouched)
|
||
}
|
||
}
|
||
}
|
||
|
||
// FuzzDecisionFieldsThroughTheDoor fuzzes the path the door is actually driven through: arbitrary FIELD
|
||
// STRINGS of a Decision, folded by ApplyDecisions and written out.
|
||
//
|
||
// It exists because the sibling round-trip fuzzer has a structural blind spot, and the blocker of this
|
||
// pack walked straight through it. That one fuzzes raw YAML, so its corpus consists by construction of
|
||
// strings that have ALREADY survived a YAML parse; the door's real input is a JSON document, on which
|
||
// YAML imposes nothing at all. No value that breaks the emitter can be reached from that corpus — a
|
||
// leading newline in a note is lawful JSON and is not producible by parsing YAML into a plain scalar.
|
||
//
|
||
// The oracle is the one §4.1 asks for, on BOTH documents: what the engine WRITES, the engine READS BACK,
|
||
// and reads back the SAME. Not merely "it parses" — two measured values parsed fine and came back
|
||
// changed («\n» as empty, «\n x» as «x»), which is the silent half of the same defect.
|
||
func FuzzDecisionFieldsThroughTheDoor(f *testing.F) {
|
||
f.Add("方源", "Фан Юань", "\nне термин", "name", "старший", 0, 0)
|
||
f.Add("x", "y", "\n", "", "", 1, 2)
|
||
f.Add("x", "y", "\n вложенный", "", "", 0, 0)
|
||
f.Add("x", "y", "текст\n\n", "place", "", 0, 0)
|
||
f.Add(" пробелы ", "\tтаб", "a\nb", "", " sense ", 0, 0)
|
||
f.Add("", "", "", "", "", 0, 0)
|
||
f.Add("赵大", "Чжао Да", "конфликт с сидом", "name", "", 0, 0)
|
||
|
||
f.Fuzz(func(t *testing.T, src, dst, note, kind, sense string, since, until int) {
|
||
in := ApplyInput{
|
||
Seed: []store.GlossaryEntry{{Src: "赵大", Dst: "Чжао Да", Status: "approved", Source: "seed"}},
|
||
Decisions: []Decision{
|
||
{Action: ActionApprove, Src: src, Sense: sense, Dst: dst, Kind: kind, Note: note,
|
||
SinceChapter: since, UntilChapter: until},
|
||
{Action: ActionDecline, Src: src + "-alt", Note: note},
|
||
},
|
||
}
|
||
res := ApplyDecisions(in)
|
||
if len(res.Rejected) > 0 {
|
||
// A refusal is always an acceptable answer to arbitrary input — but it must leave NOTHING
|
||
// behind, and it must hand the caller no bytes to write. Asserted here rather than assumed:
|
||
// «returns early on a refusal» is how the old oracle 4 became unreachable exactly when it
|
||
// mattered, and this is the same shape one function along.
|
||
if res.DeltaBytes != nil || res.RejectBytes != nil {
|
||
t.Fatalf("a refused call handed the writer bytes: delta=%q rejects=%q", res.DeltaBytes, res.RejectBytes)
|
||
}
|
||
if len(res.Accepted) != 0 {
|
||
t.Fatalf("a refused call reported %d accepted decisions", len(res.Accepted))
|
||
}
|
||
if !reflect.DeepEqual(res.Delta.Normalize(), in.Delta.Normalize()) ||
|
||
!reflect.DeepEqual(res.Rejects.Normalize(), in.Rejects.Normalize()) {
|
||
t.Fatalf("a refused call changed a document:\n%+v\n%+v", res.Delta, res.Rejects)
|
||
}
|
||
return
|
||
}
|
||
if res.DeltaBytes == nil || res.RejectBytes == nil {
|
||
t.Fatalf("an accepted call must hand its caller the bytes it verified: delta=%v rejects=%v",
|
||
res.DeltaBytes != nil, res.RejectBytes != nil)
|
||
}
|
||
backDelta, derr := seed.DecodeFile(res.DeltaBytes)
|
||
if derr != nil {
|
||
t.Fatalf("the engine would write a delta it cannot read: %v\n%q", derr, res.DeltaBytes)
|
||
}
|
||
backRejects, rerr := seed.DecodeRejects(res.RejectBytes)
|
||
if rerr != nil {
|
||
t.Fatalf("the engine would write a reject list it cannot read: %v\n%q", rerr, res.RejectBytes)
|
||
}
|
||
// …and the SAME document, not merely a parseable one.
|
||
if want := res.Delta.Normalize(); !reflect.DeepEqual(backDelta.Normalize(), want) {
|
||
t.Fatalf("the delta reads back as another document:\nwrote %q\nheld %+v\nread %+v", res.DeltaBytes, want, backDelta.Normalize())
|
||
}
|
||
if want := res.Rejects.Normalize(); !reflect.DeepEqual(backRejects.Normalize(), want) {
|
||
t.Fatalf("the reject list reads back as another document:\nwrote %q\nheld %+v\nread %+v", res.RejectBytes, want, backRejects.Normalize())
|
||
}
|
||
// The result loads as a BANK, which is the next thing that happens to it.
|
||
if _, err := ParseBankSeed("fuzz", res.DeltaBytes); err != nil {
|
||
t.Fatalf("an accepted result would not load into the bank: %v\n%s", err, res.DeltaBytes)
|
||
}
|
||
})
|
||
}
|
||
|
||
// FuzzDecisionDocument drives the whole door from raw request bytes: decode, then apply against a small
|
||
// but non-trivial book — a signed seed with an alias, a ruby reading grafted onto it, a voice profile,
|
||
// and a delta that already holds a decision. Those four are exactly the inputs whose interaction the
|
||
// review found defects in, so they are the state the fuzzer explores against rather than an empty book.
|
||
func FuzzDecisionDocument(f *testing.F) {
|
||
f.Add(`{"decisions_version":"tm-bank-decisions-v1","book_id":"b","decisions":[{"action":"approve","src":"方源","dst":"Фан Юань"}]}`)
|
||
f.Add(`{"decisions_version":"tm-bank-decisions-v1","book_id":"b","decisions":[{"action":"decline","src":"花海"}]}`)
|
||
f.Add(`{"decisions_version":"tm-bank-decisions-v1","book_id":"b","decisions":[{"action":"approve","id":"0000000000000000","dst":"X"}]}`)
|
||
f.Add(`{"decisions_version":"tm-bank-decisions-v1","book_id":"b","decisions":[{"action":"approve","src":"a","dst":"b"},{"action":"decline","src":"a"}]}`)
|
||
f.Add(`{"decisions_version":"tm-bank-decisions-v1","book_id":"b","decisions":[{"action":"approve","src":"赵大","dst":"Другое"}]}`)
|
||
f.Add(`{"decisions_version":"tm-bank-decisions-v1","book_id":"b","decisions":[{"action":"approve","src":"x","sense":"s","since_chapter":-1,"dst":"y"}]}`)
|
||
f.Add(`{"decisions_version":"tm-bank-decisions-v1","book_id":"b","decisions":[]}`)
|
||
// The lawful decline of a SEED SURFACE the delta holds a row for — the livelock repair, and the one
|
||
// trajectory whose re-send the door used to refuse forever. Oracle 4 below is exactly the property
|
||
// it breaks; the corpus simply could not reach the state, because the delta fixture held no row for
|
||
// a seed surface. One row (老赵, an alias of the signed 赵大) and this seed put it in reach.
|
||
f.Add(`{"decisions_version":"tm-bank-decisions-v1","book_id":"b","decisions":[{"action":"decline","src":"老赵"}]}`)
|
||
f.Add(`{"decisions_version":"tm-bank-decisions-v1","book_id":"b","decisions":[{"action":"decline","src":"老赵"},{"action":"approve","src":"李青","dst":"Ли Цин"}]}`)
|
||
f.Add(`not json at all`)
|
||
|
||
bank := []store.GlossaryEntry{
|
||
{Src: "方源", Status: "auto", Source: "mined", Type: "name",
|
||
Aliases: []store.GlossaryAlias{{Alias: "方小子", AliasType: "mined"}}},
|
||
}
|
||
seedRows := []store.GlossaryEntry{
|
||
{Src: "赵大", Dst: "Чжао Да", Status: "approved", Source: "seed",
|
||
Aliases: []store.GlossaryAlias{{Alias: "老赵"}}},
|
||
{Src: "高橋", Dst: "Такахаси", Status: "approved", Source: "seed"},
|
||
}
|
||
|
||
f.Fuzz(func(t *testing.T, raw string) {
|
||
doc, err := DecodeDecisions([]byte(raw))
|
||
if err != nil {
|
||
return // a refusal is always an acceptable answer to arbitrary bytes
|
||
}
|
||
// Whatever the decoder ACCEPTED must satisfy the envelope contract.
|
||
if doc.Version != DecisionsVersion || doc.BookID == "" || len(doc.Decisions) == 0 {
|
||
t.Fatalf("the decoder accepted a document that violates its own envelope: %+v", doc)
|
||
}
|
||
applyOracles(t, ApplyInput{
|
||
Bank: bank, Seed: seedRows,
|
||
Ruby: []store.RubyReading{{Base: "高橋", Reading: "たかはし", Occurrences: 2, FirstChapter: 1}},
|
||
Voices: []store.VoiceProfile{{Src: "赵大", SinceCh: 1}},
|
||
Delta: seed.File{Terms: []seed.Term{
|
||
{Src: "花月", Dst: "Хуа Юэ", Status: "approved", Aliases: []seed.Alias{{Alias: "月妹", Type: "mined"}}},
|
||
// A row whose OWN src is a surface of the SIGNED seed term above (赵大's alias 老赵). It is
|
||
// the collision the seed-conflict carve-out exists to let the owner repair, and without it
|
||
// in the fixture the corpus cannot reach the state whose re-send used to be refused —
|
||
// oracle 4 owned the property and never got to exercise it.
|
||
{Src: "老赵", Dst: "Старина Чжао", Status: "approved"},
|
||
}},
|
||
Rejects: seed.RejectFile{Rejects: []seed.Reject{{Src: "水", Note: "n"}}},
|
||
Decisions: doc.Decisions,
|
||
})
|
||
})
|
||
}
|
||
|
||
// FuzzSeedDocumentRoundTrip pins the property the whole canonical-rewrite design rests on: what the
|
||
// engine WRITES, the engine can READ BACK, unchanged.
|
||
//
|
||
// Without it the byte-level idempotency contract is a hope. The door decides "this file needs no write"
|
||
// by rendering the document and comparing bytes; if rendering a parsed document could produce something
|
||
// that parses differently — or does not parse at all — a retry would either loop rewriting the same file
|
||
// or write a file the next run refuses. Both are silent until a paid run dies.
|
||
//
|
||
// Stated as: parse(x) = A ⟹ parse(render(A)) = A, and render(parse(render(A))) is byte-identical to
|
||
// render(A). Refusals at the first parse are an acceptable answer to arbitrary bytes.
|
||
func FuzzSeedDocumentRoundTrip(f *testing.F) {
|
||
f.Add("terms:\n - src: 方源\n dst: Фан Юань\n status: approved\n")
|
||
f.Add("terms: []\nvoices: []\naddresses: []\n")
|
||
f.Add("# only a comment\n")
|
||
f.Add("")
|
||
f.Add("terms:\n - src: \" пробелы \"\n dst: x\n aliases:\n - {alias: a, type: mined}\n")
|
||
f.Add("terms:\n - src: a\n dst: b\n decl: {invariant: true, forms: []}\n")
|
||
f.Add("voices:\n - src: a\n since_ch: 1\n")
|
||
|
||
f.Fuzz(func(t *testing.T, raw string) {
|
||
first, err := seed.DecodeFile([]byte(raw))
|
||
if err != nil {
|
||
return // refusing arbitrary bytes is always acceptable
|
||
}
|
||
rendered, err := RenderSeedFile(first)
|
||
if err != nil {
|
||
t.Fatalf("a document that PARSED cannot be rendered: %v", err)
|
||
}
|
||
second, err := seed.DecodeFile(rendered)
|
||
if err != nil {
|
||
t.Fatalf("the engine rendered a document it cannot read back: %v\n%s", err, rendered)
|
||
}
|
||
again, err := RenderSeedFile(second)
|
||
if err != nil {
|
||
t.Fatalf("re-render failed: %v", err)
|
||
}
|
||
if string(rendered) != string(again) {
|
||
t.Fatalf("rendering is not a fixed point — a retry would rewrite the file forever\nfirst:\n%s\nsecond:\n%s", rendered, again)
|
||
}
|
||
})
|
||
}
|