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) } }) } }