171 lines
8.2 KiB
Go
171 lines
8.2 KiB
Go
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)
|
||
}
|
||
}
|