textmachine/backend/internal/pipeline/cheapgates_test.go

230 lines
9.6 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 pipeline
import (
"context"
"testing"
)
// --- 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},
{"mixing em-dash and chevrons", "— Реплика первая.\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)
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},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
n, _ := 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, _ := 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},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
n, _ := 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{}}
r := runCheapGates(src, 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)
}
}
// TestCheapGatesSurfaceInStatus is the end-to-end wiring test: a chunk whose FINAL text has a style
// issue (a hyphen-led dialogue line) records a style flag in retrieval_state and surfaces it in the
// status projection — WITHOUT flagging the chunk (observability, never a disposition).
func TestCheapGatesSurfaceInStatus(t *testing.T) {
rec := &reqRec{}
srv := newJSONProvider(rec, func(body string) (string, string) {
if isEditBody(body) {
return "- Привет, — сказал он.", "stop" // hyphen dialogue → dialogue-dash flag
}
return "ЧЕРНОВИК ПЕРЕВОДА", "stop"
})
defer srv.Close()
bookPath := setupProjectOpts(t, srv.URL, projectOpts{source: "テキスト。", regenerate: 0})
ctx := context.Background()
r := newRunner(t, bookPath)
defer r.Close()
res, err := r.TranslateBook(ctx)
if err != nil {
t.Fatal(err)
}
if res.Flagged != 0 {
t.Fatalf("a style flag must not flag the chunk (observability only), got flagged=%d", res.Flagged)
}
rs, err := r.Store.GetRetrievalState("test-book", 1, 0)
if err != nil || rs == nil {
t.Fatalf("no retrieval state persisted: %v", err)
}
if rs.NStyleFlags == 0 {
t.Fatalf("style flag not persisted in retrieval_state: %+v", rs)
}
rep, err := r.Status(ctx)
if err != nil {
t.Fatal(err)
}
if rep.StyleFlags == 0 {
t.Fatal("status must surface the book-wide style-flag count")
}
if rep.Done != 1 || rep.Flagged != 0 {
t.Fatalf("the chunk must be done, not flagged: done=%d flagged=%d", rep.Done, rep.Flagged)
}
}