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