package membank import ( "testing" "textmachine/backend/internal/store" ) // memory_f1_test.go covers the F1 content-hash (D8): memoryVersion is a pure function of // the frozen APPROVED rows + the algorithm versions. A change to the approved glossary // re-pins loudly; auto/draft churn does not; the row id never leaks in. func TestMemoryVersionStableAndSensitive(t *testing.T) { base := []store.GlossaryEntry{ gl("阿Q", "А-кью", "name", "approved"), gl("鲁镇", "Лучжэнь", "", "approved"), } v0 := ComputeVersion(base, false) // Identical rows → identical version (idempotent; drift-proof content hash). if ComputeVersion(base, false) != v0 { t.Fatal("memoryVersion is not stable for identical rows") } // Changing an APPROVED dst → different version (a loud --resnapshot). changed := []store.GlossaryEntry{ gl("阿Q", "А-Кью (изменено)", "name", "approved"), gl("鲁镇", "Лучжэнь", "", "approved"), } if ComputeVersion(changed, false) == v0 { t.Error("changing an approved dst did not change memoryVersion — F1 would silently regress") } // Adding an alias to an approved row → different version (aliases affect matching). withAlias := []store.GlossaryEntry{ func() store.GlossaryEntry { e := gl("阿Q", "А-кью", "name", "approved") e.Aliases = []store.GlossaryAlias{alias("阿桂")} return e }(), gl("鲁镇", "Лучжэнь", "", "approved"), } if ComputeVersion(withAlias, false) == v0 { t.Error("adding an alias to an approved row did not change memoryVersion") } // PACK-20 CONTRACT CHANGE (D39.42 п.3), superseding the D8 approved-only fold. An AUTO row now MOVES // the version, because in the auto mode it is CONTENT the model is shown: it changes the rendered // injection, hence content_hash, hence the resume fast-path — while the snapshot (and with it // projectRebill's re-payment projection, the Р6 consent contour) stayed blind. Folding it makes a bank // edit of ANY status a loud --resnapshot instead of a silent per-chunk re-translate. withAuto := append(append([]store.GlossaryEntry{}, base...), gl("小D", "Малыш Дэ", "", "auto")) if ComputeVersion(withAuto, false) == v0 { t.Error("adding an auto row must move memoryVersion — its content reaches the wire (D39.42 п.3)") } withAuto2 := append(append([]store.GlossaryEntry{}, base...), gl("小D", "Другой Дэ", "", "auto")) if ComputeVersion(withAuto, false) == ComputeVersion(withAuto2, false) { t.Error("editing an auto row must move memoryVersion — the injected bytes changed") } // The other half of the same contract, and the reason shipping this re-bills nobody who had nothing // unverified: a bank that is entirely APPROVED hashes exactly as it did before pack-20. if ComputeVersion(base, false) != v0 { t.Error("an all-approved bank must hash unchanged — else every existing book re-pins for nothing") } } // TestMemoryVersionIgnoresID: the autoincrement glossary.id is fresh on every replace; // it must NOT feed the hash (else a re-seed of identical content would false-resnapshot). func TestMemoryVersionIgnoresID(t *testing.T) { a := []store.GlossaryEntry{{BookID: "b", ID: 1, Src: "甲", Dst: "А", Status: "approved"}} b := []store.GlossaryEntry{{BookID: "b", ID: 999, Src: "甲", Dst: "А", Status: "approved"}} if ComputeVersion(a, false) != ComputeVersion(b, false) { t.Error("memoryVersion depends on glossary.id — a re-seed would false-resnapshot") } } // TestMaterializeMemoryVersionMatches: the bank's Version() equals ComputeVersion // of its input rows (the runner hashes the same thing it matches on). func TestMaterializeMemoryVersionMatches(t *testing.T) { rows := specGlossary() b := Materialize(rows, false) if b.Version() != ComputeVersion(rows, false) { t.Error("bank Version() diverges from ComputeVersion of its rows") } } // TestMemoryVersionGateFoldsUnapprovedDecl covers self-review #4: when the post-check // hard gate is ON, editing a DRAFT/auto row's decl (which changes the gate's resolved // disposition but is in NEITHER content_hash NOR the approved-only hash) MUST change // memoryVersion — else a $0 resume silently flips a chunk. Gate OFF: it must NOT change // it (D8 approved-only holds; decl affects only the recomputed retrieval-state). func TestMemoryVersionGateFoldsUnapprovedDecl(t *testing.T) { mk := func(declForm string) []store.GlossaryEntry { e := gl("鈴木", "Судзуки", "", "draft") e.Decl = declJSON(false, declForm) return []store.GlossaryEntry{gl("阿Q", "А-кью", "", "approved"), e} } a := mk("Судзуки") b := mk("Судзуку") // only the DRAFT decl form changed (dst unchanged, status draft) // PACK-20 (D39.42 п.3): the fold no longer depends on the gate — EVERY row's content is hashed on // both settings. The self-review #4 hole this test was written for is therefore closed unconditionally, // not only when the gate happens to be on. if ComputeVersion(a, false) == ComputeVersion(b, false) { t.Error("gate OFF: a draft decl change must ALSO move memoryVersion now (D39.42 п.3)") } if ComputeVersion(a, true) == ComputeVersion(b, true) { t.Error("gate ON: a draft decl change MUST move memoryVersion (else a resume silently flips the chunk)") } // Toggling the gate itself also changes the version (a mode change is a loud re-pin). if ComputeVersion(a, false) == ComputeVersion(a, true) { t.Error("toggling the post-check gate must change memoryVersion") } } // TestMemoryVersionPinsTheUnverifiedRenderRevision is a GOLDEN pin, and it is the only shape that can hold // this contract: the render-revision marker exists to differ from a PREVIOUS BINARY, so no comparison // between two live calls can see it. Pack-20 gave the editor block a second, separately-headed section for // non-Confirmed rows. For a book whose post-check gate was already ON, the row set folded is identical // before and after — same version, same snapshot — while the wire bytes changed, and projectRebill skips a // unit whose snapshot is unchanged. That is a silent re-payment outside the Р6 consent contour. The marker // moves exactly the books that render the section, so their units re-pin ($0 where the bytes did not // change) instead of being re-bought without consent. // // If this literal has to change, the render of the unverified section changed with it — and every book // holding a non-Confirmed row will re-pin on the next run. That is the intended, and the only honest, // price. Bumping RenderFormatVersion instead would move EVERY book, including those that render no such // section, and a format bump is not a bank-only move, so none of them could re-pin at $0. func TestMemoryVersionPinsTheUnverifiedRenderRevision(t *testing.T) { withDraft := []store.GlossaryEntry{ {Src: "方源", Dst: "Фан Юань", Type: "name", Status: "approved"}, {Src: "花家", Dst: "клан Хуа", Type: "name", Status: "draft"}, } const wantWithDraft = "d3193be2a471468a4cf4e047da7fbb081f2578f3052538d91e1fed41605736f2" if got := ComputeVersionScoped(withDraft, true, false); got != wantWithDraft { t.Errorf("the unverified-render revision is part of the version:\n got %s\nwant %s", got, wantWithDraft) } // A bank with nothing unverified renders no such section and must hash as if the marker did not exist — // this is what keeps the change from re-billing books that had nothing to re-render. const wantAllApproved = "ecb625f264bd56a840bd3a940b01fb2a86d16f22641efcb413cdbfbdb8388b72" if got := ComputeVersionScoped(withDraft[:1], true, false); got != wantAllApproved { t.Errorf("an all-approved bank must be untouched by the marker:\n got %s\nwant %s", got, wantAllApproved) } }