package checks import ( "strings" "testing" "textmachine/backend/internal/lang" ) // checkers_pairdata_test.go: the generality nit taken with the pre-run hygiene pack (25.07) — the DC // checkers' UNIT RELATION and DETAIL TEXT are pair DATA, not Go literals. The review question the // norm asks ("does it work for a pair that is not in the repo yet, with no Go edit?") had two known // NO answers here: `n * 2` (1 时辰 = 2 h, true of Chinese and of nothing else) and detail strings // naming 时辰/千万/成 from a file that pair-14 declared free of language literals. // // These tests prove the answer is now YES by running the SAME algorithm over a SYNTHETIC pair whose // data says something different — which is exactly what a second pair would be. // synthPack builds a DCCheckerData for an imaginary pair: same detection shapes as zh-ru (so the // algorithm has something to match) but a unit worth `hours` target hours and its own message text. func synthPack(hours int) *lang.DCCheckerData { return &lang.DCCheckerData{ Numeral: map[rune]int{'三': 3}, RuHours: map[string]int{"три": 3}, RegisterNeg: []string{"терем"}, Patterns: map[string]string{ "shichen_re": `([0-9三])\s*个?\s*时辰`, "ru_hours_re": `(\d+|три)\s+час(?:а|ов)?(?:[^а-яёА-ЯЁ]|$)`, "cheng_re": `([0-9三])成([0-9三]?)`, "decimal_fraction_re": `десят(?:ая|ых)`, "qianwan_ok_re": `(?i)десят\p{L}* миллион`, "shushiwan_ok_re": `(?i)сотн\p{L}* тысяч`, "shushiwan_fire_re": `десятк\p{L}* тысяч`, "qianwan_src": "千万", "qianwan_fire_word": "тысяч", "qianwan_veto_word": "миллион", "shushiwan_src": "数十万", "percent_word": "процент", "halfshichen_re": `半\s*个?\s*时辰`, "halfshichen_fire_word": "полчаса", "hour_word_re": `(?:^|[^а-яёА-ЯЁ])час(?:а|ов)?(?:[^а-яёА-ЯЁ]|$)`, }, Ratios: map[string]int{"dc1_unit_in_hours": hours}, Messages: map[string]string{ "dc1_fractional": "SYNTH-FRACTIONAL {fire_word}", "dc1_counted": "SYNTH-COUNTED n={n} rendered={rendered} expected={expected} ratio={ratio}", "dc2_qianwan": "SYNTH-QIANWAN", "dc2_shushiwan": "SYNTH-SHUSHIWAN", "percent_scale": "SYNTH-PERCENT {tens}/{ones}/{pct}", // dc6_register is intentionally ABSENT (D39.79 Q4): the DC6 detail is a generic Go string now, so a // pack need not — and no longer may be required to — ship a register message. }, } } // TestDC1UnitRatioIsPairData: with the SAME text and the SAME patterns, a pair whose unit is worth 1 // target hour must stay silent where a double-hour pair fires. Under the old `n * 2` this was // impossible to express without editing Go — the checker would have told the 1:1 pair that its correct // rendering was a defect. func TestDC1UnitRatioIsPairData(t *testing.T) { const src, tgt = "他闭关了三个时辰。", "Он затворился на три часа." if n, _ := CompileCheckers(synthPack(2), lang.TargetChecks{}).lintTimeUnits(src, tgt); n != 1 { t.Fatalf("a 2:1 pair must flag «три часа» for 三个时辰, got %d flags", n) } if n, det := CompileCheckers(synthPack(1), lang.TargetChecks{}).lintTimeUnits(src, tgt); n != 0 { t.Fatalf("a 1:1 pair renders 三个时辰 as «три часа» CORRECTLY; the checker must be silent, got %d: %v", n, det) } } // TestCheckerDetailTextComesFromPairData: every pair-gated detail line must be the PACK's sentence. // A Go-authored fallback surviving anywhere would show up here as a message the synthetic pack never // wrote — i.e. as a second pair being told about Chinese double-hours. func TestCheckerDetailTextComesFromPairData(t *testing.T) { c := CompileCheckers(synthPack(2), lang.TargetChecks{}) for _, tc := range []struct { name, want string run func() (int, []string) }{ {"dc1-counted", "SYNTH-COUNTED n=3 rendered=3 expected=6 ratio=2", func() (int, []string) { return c.lintTimeUnits("他闭关了三个时辰。", "Он затворился на три часа.") }}, {"dc1-fractional", "SYNTH-FRACTIONAL полчаса", func() (int, []string) { return c.lintTimeUnits("他等了半个时辰。", "Он ждал полчаса.") }}, {"dc2-qianwan", "SYNTH-QIANWAN", func() (int, []string) { return c.lintMagnitudeScale("有千万条蛊虫。", "Там были тысячи червей.") }}, {"dc2-shushiwan", "SYNTH-SHUSHIWAN", func() (int, []string) { return c.lintMagnitudeScale("有数十万人。", "Там были десятки тысяч人.") }}, // dc6-register is NOT here: after Q4 its detail is a generic Go string, not the pack's sentence — it is // deliberately no longer a pair-data-messaged checker. The optional pair register path is covered by // TestDC6PairLevelRegisterOptional and the book path by TestDC6RegisterLexicon. {"percent", "SYNTH-PERCENT 三//30", func() (int, []string) { return c.lintPercentScale("他用了三成力。", "Он использовал три десятых силы.") }}, } { t.Run(tc.name, func(t *testing.T) { n, det := tc.run() if n == 0 { t.Fatalf("fixture did not fire — the assertion below would be vacuous") } if len(det) == 0 || det[0] != tc.want { t.Fatalf("detail = %q, want %q (the pack's own sentence)", det, tc.want) } }) } } // TestCorruptPairPackFailsLoud: a pack that ships detection data but no unit relation / no message is // CORRUPT, and must die at compile like a malformed regex does. The tolerant alternative (default to 2, // or emit an empty detail) is worse than a crash: a silent detector reads downstream as "nothing was // wrong", and a defaulted ratio silently re-imports the very literal this nit removed. func TestCorruptPairPackFailsLoud(t *testing.T) { for _, tc := range []struct { name, wantMsg string mutate func(d *lang.DCCheckerData) }{ {"no ratio", "dc1_unit_in_hours", func(d *lang.DCCheckerData) { delete(d.Ratios, "dc1_unit_in_hours") }}, {"zero ratio", "dc1_unit_in_hours", func(d *lang.DCCheckerData) { d.Ratios["dc1_unit_in_hours"] = 0 }}, {"no message", "dc2_shushiwan", func(d *lang.DCCheckerData) { delete(d.Messages, "dc2_shushiwan") }}, {"blank message", "dc1_counted", func(d *lang.DCCheckerData) { d.Messages["dc1_counted"] = " " }}, } { t.Run(tc.name, func(t *testing.T) { d := synthPack(2) tc.mutate(d) defer func() { rec := recover() if rec == nil { t.Fatalf("a pack missing %s compiled silently", tc.wantMsg) } if !strings.Contains(rec.(string), tc.wantMsg) { t.Fatalf("panic does not name the missing key %q: %v", tc.wantMsg, rec) } }() CompileCheckers(d, lang.TargetChecks{}) }) } } // TestNoPackStaysInert re-pins the no-langpack path across this change: a book with no pair data (the // ja→ru golden fixture) must compile a spec that fires nothing rather than panicking for want of the // new required keys. func TestNoPackStaysInert(t *testing.T) { c := CompileCheckers(nil, lang.TargetChecks{}) if n, _ := c.lintTimeUnits("他闭关了三个时辰。", "Он затворился на три часа."); n != 0 { t.Fatalf("a book with no pair pack must run the pair checkers inert, got %d", n) } if n, _ := c.lintRegisterLexicon("Он вошёл в терем.", nil); n != 0 { t.Fatalf("register check must be inert with no pack and no book blocklist, got %d", n) } } // TestDC6PairLevelRegisterOptional pins the OTHER union member (D39.79 Q4): a pack MAY still ship an optional // pair-level register list, and it fires via lintRegisterLexicon even with no book blocklist — the generic // Go detail replaces the pack's old dc6_register message. func TestDC6PairLevelRegisterOptional(t *testing.T) { c := CompileCheckers(synthPack(2), lang.TargetChecks{}) // synthPack ships RegisterNeg: ["терем"] n, det := c.lintRegisterLexicon("Он вошёл в терем.", nil) if n != 1 { t.Fatalf("an optional pair-level register lexeme must still fire, got %d", n) } if len(det) == 0 || !strings.Contains(det[0], "терем") { t.Fatalf("the generic detail must name the hit, got %q", det) } }