textmachine/backend/internal/checks/repair_test.go

249 lines
11 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 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")
}
}