textmachine/backend/internal/membank/memory_f1_test.go

144 lines
7.6 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 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 = "9b72a69581b9a68326fea2707d425ad5e3f83b764aa8638dde79b57f58f97916"
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 = "fbb199aec15467c1a81c8206f025f6dd2bb7221286092c5c0b8eca7452390a86"
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)
}
}