208 lines
11 KiB
Go
208 lines
11 KiB
Go
package checks
|
||
|
||
import (
|
||
"testing"
|
||
|
||
"textmachine/backend/internal/lang"
|
||
)
|
||
|
||
// testLangPack loads the real in-repo zh→ru language pack (configs/langpacks/) so the fixtures run the
|
||
// checker ALGORITHM over the pack's OWN patterns and tables — no pair literal is duplicated in a test.
|
||
func testLangPack(t *testing.T) *lang.Pack {
|
||
t.Helper()
|
||
p, err := lang.Load("../../configs/langpacks", "zh", "ru")
|
||
if err != nil {
|
||
t.Fatalf("load langpack: %v", err)
|
||
}
|
||
return p
|
||
}
|
||
|
||
// testCheckers builds the compiled checker spec from the REAL zh-ru pack + ru target data (pair-14 data-out):
|
||
// the fixtures exercise the checker ALGORITHM over the pack's own DETECTION patterns / tables / wordlists, so
|
||
// no pair literal is duplicated in the test — a pattern edit in the langpack flows straight into these cases.
|
||
func testCheckers(t *testing.T) *Checkers {
|
||
t.Helper()
|
||
return CompileCheckers(testLangPack(t).DCCheckers, lang.TargetChecksFor("ru"))
|
||
}
|
||
|
||
// checkers_zh_ru_test.go: WS5 (г) — the DC1/DC2/DC6 checkers must CATCH the empirical trap positives
|
||
// (ws5_checkers_verify.py §a) and stay SILENT on clean/in-register text (precision over recall). These
|
||
// are the regression fixtures the plan requires (positives from exp15 §7.9 / exp14b).
|
||
|
||
func TestDC1TimeUnits(t *testing.T) {
|
||
cases := []struct {
|
||
name, src, tgt string
|
||
wantFlag bool
|
||
}{
|
||
{"count-as-hours", "他闭关了三个时辰。", "Он затворился на три часа.", true}, // 三时辰=6h rendered «три часа»
|
||
{"correct-conversion", "他闭关了三个时辰。", "Он затворился на шесть часов.", false}, // 6h — correct, no flag
|
||
{"paraphrase-no-hours", "他闭关了三个时辰。", "Он затворился надолго.", false}, // no hours count → valid
|
||
{"no-shichen", "他走了三里。", "Он прошёл три ли.", false},
|
||
{"arabic-count", "过了2个时辰。", "Прошло 2 часа.", true}, // 2时辰=4h rendered «2 часа»
|
||
}
|
||
dcc := testCheckers(t)
|
||
for _, c := range cases {
|
||
t.Run(c.name, func(t *testing.T) {
|
||
n, _ := dcc.lintTimeUnits(c.src, c.tgt)
|
||
if (n > 0) != c.wantFlag {
|
||
t.Fatalf("lintTimeUnits(%q,%q) fired=%v, want %v", c.src, c.tgt, n > 0, c.wantFlag)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestDC2MagnitudeScale(t *testing.T) {
|
||
cases := []struct {
|
||
name, src, tgt string
|
||
wantFlag bool
|
||
}{
|
||
{"qianwan-as-thousands", "有千万条蛊虫。", "Там были тысячи гу-червей.", true}, // 千万=10M as «тысячи»
|
||
{"qianwan-correct", "有千万条蛊虫。", "Там были десятки миллионов гу-червей.", false}, // «миллион» present → ok
|
||
{"shushiwan-as-tens", "聚集了数十万人。", "Собрались десятки тысяч человек.", true}, // 数十万 as «десятки тысяч»
|
||
{"shushiwan-correct", "聚集了数十万人。", "Собрались сотни тысяч человек.", false}, // «сотни тысяч» → ok
|
||
// ws5 parity (R4-review MAJOR): a CORRECT rendering «сотни тысяч» SUPPRESSES the flag even when
|
||
// «десятки тысяч» appears elsewhere as an unrelated quantity (the ok_re guard).
|
||
{"shushiwan-okre-suppress", "聚集了数十万人。", "Здесь сотни тысяч воинов, а не десятки тысяч слуг.", false},
|
||
// ws5 parity (R4-review MINOR): the inner fire check is CASE-SENSITIVE (reference no re.I), so a
|
||
// sentence-initial capitalized «Десятки тысяч» does NOT fire.
|
||
{"case-sensitive-capitalized", "聚集了数十万人。", "Десятки тысяч человек собрались.", false},
|
||
{"no-magnitude", "他有三个朋友。", "У него три друга.", false},
|
||
}
|
||
dcc := testCheckers(t)
|
||
for _, c := range cases {
|
||
t.Run(c.name, func(t *testing.T) {
|
||
n, _ := dcc.lintMagnitudeScale(c.src, c.tgt)
|
||
if (n > 0) != c.wantFlag {
|
||
t.Fatalf("lintMagnitudeScale(%q,%q) fired=%v, want %v", c.src, c.tgt, n > 0, c.wantFlag)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestDC6RegisterLexicon(t *testing.T) {
|
||
// The register blocklist now comes from the BOOK config (book.yaml register_blocklist → CheapGateConfig,
|
||
// D39.79 Q4), not the pair pack. These fixtures exercise the checker ALGORITHM (whole-word, target
|
||
// word-boundary) over a book-supplied blocklist — the terem paradigm that used to live in dc-checkers.txt.
|
||
blocklist := []string{"терем", "терема", "тереме", "теремом", "терему", "теремах"}
|
||
cases := []struct {
|
||
name, tgt string
|
||
wantN int
|
||
}{
|
||
{"terem", "Он вошёл в высокий терем.", 1},
|
||
{"terem-inflected", "В тереме было тихо, у терема стоял страж.", 2}, // тереме + терема → 2 distinct register forms
|
||
{"clean", "Он вошёл в высокий зал павильона.", 0},
|
||
{"substring-not-word", "Термин был странным.", 0}, // «терм» inside «термин» must NOT fire (whole-word)
|
||
}
|
||
dcc := testCheckers(t)
|
||
for _, c := range cases {
|
||
t.Run(c.name, func(t *testing.T) {
|
||
n, det := dcc.lintRegisterLexicon(c.tgt, blocklist)
|
||
if n != c.wantN {
|
||
t.Fatalf("lintRegisterLexicon(%q) = %d (%v), want %d", c.tgt, n, det, c.wantN)
|
||
}
|
||
})
|
||
}
|
||
// The D39.79 second-book generality invariant: the real zh-ru pack ships NO register_neg, so a book that
|
||
// supplies no register_blocklist runs DC6 inert — a zh→ru book of another genre gets ZERO false DC6 flags
|
||
// and needs NO Go/pair edit.
|
||
if n, _ := dcc.lintRegisterLexicon("Он вошёл в высокий терем.", nil); n != 0 {
|
||
t.Fatalf("with an empty pair register_neg and no book register_blocklist DC6 must be inert, got %d", n)
|
||
}
|
||
}
|
||
|
||
// The DC checkers must stay SILENT on a non-zh / clean chunk (they self-gate on content), so a
|
||
// non-Chinese book never accrues style flags from them.
|
||
func TestDCCheckersSilentOnCleanChunk(t *testing.T) {
|
||
r := RunCheapGates("静かな図書館の朝。", "", "Тихое утро в библиотеке.", CheapGateConfig{})
|
||
if r.DC1TimeUnits != 0 || r.DC2Magnitude != 0 || r.DC6Register != 0 {
|
||
t.Fatalf("DC checkers must be silent on a clean ja→ru chunk, got %+v", r)
|
||
}
|
||
}
|
||
|
||
// TestDC1HoursWordBoundary pins the RIGHT boundary of ru_hours_re (pack-16 pair-data fix). The stem «час» is a
|
||
// prefix of ordinary Russian words («части», «часовых»), and Go RE2 has neither lookahead nor a Unicode \b, so
|
||
// without an explicit boundary the class fired on clean prose — harmless as observability, a wrong REWRITE the
|
||
// moment a repair loop acts on the class. The paradigm forms must still fire (no recall traded for precision).
|
||
func TestDC1HoursWordBoundary(t *testing.T) {
|
||
dcc := testCheckers(t)
|
||
const src = "他闭关了三个时辰。" // 三个时辰 = 6h; a «три час…» rendering is the defect
|
||
cases := []struct {
|
||
name, tgt string
|
||
wantFlag bool
|
||
}{
|
||
{"false-positive-parts", "Он разделил это на три части.", false}, // «три час|ти» — must NOT fire
|
||
{"false-positive-adjective", "Он ждал три часовых смены.", false}, // «часовых» is not an hour count
|
||
{"true-positive-nominative", "Он затворился на три часа.", true}, // the canonical defect
|
||
{"true-positive-prepositional", "Он провёл в этом три часах.", true}, // inflected form still fires
|
||
{"correct-conversion-silent", "Он затворился на шесть часов.", false},
|
||
}
|
||
for _, c := range cases {
|
||
t.Run(c.name, func(t *testing.T) {
|
||
n, det := dcc.lintTimeUnits(src, c.tgt)
|
||
if (n > 0) != c.wantFlag {
|
||
t.Fatalf("lintTimeUnits(%q) fired=%v (%v), want %v", c.tgt, n > 0, det, c.wantFlag)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// TestDC1FractionalUnit pins the 半个时辰 probe (pack-16): the counted branch cannot see a fractional unit word,
|
||
// so the most frequent 时辰 form in the corpus was invisible to the class. Both data keys are required — a pack
|
||
// shipping neither leaves the probe inert.
|
||
func TestDC1FractionalUnit(t *testing.T) {
|
||
dcc := testCheckers(t)
|
||
cases := []struct {
|
||
name, src, tgt string
|
||
wantFlag bool
|
||
}{
|
||
{"half-as-half-hour", "他等了半个时辰。", "Он прождал полчаса.", true}, // 半个时辰 ≈ 1 h, not 30 min
|
||
{"half-correct", "他等了半个时辰。", "Он прождал час.", false},
|
||
{"half-paraphrase", "他等了半个时辰。", "Он прождал недолго.", false},
|
||
{"no-half-in-source", "他等了很久。", "Он прождал полчаса.", false}, // no source probe → silent
|
||
}
|
||
for _, c := range cases {
|
||
t.Run(c.name, func(t *testing.T) {
|
||
n, det := dcc.lintTimeUnits(c.src, c.tgt)
|
||
if (n > 0) != c.wantFlag {
|
||
t.Fatalf("lintTimeUnits(%q,%q) fired=%v (%v), want %v", c.src, c.tgt, n > 0, det, c.wantFlag)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// TestDC1FractionalProbeInertWithoutData pins the empty-probe guard: a pack with no fractional keys must leave
|
||
// the sub-check inert rather than fire on every «полчаса» (the pack-15 lintMagnitudeScale lesson).
|
||
func TestDC1FractionalProbeInertWithoutData(t *testing.T) {
|
||
dcc := testCheckers(t)
|
||
dcc.halfShichenRE, dcc.halfShichenFireWord = nil, ""
|
||
if n, _ := dcc.lintTimeUnits("他等了半个时辰。", "Он прождал полчаса."); n != 0 {
|
||
t.Fatalf("fractional probe must be inert without pair data, fired %d", n)
|
||
}
|
||
}
|
||
|
||
// TestChevronMixingWholeWordInnerMarker pins the row-93 whole-word fix on isSpokenChevronLine's inner-marker
|
||
// veto. «в уме» is a substring of «в умении»; a substring veto would MUFFLE a real style clash — a chevron
|
||
// reply whose text merely CONTAINS «в умении», mixed with an em-dash line in the same chunk. The veto must
|
||
// match whole-word, so the clash fires; a genuine whole-word marker («про себя») must still veto. The
|
||
// labelled corpus carries 0 instances of the «в умении» shape, so this moves no label cell — a boundary fix.
|
||
func TestChevronMixingWholeWordInnerMarker(t *testing.T) {
|
||
dcc := testCheckers(t)
|
||
const emDashLine = "— Здравствуй, брат.\n" // an em-dash speech line → the mixing pre-condition
|
||
cases := []struct {
|
||
name, chevron string
|
||
wantMixing bool
|
||
}{
|
||
// «в умении» contains the substring «в уме» but is not the marker — the style clash must fire.
|
||
{"substring-not-marker-fires", "«Замолчи!» — воскликнул он, упражняясь в умении.", true},
|
||
// «про себя» is a whole-word inner marker — the reply is a thought, so the veto still muffles.
|
||
{"whole-word-marker-vetoes", "«Замолчи!» — пробормотал он про себя.", false},
|
||
}
|
||
for _, c := range cases {
|
||
t.Run(c.name, func(t *testing.T) {
|
||
n, det := lintDialogueDash(emDashLine+c.chevron, dcc)
|
||
if (n > 0) != c.wantMixing {
|
||
t.Fatalf("lintDialogueDash mixing fired=%v (%v), want %v", n > 0, det, c.wantMixing)
|
||
}
|
||
})
|
||
}
|
||
}
|