139 lines
6.7 KiB
Go
139 lines
6.7 KiB
Go
package pipeline
|
||
|
||
import (
|
||
"context"
|
||
"strings"
|
||
"testing"
|
||
|
||
"textmachine/backend/internal/obs"
|
||
)
|
||
|
||
// regressionguard_live_test.go: the post-reflow regression guard, driven through the REAL runner
|
||
// (norm 11 — a sandbox reproduction through the driver, not a unit call on the flagger).
|
||
//
|
||
// The unit tests in internal/checks already pin the flagger's arithmetic. What was never pinned — and
|
||
// what D38.4 point 5 actually obliged us to deliver — is the WIRING: the gate config reaching the cheap
|
||
// gates, the edit wave passing the real DRAFT (not the final twice), and the hit landing in durable
|
||
// observability. The obligation went unmet for a whole pere-run (D39.34 finding 1: `regression_guard`
|
||
// was absent from every shipping config, so the guard was dark), which is exactly the class of failure a
|
||
// wiring test catches and a unit test cannot.
|
||
//
|
||
// The fixture makes the editor do what the guard exists to catch: it returns a text that is far shorter
|
||
// than the draft AND has lost a multi-digit number. Nothing else in the cheap-gate battery may fire on
|
||
// this text, so the asserted counts are the guard's alone.
|
||
|
||
// rgDraft is a draft long enough for the collapse ratio to be meaningful (>200 non-space chars) that
|
||
// carries two multi-digit numbers. No dialogue dashes, no Latin, no ё/е pair, no CJK magnitude — so no
|
||
// other cheap gate has anything to say about it.
|
||
const rgDraft = "Утро выдалось тихим, и в читальном зале пахло старой бумагой. " +
|
||
"Он просидел над свитками 44 минуты, пока свет не сместился к дальней стене. " +
|
||
"Потом пришли остальные, и зал наполнился шорохом страниц и негромкими голосами. " +
|
||
"К полудню в списке значилось 128 имён, и каждое из них требовалось сверить с описью. " +
|
||
"Работа была скучной, но её надо было довести до конца, потому что иначе опись теряла смысл."
|
||
|
||
// rgShortFinal is what a reflow that DROPPED content looks like: a fraction of the length, and the
|
||
// numbers are gone. Under the guard this is one length collapse + two disappeared numbers.
|
||
const rgShortFinal = "Утро выдалось тихим, в зале пахло бумагой."
|
||
|
||
// rgProvider answers the draft with rgDraft and the edit with rgShortFinal.
|
||
func rgProvider(body string) (string, string) {
|
||
if isEditBody(body) {
|
||
return rgShortFinal, "stop"
|
||
}
|
||
return rgDraft, "stop"
|
||
}
|
||
|
||
// regressionStyleFlags runs one book end-to-end with the given gates block and returns the leader
|
||
// chunk's durable style-flag count and its JSON detail.
|
||
func regressionStyleFlags(t *testing.T, gatesYAML string) (int, string) {
|
||
t.Helper()
|
||
rec := &reqRec{}
|
||
srv := newJSONProvider(rec, rgProvider)
|
||
defer srv.Close()
|
||
bookPath := setupProjectOpts(t, srv.URL, projectOpts{
|
||
source: suzukiSource,
|
||
glossarySeed: suzukiSeed, // retrieval_state rows exist only for a book with a bank
|
||
gatesYAML: gatesYAML,
|
||
regenerate: 0,
|
||
})
|
||
r := newRunner(t, bookPath)
|
||
defer r.Close()
|
||
if _, err := r.TranslateBook(obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()})); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
rs, err := r.Store.GetRetrievalState("test-book", 1, 0)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if rs == nil {
|
||
t.Fatal("no retrieval_state row for the leader chunk — the observability channel itself is dark")
|
||
}
|
||
return rs.NStyleFlags, rs.StyleDetail
|
||
}
|
||
|
||
// TestRegressionGuardFiresLiveWhenEnabled is the obligation D38.4 point 5 left unpaid: with the gate ON
|
||
// a reflow that halved the text and lost its numbers must be VISIBLE in the durable per-chunk
|
||
// observability, with both flaggers naming themselves.
|
||
func TestRegressionGuardFiresLiveWhenEnabled(t *testing.T) {
|
||
n, detail := regressionStyleFlags(t, "\ngates:\n regression_guard:\n enabled: true\n")
|
||
if n == 0 {
|
||
t.Fatalf("regression guard enabled but the chunk records 0 style flags — the gate is wired to nothing")
|
||
}
|
||
for _, want := range []string{`"length_collapse":1`, `"number_drift":2`} {
|
||
if !strings.Contains(detail, want) {
|
||
t.Errorf("style detail does not carry %s:\n%s", want, detail)
|
||
}
|
||
}
|
||
// The human-readable lines must survive to the detail too — a count with no sentence is not
|
||
// observability, it is a number nobody can action.
|
||
for _, want := range []string{"length collapse", "44", "128"} {
|
||
if !strings.Contains(detail, want) {
|
||
t.Errorf("style detail is missing %q:\n%s", want, detail)
|
||
}
|
||
}
|
||
if n != 3 { // 1 collapse + 2 disappeared numbers
|
||
t.Errorf("style flags = %d, want 3 (1 length collapse + 2 number drifts); detail: %s", n, detail)
|
||
}
|
||
}
|
||
|
||
// TestRegressionGuardSilentWhenDisabled is the other half of the same wiring claim: the gate is OPT-IN,
|
||
// so the identical run with no gates block must record nothing. Without this, a test asserting only the
|
||
// firing side would pass just as happily if the guard ran unconditionally — and the "byte-identical when
|
||
// off" property (cheapgates.go) would be unpinned.
|
||
func TestRegressionGuardSilentWhenDisabled(t *testing.T) {
|
||
n, detail := regressionStyleFlags(t, "")
|
||
if n != 0 {
|
||
t.Fatalf("regression guard is opt-in but a run without it recorded %d style flags: %s", n, detail)
|
||
}
|
||
}
|
||
|
||
// TestRegressionGuardComparesDraftAgainstFinal pins WHICH two texts the edit wave hands the guard. The
|
||
// gate is only meaningful over the draft→final transform; a wiring that passed the final twice (the shape
|
||
// the draft-only wave legitimately uses) would silently report a clean book forever, because a text never
|
||
// collapses against itself. Asserted by making the editor return the draft UNCHANGED: same content, so a
|
||
// correct wiring is silent — and then the fixture above, which differs only in the editor's answer,
|
||
// proves the channel is not simply dead.
|
||
func TestRegressionGuardComparesDraftAgainstFinal(t *testing.T) {
|
||
rec := &reqRec{}
|
||
srv := newJSONProvider(rec, func(body string) (string, string) { return rgDraft, "stop" })
|
||
defer srv.Close()
|
||
bookPath := setupProjectOpts(t, srv.URL, projectOpts{
|
||
source: suzukiSource,
|
||
glossarySeed: suzukiSeed,
|
||
gatesYAML: "\ngates:\n regression_guard:\n enabled: true\n",
|
||
regenerate: 0,
|
||
})
|
||
r := newRunner(t, bookPath)
|
||
defer r.Close()
|
||
if _, err := r.TranslateBook(obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()})); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
rs, err := r.Store.GetRetrievalState("test-book", 1, 0)
|
||
if err != nil || rs == nil {
|
||
t.Fatalf("retrieval_state: %v %v", rs, err)
|
||
}
|
||
if rs.NStyleFlags != 0 {
|
||
t.Fatalf("an editor that returned the draft unchanged must not trip the guard, got %d flags: %s",
|
||
rs.NStyleFlags, rs.StyleDetail)
|
||
}
|
||
}
|