package checks import ( "strings" "testing" "unicode/utf8" "textmachine/backend/internal/lang" ) // repair_test.go: the addressable-defect layer (pack-16). The load-bearing properties are (a) a candidate's // span really covers the defect, (b) an AMBIGUOUS anchor yields nothing, (c) the layer agrees with the lints // it mirrors, and (d) expansion/disjointness can never produce a splice that corrupts text. func testRepairCfg(t *testing.T) CheapGateConfig { t.Helper() return CheapGateConfig{Checkers: testCheckers(t)} } // span returns the substring a candidate would replace — the assertion that a span POINTS AT the defect // rather than merely existing. func span(text string, s [2]int) string { return text[s[0]:s[1]] } func TestRepairCandidateDC1Counted(t *testing.T) { cfg := testRepairCfg(t) src := "他闭关了三个时辰。" fin := "Он затворился на три часа и вышел." got := RepairCandidates(src, fin, cfg) if len(got) != 1 || got[0].Class != RepairDC1TimeUnits { t.Fatalf("want one dc1 candidate, got %+v", got) } if s := span(fin, got[0].DstSpan); s != "три часа" { t.Fatalf("dst span = %q, want the hours phrase «три часа» (the boundary char must stay out)", s) } if s := span(src, got[0].SrcSpan); s != "三个时辰" { t.Fatalf("src span = %q, want «三个时辰»", s) } } func TestRepairCandidateDC1Fractional(t *testing.T) { cfg := testRepairCfg(t) src := "他等了半个时辰。" fin := "Он прождал полчаса и ушёл." got := RepairCandidates(src, fin, cfg) if len(got) != 1 || got[0].Class != RepairDC1Fractional { t.Fatalf("want one fractional candidate, got %+v", got) } if s := span(fin, got[0].DstSpan); s != "полчаса" { t.Fatalf("dst span = %q, want «полчаса»", s) } } // The uniqueness guard is the mechanism that keeps a first-match↔first-match heuristic from addressing an // unrelated occurrence. Two hour phrases in one unit ⇒ NO addressable candidate (the class stays a flag). func TestRepairCandidateAmbiguousAnchorYieldsNothing(t *testing.T) { cfg := testRepairCfg(t) cases := []struct{ name, src, fin string }{ {"two-target-matches", "他闭关了三个时辰。", "Он ждал три часа, потом ещё два часа."}, {"two-source-matches", "他闭关了三个时辰,又等了五个时辰。", "Он затворился на три часа."}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { if got := RepairCandidates(c.src, c.fin, cfg); len(got) != 0 { t.Fatalf("ambiguous anchor must yield no candidate, got %+v", got) } }) } } func TestRepairCandidateLatinAndBrokenWord(t *testing.T) { cfg := testRepairCfg(t) fin := "Он открыл их again, а потом решил войть внутрь." got := RepairCandidates("", fin, cfg) if len(got) != 2 { t.Fatalf("want latin + broken-word candidates, got %+v", got) } byClass := map[RepairClass]string{} for _, c := range got { byClass[c.Class] = span(fin, c.DstSpan) } if byClass[RepairLatinResidue] != "again" { t.Fatalf("latin span = %q, want «again»", byClass[RepairLatinResidue]) } if byClass[RepairBrokenWord] != "войть" { t.Fatalf("broken-word span = %q, want «войть»", byClass[RepairBrokenWord]) } } // A pair/target with no data must produce nothing rather than panic — the no-pack path every non-zh book runs. func TestRepairCandidatesInertWithoutData(t *testing.T) { if got := RepairCandidates("他闭关了三个时辰。", "Он затворился на три часа.", CheapGateConfig{}); len(got) != 0 { t.Fatalf("a bare config must yield no candidates, got %+v", got) } empty := CompileCheckers(nil, lang.TargetChecks{}) if got := RepairCandidates("他等了半个时辰。", "Он прождал полчаса.", CheapGateConfig{Checkers: empty}); len(got) != 0 { t.Fatalf("a no-data pack must yield no candidates, got %+v", got) } } // TestRepairCandidatesAgreeWithLints is the equivalence proof the verdict-neutrality claim rests on: over a // corpus exercising both firing and non-firing paths, a class yields a candidate ONLY where its lint fires. // (The converse does not hold by design — the uniqueness guard suppresses candidates the lint still flags — // so the assertion is one-directional and the ambiguous cases are listed explicitly.) func TestRepairCandidatesAgreeWithLints(t *testing.T) { cfg := testRepairCfg(t) corpus := []struct{ src, fin string }{ {"他闭关了三个时辰。", "Он затворился на три часа."}, {"他闭关了三个时辰。", "Он затворился на шесть часов."}, {"他闭关了三个时辰。", "Он разделил это на три части."}, {"他等了半个时辰。", "Он прождал полчаса."}, {"他等了半个时辰。", "Он прождал час."}, {"", "Он открыл их again."}, {"", "Он решил войть внутрь."}, {"", "Совершенно чистый русский текст без дефектов."}, {"有千万条蛊虫。", "Там были тысячи гу-червей."}, {"", "Он вошёл в BANK и почувствовал Cultivation."}, // #6: caps leaks fire BOTH lint and twin } for _, c := range corpus { cands := RepairCandidates(c.src, c.fin, cfg) has := map[RepairClass]bool{} for _, k := range cands { has[k.Class] = true } dc1, _ := cfg.Checkers.lintTimeUnits(c.src, c.fin) if (has[RepairDC1TimeUnits] || has[RepairDC1Fractional]) && dc1 == 0 { t.Errorf("dc1 candidate without a lint hit: src=%q fin=%q", c.src, c.fin) } lat, _ := lintLatinResidue(c.fin, cfg.Allowlist) if has[RepairLatinResidue] && lat == 0 { t.Errorf("latin candidate without a lint hit: fin=%q", c.fin) } bw, _ := cfg.Checkers.lintBrokenWord(c.fin) if has[RepairBrokenWord] && bw == 0 { t.Errorf("broken-word candidate without a lint hit: fin=%q", c.fin) } } } // TestLatinResidueCandidateCapsSynced pins the #6 sync: after the lint dropped its caps-reject, the repair // twin must ALSO emit a candidate for a Title/ALL-CAPS leak — else the actuator is blind to exactly the class // the lint made visible (the drift the review caught: lint=1, candidates=0 on «Cultivation»/«BANK»). The // allowlist suppresses BOTH sides case-insensitively. func TestLatinResidueCandidateCapsSynced(t *testing.T) { fin := "Он вошёл в BANK и почувствовал Cultivation." spans := latinResidueSpans(fin, nil) if !spans["BANK"] { t.Errorf("ALL-CAPS «BANK» must yield a repair candidate (the lint fires on it since #6)") } if !spans["Cultivation"] { t.Errorf("Title-case «Cultivation» must yield a repair candidate") } if n, _ := lintLatinResidue(fin, nil); n != 2 { t.Fatalf("the lint must flag exactly the two caps leaks (agreement), got %d", n) } // A case-insensitive allowlist suppresses BOTH the twin and the lint. allow := map[string]bool{"bank": true, "cultivation": true} if got := latinResidueSpans(fin, allow); len(got) != 0 { t.Errorf("a lower-folded allowlist must suppress the caps candidates, got %v", got) } if n, _ := lintLatinResidue(fin, allow); n != 0 { t.Errorf("the lint must also honour the lower-folded allowlist, got %d", n) } } // latinResidueSpans maps each latin-residue candidate's replaced surface → true, for the sync assertions. func latinResidueSpans(fin string, allow map[string]bool) map[string]bool { out := map[string]bool{} for _, c := range latinResidueCandidates(fin, allow) { out[span(fin, c.DstSpan)] = true } return out } func TestExpandToSentence(t *testing.T) { text := "Первое предложение. Он ждал три часа тут. Третье предложение." i := strings.Index(text, "три часа") got := ExpandToSentence(text, [2]int{i, i + len("три часа")}) if s := span(text, got); s != "Он ждал три часа тут." { t.Fatalf("expanded span = %q, want the whole middle sentence", s) } } // Overlap is the case that would corrupt a paid run: two classes inside ONE sentence expand to the same span, // and splicing both would cut an already-mutated string. The second candidate must be dropped, not repaired. func TestDisjointCandidatesDropsOverlap(t *testing.T) { cfg := testRepairCfg(t) fin := "Он открыл их again и решил войть внутрь." cands := RepairCandidates("", fin, cfg) if len(cands) != 2 { t.Fatalf("premise broken: want 2 candidates in one sentence, got %+v", cands) } got := DisjointCandidates(fin, cands) if len(got) != 1 { t.Fatalf("overlapping candidates must collapse to one, got %d: %+v", len(got), got) } if got[0].DstSpan[0] < 0 || got[0].DstSpan[1] > len(fin) { t.Fatalf("expanded span out of bounds: %+v", got[0].DstSpan) } } // Every span this package hands out must be safe to splice: inside the text and on rune boundaries. A cut // mid-rune produces invalid UTF-8 that no downstream check inspects. func TestRepairSpansAreSpliceSafe(t *testing.T) { cfg := testRepairCfg(t) src, fin := "他等了半个时辰,又闭关了三个时辰。", "Он прождал полчаса, открыл их again и решил войть внутрь." for _, c := range DisjointCandidates(fin, RepairCandidates(src, fin, cfg)) { if c.DstSpan[0] < 0 || c.DstSpan[1] > len(fin) || c.DstSpan[0] >= c.DstSpan[1] { t.Fatalf("dst span %v is not a valid range of a %d-byte text", c.DstSpan, len(fin)) } head, tail := fin[:c.DstSpan[0]], fin[c.DstSpan[1]:] if !utf8.ValidString(head) || !utf8.ValidString(span(fin, c.DstSpan)) || !utf8.ValidString(tail) { t.Fatalf("splicing at %v breaks UTF-8", c.DstSpan) } if c.hasSrc() && (c.SrcSpan[1] > len(src) || c.SrcSpan[0] >= c.SrcSpan[1]) { t.Fatalf("src span %v is not a valid range of a %d-byte source", c.SrcSpan, len(src)) } } } // TestHourWordProbeBoundaries pins the mini-delta fix: the bare hour word carries BOTH word boundaries, so a // reply that DELETES the duration and leaves any «-час» filler cannot satisfy the fractional class's positive // post-condition. Without the left boundary the stem matched inside «полчаса», «тотчас» and «сейчас» — i.e. // the guard accepted exactly the deletion it exists to refuse. func TestHourWordProbeBoundaries(t *testing.T) { c := testCheckers(t) cases := []struct { text string want bool }{ {"Он прождал час и вошёл.", true}, {"Он прождал шесть часов.", true}, {"Прошло два часа.", true}, {"час", true}, {"Он прождал полчаса и вошёл.", false}, // the defect itself is not a duration statement {"Он прождал тотчас и вошёл.", false}, // filler on «-час» — the deletion the orchestrator named {"Он прождал сейчас и вошёл.", false}, {"Он вошёл внутрь.", false}, } for _, tc := range cases { if got := MentionsHourWord(c, tc.text); got != tc.want { t.Errorf("MentionsHourWord(%q) = %v, want %v", tc.text, got, tc.want) } } // A pair without the probe cannot have the invariant asserted → the answer is "no", so the guard rejects // rather than waving a repair through. if MentionsHourWord(CompileCheckers(nil, lang.TargetChecks{}), "Он прождал час.") { t.Fatal("a pair with no hour-word probe must not be able to satisfy the positive condition") } }