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