package pipeline import ( "strings" "testing" ) // regressionguard_test.go — the post-reflow regression guard (D38). Precision over recall: each // flagger asserts BOTH firing on a real regression AND non-firing on a legitimate reflow. func TestRegressionGuardLengthCollapse(t *testing.T) { // A draft well over the min-chars floor, halved by the "reflow" → collapse fires. draft := strings.Repeat("Судзуки медленно шёл по коридору академии магии. ", 12) // ~560 non-space chars shortFinal := "Судзуки шёл." if r := runRegressionGuard(draft, shortFinal); r.LengthCollapse == 0 { t.Errorf("expected a length-collapse flag (draft≫final)") } // A legitimate reflow that merges lines keeps ~all the content → must NOT fire. sameFinal := strings.Repeat("Судзуки медленно шёл по коридору академии магии. ", 11) if r := runRegressionGuard(draft, sameFinal); r.LengthCollapse != 0 { t.Errorf("length-collapse FALSE POSITIVE on a near-equal reflow: %v", r.Detail) } // A short draft below the floor must never fire (a % ratio is meaningless there). if r := runRegressionGuard("Он ушёл.", "Он."); r.LengthCollapse != 0 { t.Errorf("length-collapse fired below the min-chars floor: %v", r.Detail) } } func TestRegressionGuardNumberDrift(t *testing.T) { // 四成四=44% rendered as «44» in the draft, drifted to a word form in the final → 44 disappears. if r := runRegressionGuard("У него было 44 процента запаса.", "У него было четыре десятых запаса."); r.NumberDrift == 0 { t.Errorf("expected a number-drift flag (44 vanished)") } // A number APPEARING in the final that was not in the draft (hallucinated figure). if r := runRegressionGuard("Он собрал войско.", "Он собрал войско из 3000 солдат."); r.NumberDrift == 0 { t.Errorf("expected a number-drift flag (3000 appeared)") } clean := []struct{ draft, final string }{ // The multi-digit number is preserved across the reflow (44 on both sides). {"У него было 44 процента.", "У него имелось 44 процента запаса."}, // Grouped-triple vs joined form of the SAME number must not drift (10 000 ≡ 10000). {"Армия в 10 000 воинов.", "Армия насчитывала 10000 воинов."}, // SINGLE digits are excluded (routinely spelled out), so «5»→«пять» is not a drift. {"У него было 5 мечей.", "У него было пять мечей."}, // No numbers at all — never fires. {"Судзуки шёл по коридору академии магии.", "Судзуки медленно шёл по длинному коридору."}, } for _, c := range clean { if r := runRegressionGuard(c.draft, c.final); r.NumberDrift != 0 { t.Errorf("number-drift FALSE POSITIVE on %q→%q: %v", c.draft, c.final, r.Detail) } } } // TestRunCheapGatesRegressionOptIn pins that the guard is OFF by default and folds into the // observability total only when enabled (a book that does not opt in serialises identically). func TestRunCheapGatesRegressionOptIn(t *testing.T) { draft := strings.Repeat("Судзуки медленно шёл по коридору академии магии. ", 12) final := "Судзуки шёл." off := runCheapGates("", draft, final, cheapGateConfig{yoPolicy: "auto", allowlist: map[string]bool{}}) if off.LengthCollapse != 0 || off.NumberDrift != 0 { t.Errorf("regression guard fired while disabled: %+v", off) } on := runCheapGates("", draft, final, cheapGateConfig{yoPolicy: "auto", allowlist: map[string]bool{}, regressionEnabled: true}) if on.LengthCollapse == 0 { t.Errorf("regression guard did not fire while enabled: %+v", on) } if on.total() <= off.total() { t.Errorf("enabled guard must raise the observability total: off=%d on=%d", off.total(), on.total()) } } // TestRegressionGuardDeterministic — a pure function of (draft, final), resume-safe. func TestRegressionGuardDeterministic(t *testing.T) { d := strings.Repeat("текст с числом 44 и 128. ", 20) f := "короткий финал без чисел" a, b := runRegressionGuard(d, f), runRegressionGuard(d, f) if a.LengthCollapse != b.LengthCollapse || a.NumberDrift != b.NumberDrift || strings.Join(a.Detail, "|") != strings.Join(b.Detail, "|") { t.Fatalf("runRegressionGuard not deterministic: %+v vs %+v", a, b) } }