textmachine/backend/internal/checks/checkers_pairdata_test.go

171 lines
8.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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