package pipeline import ( "context" "os" "path/filepath" "strings" "testing" "textmachine/backend/internal/membank" "textmachine/backend/internal/obs" ) // snapshotdiff_test.go: the re-pin guard must name the field it COMPARED and nothing else. // // ⚠ ON THE FORM OF THESE ASSERTIONS (D39.171). The message is asserted through the ERROR THE GUARD // RETURNS, never by grepping a shared log buffer: a substring assertion over a buffer every test writes // into stays green when the sentence it claims to pin has moved elsewhere or disappeared. Here the string // under test is the return value of the call under test, so a changed message cannot pass unnoticed — and // the negative assertion below (the retired false sentence must be ABSENT) is what makes it a landing // rather than a decoration. // TestTheMovedFieldIsNamedAndTheOldLieIsGone is the A4 landing on the ordinary live shape: the BANK moved // and nothing else did. This is the cold run of 31.08 reproduced at $0 — there the two edit-wave payloads // differed in memory_version alone while prompt_sha256, model, temperature and reasoning were byte-equal, // and the guard told its reader «the config/prompts changed». // // Mutation this catches: restore the old sentence (or hard-code any cause) and BOTH assertions fire — the // positive one because memory_version is no longer named, the negative one because the retired claim is back. func TestTheMovedFieldIsNamedAndTheOldLieIsGone(t *testing.T) { rec := &reqRec{} srv := newJSONProvider(rec, draftEdit) defer srv.Close() bookPath := volumeBook(t, srv.URL, 2) dir := filepath.Dir(bookPath) ctx := obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()}) r1 := newRunner(t, bookPath) if _, err := r1.TranslateBook(ctx); err != nil { t.Fatal(err) } r1.Close() // Sign a term that occurs in the source: the bank moves, and with it the edit-wave snapshot — and // NOTHING else about the configuration is touched, which is the whole premise. doc := decisionsDocFor(t, dir, "test-book", membank.Decision{ Action: membank.ActionApprove, Src: "静か", Dst: "тихий"}) if _, err := ApplyBankDecisions(ctx, bookPath, doc, false); err != nil { t.Fatal(err) } r2 := newRunner(t, bookPath) defer r2.Close() // The re-payment consent gate sits BEFORE the snapshot guard and would stop the run with its own // message; consenting is not what is under test here. r2.AcceptRebill = RebillConsent{Given: true} _, err := r2.TranslateBook(ctx) // no --resnapshot: the guard must stop the run if err == nil { t.Fatal("a moved snapshot without --resnapshot must stop the run") } msg := err.Error() if !strings.Contains(msg, memoryVersionField) { t.Fatalf("the guard must NAME the field that actually moved (%s), got:\n%s", memoryVersionField, msg) } if !strings.Contains(msg, "that is the BANK") { t.Fatalf("a bank-only move must be called what it is, so the operator does not go looking for a "+ "config edit; got:\n%s", msg) } if strings.Contains(msg, "the config/prompts changed") { t.Fatalf("the retired claim asserts a cause the engine never compared — it must not come back:\n%s", msg) } // And it must not name axes that did NOT move: a message that lists everything names nothing. for _, absent := range []string{"prompt_sha256", "temperature", "model"} { if strings.Contains(msg, absent) { t.Fatalf("%q did not move on this book, so the guard must not name it:\n%s", absent, msg) } } } // TestAPromptEditIsNamedAsAPromptEdit is the same guard on the case its retired sentence was ACCIDENTALLY // right about — and it is here because a message that says "the bank" for every move would be the same // defect with a different constant. The prompt file is edited between runs; nothing else changes. func TestAPromptEditIsNamedAsAPromptEdit(t *testing.T) { rec := &reqRec{} srv := newJSONProvider(rec, draftEdit) defer srv.Close() bookPath := volumeBook(t, srv.URL, 2) dir := filepath.Dir(bookPath) ctx := obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()}) r1 := newRunner(t, bookPath) if _, err := r1.TranslateBook(ctx); err != nil { t.Fatal(err) } r1.Close() if err := os.WriteFile(filepath.Join(dir, "prompts", "editor.md"), []byte("Редактируй перевод ИНАЧЕ.\n---USER---\nИсходник: {{text}}\nЧерновик перевода для редактуры: {{draft}}"), 0o644); err != nil { t.Fatal(err) } r2 := newRunner(t, bookPath) defer r2.Close() r2.AcceptRebill = RebillConsent{Given: true} // as above: the consent gate is not the subject _, err := r2.TranslateBook(ctx) if err == nil { t.Fatal("an edited prompt moves the snapshot, so the run must stop without --resnapshot") } msg := err.Error() if !strings.Contains(msg, "prompt_sha256") { t.Fatalf("an edited prompt must be named as the prompt hash, not as something else:\n%s", msg) } if strings.Contains(msg, "that is the BANK") { t.Fatalf("this move is NOT a bank move, and saying so would be the retired defect with a new "+ "constant:\n%s", msg) } } // TestAnUncomparableMoveSaysSoInsteadOfGuessing is §2.2 in its pure form: with no stored payload there is // nothing to compare, and the guard must say that rather than fall back to a plausible cause. func TestAnUncomparableMoveSaysSoInsteadOfGuessing(t *testing.T) { got := describeSnapshotMove(snapshotFieldDiff("", `{"a":1}`)) if !strings.Contains(got, "cannot be named") || !strings.Contains(got, "do not assume") { t.Fatalf("with nothing to compare, the guard must decline to name a cause: %q", got) } if strings.Contains(got, "config") && !strings.Contains(got, "do not assume a config") { t.Fatalf("a declined comparison must not smuggle a cause back in: %q", got) } } // TestTheDiffIsDeterministicAndSorted pins the property the orchestrator asked for by name: the message // must not change between two runs over the same two payloads. Go map iteration is randomised per range, // so an unsorted walk fails this within a handful of iterations. func TestTheDiffIsDeterministicAndSorted(t *testing.T) { was := `{"z":1,"a":1,"m":{"q":1,"b":1},"stages":[{"model":"x","temperature":0.3}]}` now := `{"z":2,"a":2,"m":{"q":2,"b":2},"stages":[{"model":"y","temperature":0.3}]}` first, ok := snapshotFieldDiff(was, now) if !ok { t.Fatal("both payloads are valid JSON objects, so the comparison must succeed") } want := []string{"a", "m.b", "m.q", "stages[0].model", "z"} if strings.Join(first, "|") != strings.Join(want, "|") { t.Fatalf("paths must be sorted and dotted; got %v want %v", first, want) } for i := 0; i < 50; i++ { again, _ := snapshotFieldDiff(was, now) if strings.Join(again, "|") != strings.Join(first, "|") { t.Fatalf("the diff is not deterministic: %v then %v — an operator comparing two logs of the "+ "same move would be told two different stories", first, again) } } // temperature did not move and must not be reported. for _, p := range first { if strings.Contains(p, "temperature") { t.Fatalf("an unchanged field must not appear in the diff: %v", first) } } }