textmachine/backend/internal/checks/cheapgates_test.go

231 lines
11 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 (
"testing"
"textmachine/backend/internal/lang"
)
// --- dialogue-dash --------------------------------------------------------------
func TestLintDialogueDash(t *testing.T) {
cases := []struct {
name string
text string
want int
}{
{"em-dash correct", "— Привет, — сказал он.\n— И тебе.", 0},
{"hyphen dialogue", "- Привет, — сказал он.\n- Пока.", 2},
{"en-dash dialogue", " Привет, мир.", 1},
{"straight quote dialogue", "\"Привет\", — сказал он.", 1},
{"hyphenated word not dialogue", "Он сделал это как-то по-своему.\n-то не считается.", 0},
{"list-like dash not dialogue", "-1 балл\n-2 очка", 0},
// Mini-run FP (25.07), now pinned as NON-firing: a chevron line whose attribution is an INNER-SPEECH
// verb is a THOUGHT, and Russian sets thought in «…» beside spoken «—» on purpose. Flagging it
// called correct typography a style clash (8 of 8 hits on the 10-chapter run were this shape).
{"inner speech beside dialogue is correct typography", "— Реплика первая.\n«Реплика вторая», — подумал он.", 0},
{"inner speech «про себя» beside dialogue", "— Идём.\n«Становится интереснее», — усмехнулся про себя Фан Юань.", 0},
// Live 2-chapter verification run (26.07): these two attributions are what made the thought-verb
// polarity untenable — neither «понимал» nor «размышлял» is enumerable in advance, while the
// speech-verb side is a closed list. An unrecognised attribution must be SILENCE, not a flag.
{"thought attributed with «понимал»", "— Идём.\n«Это корм для гу-червей», — прекрасно понимал Фан Юань.", 0},
{"speech verb VETOED by «про себя»", "— Идём.\n«Это и есть гу Надежды», — пробормотал про себя Фан Юань.", 0},
{"thought attributed with «размышлял»", "— Идём.\n«Он ведёт борьбу», — размышлял Фан Юань, — «и ему нужен внук».", 0},
// …and the rule must still fire on genuine SPOKEN chevron dialogue mixed with dashes, else the
// discriminator would have silently disabled the whole check.
{"spoken chevrons mixed with dashes still flagged", "— Реплика первая.\n«Реплика вторая», — сказал он.", 1},
{"chevron only no mixing", "«Цитата из книги».\nОбычный текст.", 0},
{"chevron term in prose not flagged", "Это была «Война и мир».", 0},
// D20.4 FP1: a chevron CITATION/definition at line start (« … » — prose, NO attribution comma)
// mixed with em-dash dialogue must NOT count as style mixing.
{"chevron citation at line start not mixing", "— Реплика.\n«Мастер Гу» — так его называли.", 0},
{"chevron title alone at line start not mixing", "— Реплика.\n«Война и мир» стояла на полке.", 0},
// Self-review: an em-dash + space + digit is a scene break, NOT dialogue — must not count.
{"em-dash scene break not dialogue", "— 15 минут спустя он вернулся.", 0},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
n, _ := lintDialogueDash(c.text, CompileCheckers(nil, lang.TargetChecksFor("ru")))
if n != c.want {
t.Errorf("lintDialogueDash(%q) = %d, want %d", c.text, n, c.want)
}
})
}
}
// --- yofikator ------------------------------------------------------------------
func TestLintYofikation(t *testing.T) {
cases := []struct {
name string
text string
policy string
want int
}{
{"name both spellings auto", "Пётр вошёл. Потом Петр ушёл.", "auto", 1},
{"name consistent auto", "Пётр вошёл и Пётр ушёл.", "auto", 0},
{"homograph vse not flagged", "Все ушли. И всё пропало.", "auto", 0},
{"no yo at all auto", "Все ели мед.", "auto", 0},
{"all-e flags any yo", "Пётр ел мёд.", "all-e", 2},
{"all-e clean", "Петр ел мед.", "all-e", 0},
{"artem both spellings", "Артём и Артем — один человек.", "auto", 1},
}
dcc := testCheckers(t)
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
n, _ := dcc.lintYofikation(c.text, c.policy)
if n != c.want {
t.Errorf("lintYofikation(%q, %q) = %d, want %d", c.text, c.policy, n, c.want)
}
})
}
}
// --- translit interjections -----------------------------------------------------
func TestLintTranslitInterjections(t *testing.T) {
empty := map[string]bool{}
cases := []struct {
name string
text string
allow map[string]bool
want int
}{
{"ara-ara and hmf", "Ара-ара, — протянула она. Хмф!", empty, 2},
{"nani", "Нани?! — вскрикнул он.", empty, 1},
{"inside word not flagged", "У него сильный характер и добрая душа.", empty, 0},
{"allowlisted maa", "Маа, что же делать.", map[string]bool{"маа": true}, 0},
{"non-allowlisted maa fires", "Маа, что же делать.", empty, 1},
// Self-review majors: «ара» (macaw noun / name) and «уму» (dative of «ум») were removed from
// the blocklist — they must NOT flag ordinary prose.
{"bare ara is a macaw not flagged", "Красный ара сидел на ветке.", empty, 0},
{"umu dative not flagged", "Это уму непостижимо.", empty, 0},
{"clean prose", "Он молча кивнул и ушёл.", empty, 0},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
n, _ := CompileCheckers(nil, lang.TargetChecksFor("ru")).lintTranslitInterjections(c.text, c.allow)
if n != c.want {
t.Errorf("lintTranslitInterjections(%q) = %d, want %d", c.text, n, c.want)
}
})
}
}
// --- CJK number parser ----------------------------------------------------------
func TestParseCJKNumber(t *testing.T) {
cases := []struct {
s string
want int64
ok bool
}{
{"三万", 30000, true},
{"三千万", 30000000, true},
{"一億二千万", 120000000, true},
{"十万", 100000, true},
{"30万", 300000, true},
{"五億", 500000000, true},
{"一兆", 1000000000000, true},
{"三", 0, false}, // no big marker
{"三千", 0, false}, // no big marker (千 is a small unit)
{"", 0, false},
}
for _, c := range cases {
v, ok := parseCJKNumber(c.s)
if ok != c.ok || (ok && v != c.want) {
t.Errorf("parseCJKNumber(%q) = (%d,%v), want (%d,%v)", c.s, v, ok, c.want, c.ok)
}
}
}
// --- number magnitude gate ------------------------------------------------------
func TestLintNumberMagnitude(t *testing.T) {
cases := []struct {
name string
source string
final string
want int
}{
{"30k mistranslated as millions", "他有三万石粮食。", "У него было три миллиона мешков зерна.", 1},
{"30k correct as tens of thousands", "他有三万石粮食。", "У него было тридцать тысяч мешков зерна.", 0},
{"300 million correct", "损失三億元。", "Убытки составили триста миллионов юаней.", 0},
{"hundred million as thousand wrong", "军队三億人。", "Армия в три тысячи человек.", 1},
{"wan as myriad not a number", "世间万物皆有灵。", "Все вещи в мире имеют душу.", 0},
// Self-review major: CJK idioms carrying 万/億 but no digit coefficient are NOT magnitudes.
{"wan-yi idiom", "万一出事了怎么办。", "Что если что-то случится?", 0},
{"qianwan idiom", "你千万要小心。", "Ты обязательно будь осторожен.", 0},
{"wanfen idiom", "他万分感激。", "Он был крайне благодарен.", 0},
{"no magnitude at all", "他走进房间。", "Он вошёл в комнату.", 0},
{"arabic output matches", "三万里。", "30000 ли.", 0},
{"no output number silent", "三万大军。", "Огромная армия.", 0},
// D20.4 FP3: a magnitude rephrased as prose + a STRAY unrelated output integer (a year) must
// NOT flag — the year is not evidence the 万 was mistranslated (bare integers only confirm).
{"rephrased magnitude plus stray year not flagged", "三万大军压境时值1990年。", "Несметное войско подступило; шёл 1990 год.", 0},
// D20.4 FP2: 万 inside a NAME (digit-before-万 makes it parse as a magnitude) + an unrelated
// output number must NOT flag.
{"wan in a name plus stray year not flagged", "他叫赵三万生于1985年。", "Его звали Чжао Саньвань, родился в 1985 году.", 0},
}
dcc := testCheckers(t)
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
n, _ := dcc.lintNumberMagnitude(c.source, c.final)
if n != c.want {
t.Errorf("lintNumberMagnitude(%q → %q) = %d, want %d", c.source, c.final, n, c.want)
}
})
}
}
// --- combined ------------------------------------------------------------------
func TestRunCheapGatesCombined(t *testing.T) {
src := "他有三万石粮食。"
final := "- Ара-ара, — у Пётр было три миллиона мешков. Потом Петр ушёл."
cfg := CheapGateConfig{YoPolicy: "auto", Allowlist: map[string]bool{}, Checkers: testCheckers(t)}
r := RunCheapGates(src, final, final, cfg)
if r.DialogueDash == 0 {
t.Error("expected a dialogue-dash flag (hyphen-led line)")
}
if r.TranslitInterj == 0 {
t.Error("expected a translit-interjection flag (ара-ара)")
}
if r.YoInconsistent == 0 {
t.Error("expected a yofikation flag (Пётр/Петр)")
}
if r.NumberMagnitude == 0 {
t.Error("expected a number-magnitude flag (三万 → миллиона)")
}
if r.Total() < 4 {
t.Errorf("expected all four gates to fire, total=%d detail=%v", r.Total(), r.Detail)
}
}
// TestLintDialogueDashInertWithoutTargetData pins the "no data → inert, never a crash" contract of the
// chevron-mixing rule, which the D-pack mutation run exposed as untested: with a nil checker spec (a book
// whose target ships no readability data — the no-pack path the golden fixture rides) the rule must still
// run and simply never count a chevron line. A nil-receiver dereference here would take down the whole
// cheap-gate battery on exactly the books that have the least data to spare.
func TestLintDialogueDashInertWithoutTargetData(t *testing.T) {
mixed := "— Реплика первая.\n«Реплика вторая», — сказал он."
for _, tc := range []struct {
name string
c *Checkers
}{
{"nil spec", nil},
{"spec with no target data", CompileCheckers(nil, lang.TargetChecks{})},
} {
t.Run(tc.name, func(t *testing.T) {
n, _ := lintDialogueDash(mixed, tc.c) // must not panic
if n != 0 {
t.Fatalf("without speech-verb data the mixing rule must stay silent, got %d", n)
}
// The data-independent half of the rule must still work: a hyphen-led speech line is a
// defect in any target that uses dashes, and it needs no vocabulary to spot.
if n, _ := lintDialogueDash("- Привет, — сказал он.", tc.c); n == 0 {
t.Fatal("the hyphen-instead-of-dash half must keep firing without target data")
}
})
}
}