package pipeline import ( "strings" "testing" ) // sanitizer_test.go — the output-sanitizer (D30.3) unit suite. Per the gate discipline // (precision over recall) EVERY class asserts BOTH firing on the exp12/flagman defect and // NON-firing on legitimate prose, so a future rule edit that starts false-flagging real // Russian text fails loudly here. func TestSanitizerLeadingPreamble(t *testing.T) { fire := []string{ // gemini 6/6 leak (exp12 §4.1) — the canonical target. "Вот отредактированный перевод с соблюдением всех терминов из глоссария:\n\nСудзуки шёл по коридору.", "Вот отредактированный перевод:\nТекст главы.", "Вот перевод фрагмента: Судзуки открыл дверь.", "Ниже приведён отредактированный текст:\nГлава началась.", "Отредактированный перевод: Судзуки вошёл.", "Представляю исправленный вариант перевода: он ушёл.", } for _, s := range fire { if got := sanitizeOutput(s); got.Preamble == 0 { t.Errorf("preamble not detected in %q", preview(s)) } } clean := []string{ "Судзуки шёл по коридорам Академии магии, вспоминая вчерашнюю лекцию.", "Вот и настал тот день, которого он так ждал.", // «Вот и…» narrative — no service phrase "Вот дом стоял на холме, окружённый старыми соснами.", // «Вот дом…» "— Вот твой перевод, — сказал он и протянул конверт.", // dialogue about a translation, not a preamble label "Перевод стрелки часов занял у него всё утро.", // «Перевод» as a common noun, no service label "Ниже по реке стоял старый мост, к которому вела тропа.", // «Ниже…» geographic // адверсариальное ревью false positives — must NOT fire (em-dash terminator + common nouns): "Вот такой вариант развития событий — самый вероятный.", // вот + вариант + em-dash, no edit-adjective "Ниже текста была подпись — размашистая.", // ниже + текст + em-dash, not «ниже приведён … перевод:» "Вот новая версия карты — с пометками отца.", // вот + версия + em-dash "Итоговый текст письма — три строки.", // итоговый + текст + em-dash "Вот его вариант: он молча ушёл.", // вот + вариант + COLON, but no edit-adjective / service phrase } for _, s := range clean { if got := sanitizeOutput(s); got.Preamble != 0 { t.Errorf("preamble FALSE POSITIVE in %q: %v", preview(s), got.Detail) } } } func TestSanitizerTrailingNote(t *testing.T) { fire := []string{ "Судзуки открыл дверь.\n\nПримечание: имя героя оставлено без перевода.", "Он ушёл в туман.\n\nВнесённые правки: заменил «фракции» на «школы».", "Глава закончилась.\n\nЧто изменено: исправлена терминология гу.", "Текст главы.\n\n*Примечание переводчика:* здесь игра слов.", "Финал сцены.\n\nЗаметки редактора: стиль приведён к норме.", "Список правок: 1) термины; 2) вёрстка.\nТекст главы дальше.", // edit-meta anywhere // D33.1а — the gemini A5/5_1 TAIL leak (exp12:229): the head-preamble rule missed it. // Reproduces the leaked block STRUCTURE (header + edit list), not the copyrighted chapter. "Судзуки закрыл дверь книгохранилища и медленно пошёл прочь.\n\n" + "Основные правки для справки:\n- унифицированы термины культивации;\n- «фракции» заменены на «школы».", "Текст главы.\n\nОсновные правки: приведены имена к глоссарию.", // colon-header variant } for _, s := range fire { if got := sanitizeOutput(s); got.TrailingNote == 0 { t.Errorf("trailing note not detected in %q", preview(s)) } } clean := []string{ "Судзуки открыл дверь и вошёл в тёмный зал, где ждала тишина.", "Примечательно, что никто не заметил его исчезновения в ту ночь.", // «Примечательно» ≠ «Примечание:» "Он оставил заметку на столе и вышел, не оглядываясь назад.", // «заметку» mid-narrative, not a header "— Что изменилось? — спросил он, оглядывая пустую комнату.", // dialogue, not «Что изменено:» // адверсариальное ревью false positives — ordinary narrative nouns/gerunds ending a chapter: "Комментатор захлёбывался от восторга, махая руками над головой.", // «Комментатор» ≠ «Комментарий:» "Комментируя случившееся, старик лишь развёл руками в тишине.", // «Комментируя» gerund "Заметка белела на столе, и никто не решался её тронуть.", // «Заметка» + no colon "Пояснения не требовалось: всё было ясно без единого слова.", // «Пояснения» + no colon (the colon comes later) "Сноска в конце страницы объясняла древний обычай клана.", // «Сноска» narrative subject "Изменения — вот что пугало его больше всего на этом свете.", // «Изменения —» common narrative opener "Правка — дело тонкое, и старый мастер знал это лучше всех.", // «Правка —» aphorism "Основные правки внёс редактор газеты, и статья пошла в набор.", // «Основные правки» + no «для справки»/colon (D33.1а precision guard) } for _, s := range clean { if got := sanitizeOutput(s); got.TrailingNote != 0 { t.Errorf("trailing note FALSE POSITIVE in %q: %v", preview(s), got.Detail) } } } func TestSanitizerMarkdownHeader(t *testing.T) { fire := []string{ "### Глава 1\n\nСудзуки шёл по коридору.", "# Пролог\n\nТекст.", "Текст первой сцены.\n\n## Часть вторая\n\nПродолжение.", "### 1. Вступление\n\nТекст.", // digit after hashes is still a header (D33.2) } for _, s := range fire { if got := sanitizeOutput(s); got.MarkdownHeader == 0 { t.Errorf("markdown header not detected in %q", preview(s)) } } clean := []string{ "Он купил дом №5 на этой улице за бесценок прошлой весной.", // «№5» not a header "Комната #1 была заперта, и ключа ни у кого не нашлось.", // «#1» no space+text "Язык C# он выучил ещё в университете, но давно забросил.", // «C#» mid-line "Судзуки шёл по коридорам, вспоминая вчерашнюю долгую лекцию.", // D33.2 — decorative scene-breaks rendered with hashes: punctuation after the hashes, // not a header word/number → must NOT fire. "Сцена оборвалась.\n\n# # #\n\nНачалась новая.", // hash-spaced scene break "Он умолк.\n\n## ***\n\nУтро выдалось ясным.", // hashes then asterisks "Конец главы.\n\n### ---\n\nСледующий день.", // hashes then dashes } for _, s := range clean { if got := sanitizeOutput(s); got.MarkdownHeader != 0 { t.Errorf("markdown header FALSE POSITIVE in %q: %v", preview(s), got.Detail) } } } func TestSanitizerLatinInsertion(t *testing.T) { fire := []string{ "Судзуки открыл книгу и прочитал: the quick brown fox jumps over.", // ≥4-word latin phrase "He walked into the ancient library at dawn and found nothing.", // whole latin sentence } for _, s := range fire { if got := sanitizeOutput(s); got.LatinInsert == 0 { t.Errorf("latin insertion not detected in %q", preview(s)) } } clean := []string{ "Судзуки купил iPhone и MacBook, чтобы работать в дороге по ночам.", // 2 latin brand names "На экране светилась надпись Google, и он нажал на неё пальцем.", // single latin name "Он listened внимательно.", // 1 stray latin word "Судзуки шёл по коридорам Академии магии, вспоминая вчерашнюю лекцию.", "Идентификатор 138fd5c384e3 и код a1b2c3d4e5f6 записаны в журнале.", // hex/id: single-letter latin tokens separated by digits — NOT a phrase "На гербе была выбита надпись: sic transit gloria mundi.", // 4-word quoted Latin motto — legit preserved (адверсариальное ревью) // D33.2 — a list of 5+ capitalized brands is legit proper nouns, not a Latin leak: the // heavy-count excludes capitalized tokens (this fired under the pre-D33.2 heavy-share gate). "Он купил iPhone, iPad, MacBook, Apple Watch и Google Pixel вчера.", } for _, s := range clean { if got := sanitizeOutput(s); got.LatinInsert != 0 { t.Errorf("latin insertion FALSE POSITIVE in %q: %v", preview(s), got.Detail) } } } func TestSanitizerBrokenWord(t *testing.T) { fire := []string{ "Стольь высокая башня терялась в облаках над городом.", // doubled soft sign ьь "Он вошёл в ьзал и замер на пороге, не решаясь шагнуть.", // soft sign word-initial } for _, s := range fire { if got := sanitizeOutput(s); got.BrokenWord == 0 { t.Errorf("broken word not detected in %q", preview(s)) } } // Mixed Cyrillic+Latin homoglyph in one token (latin 'p'/'e'/'o' inside a Cyrillic word). if got := sanitizeOutput("Пеpвок предок восстал из пепла."); got.BrokenWord == 0 { t.Errorf("mixed-script homoglyph not detected") } clean := []string{ "Судзуки шёл по коридорам Академии магии, вспоминая вчерашнюю лекцию.", "— Хорошо, хорошо, — устало ответил он, поднимая обе руки вверх.", // emphatic exact repeat, NOT a split "Молоко около дома скисло за одну жаркую летнюю ночь под навесом.", // adjacent words "На валуне у реки сидел одинокий путник в сером плаще.", // «валуне» is a VALID form (recall gap) // адверсариальное ревью false positives: "Старик старика видел издалека, но узнал не сразу в тумане.", // polyptoton (старик + старика) "Город города не выдаёт — так говаривал его дед у очага.", // город + города "Буква ъ раньше называлась ером и стояла в конце многих слов.", // metalinguistic mention of the letter ъ (standalone) "Хорошо хорошо всё это выглядело только со стороны, издалека.", // capitalized emphatic repeat // D33.1б — the removed junction-duplication detector false-flagged these: adjacent tokens // sharing an incidental ≥4-rune morphemic boundary («коло», «тало»), NOT a broken split. "Он остановился около колонны и поднял голову к тёмному своду.", // «около колонны» — overlap «коло», no containment "Путник склонился около колодца и зачерпнул ледяной воды.", // «около колодца» — overlap «коло» "К утру всё стало талой водой и растеклось по камням двора.", // «стало талой» — overlap «тало» } for _, s := range clean { if got := sanitizeOutput(s); got.BrokenWord != 0 { t.Errorf("broken word FALSE POSITIVE in %q: %v", preview(s), got.Detail) } } } // TestSanitizerBrokenWordRecallGap documents the honest boundary of the DETERMINISTIC // minimum (D30.3: precision over recall, «это ФЛАГГЕР»): a word split into two // individually-valid tokens with NO invalid sign and NO homoglyph — the bare «Первок предок» // from exp12 — is NOT offline-detectable without a morphology/dictionary pass (Ф2). Since the // junction-duplication heuristic was removed (D33.1б: it false-flagged prose), such a split is // caught only when it leaves an invalid character or a mixed-script token. This test PINS that // gap so a future morphology-based detector is a conscious change, not an accident. func TestSanitizerBrokenWordRecallGap(t *testing.T) { if got := sanitizeOutput("Первок предок восстал из пепла забытых веков."); got.BrokenWord != 0 { t.Errorf("unexpected: the pure-orthographic split «Первок предок» is documented as a recall gap, but it fired: %v", got.Detail) } } // TestSanitizerCleanNarrativePassesEntirely guards the whole gate against false positives // on a realistic clean Russian passage with dialogue, names and numbers. func TestSanitizerCleanNarrative(t *testing.T) { passage := strings.Join([]string{ "Судзуки медленно шёл по длинным коридорам Академии магии.", "", "— Сегодня я наконец открою дверь книгохранилища, — прошептал он.", "", "За окном лил серебристый дождь, собираясь в ручьи на брусчатке двора.", "Башня библиотеки тонула в тумане, и вершины её не было видно.", }, "\n") if got := sanitizeOutput(passage); got.total() != 0 { t.Fatalf("clean narrative flagged by the sanitizer: %+v", got.Detail) } } // TestSanitizerCJKLeak — the D38.1 class: a stray Han/kana/fullwidth glyph in the ru final. func TestSanitizerCJKLeak(t *testing.T) { fire := []string{ // The span the orchestrator verified (arms/C/17.0): a raw Han run mid-Russian-sentence. "«…охотники,却有 на это лишние деньги!»", "Судзуки нашёл 特产 древнего клана.", // single Han run "Разряд资质乙等 остался неясен.", // Han glued to Cyrillic "Он сказал: すごい, и замолчал.", // kana leak (ja book) } for _, s := range fire { if got := sanitizeOutput(s); got.CJKLeak == 0 { t.Errorf("CJK-leak not detected in %q: %+v", preview(s), got) } } clean := []string{ "Судзуки шёл по коридорам Академии магии, вспоминая вчерашнюю долгую лекцию.", "— Сегодня я открою дверь книгохранилища, — прошептал он, сжимая ключ.", "На гербе была латинская надпись, но он её не разобрал в темноте.", "Цена была 123 золотых, и он расплатился не торгуясь.", // ASCII digits — not fullwidth // U+3000 ideographic space alone is an INDENT artifact, NOT a content leak → must NOT fire // (research/18 §C#4); the export contract normalises it, but it does not flag the chunk. " Судзуки медленно шёл по длинному коридору к башне.", // D39 слой 6: RECOVERABLE wrong-glyph renderings — fullwidth ASCII «123» and the ideographic // comma «、» — no longer FIRE the leak class. The detector is a pure detector over the folded // text (fullwidth «123»→«123», «、»→«,»), and the export contract fixes them silently. Only // genuinely contentless CJK (Han/kana above) is flagged. "Цена была 123 золотых.", // fullwidth digits → silently folded to «123» "Он вошёл в дом、и дверь закрылась.", // ideographic comma (U+3001) → silently folded to «,» } for _, s := range clean { if got := sanitizeOutput(s); got.CJKLeak != 0 { t.Errorf("CJK-leak FALSE POSITIVE in %q: %v", preview(s), got.Detail) } } } // TestSanitizerCosmeticOnly pins the strip-vs-skip disposition matrix (D35.4a): only the markdown // header and CJK-leak classes are strippable; any substantive class pulls the chunk into skip. func TestSanitizerCosmeticOnly(t *testing.T) { cosmetic := []string{ "### Глава 7\n\nСудзуки открыл седьмую дверь и замер на пороге.", // markdown only "Судзуки нашёл 特产 древнего клана в тёмном углу книгохранилища.", // cjk only "### Пролог\n\nСудзуки увидел 却有 на алтаре забытого клана.", // BOTH cosmetic classes } for _, s := range cosmetic { got := sanitizeOutput(s) if got.total() == 0 || !got.cosmeticOnly() { t.Errorf("expected cosmeticOnly for %q: %+v", preview(s), got) } } substantive := []string{ "Вот перевод фрагмента: Судзуки открыл дверь.", // preamble (substantive) // a LEADING preamble co-occurring with a CJK leak → the substantive class wins (skip, not strip). "Вот перевод фрагмента: Судзуки увидел 特产 на алтаре забытого клана.", "Судзуки открыл дверь.\n\nПримечание: имя героя оставлено как есть.", // trailing note "Он прочитал: the quick brown fox jumps over lazy dog today.", // latin phrase } for _, s := range substantive { if got := sanitizeOutput(s); got.total() == 0 || got.cosmeticOnly() { t.Errorf("expected NOT cosmeticOnly (substantive) for %q: %+v", preview(s), got) } } } // TestStripCosmetic pins the exact deterministic strip output — the "экспортит текст БЕЗ ведущего // ###" contract — and that the result is itself clean (the classifyOutput guarantee). func TestStripCosmetic(t *testing.T) { cases := []struct{ in, want string }{ // markdown header prefix removed, heading text kept. {"### Глава 7\n\nСудзуки открыл дверь.", "Глава 7\n\nСудзуки открыл дверь."}, {"# Пролог\nТекст главы.", "Пролог\nТекст главы."}, // Han run removed; the seam is tidied (no double space, no space-before-comma), no word-merge. {"«…охотники,却有 на это лишние деньги!»", "«…охотники, на это лишние деньги!»"}, {"Судзуки нашёл 特产 древнего клана.", "Судзуки нашёл древнего клана."}, {"нашёл特产древний", "нашёл древний"}, // no spaces in source → strip leaves a space, not a merge // CONTENT-BEARING fullwidth ASCII is FOLDED, not deleted (adversarial review MAJOR): the number survives. {"Цена была 123 золотых.", "Цена была 123 золотых."}, {"Индекс ABC найден.", "Индекс ABC найден."}, // ideographic comma/period fold to plain — the clause boundary is kept. {"Он вошёл в дом、и дверь закрылась。", "Он вошёл в дом,и дверь закрылась."}, // a whitelisted CJK term gloss is now PRESERVED (D39.2 T2 — owner keeps glosses); a co-occurring // BARE Han run (no head, not in parens) is still stripped, and the gloss parens do not empty out. {"Кулак Ло Хань (羅漢拳) ударил 特产 врага.", "Кулак Ло Хань (羅漢拳) ударил врага."}, // a legit ratio «2 : 1» is NOT reformatted (colon dropped from the tidy set). {"### Итог\n\nСчёт был 2 : 1 却有 в пользу гостей.", "Итог\n\nСчёт был 2 : 1 в пользу гостей."}, // U+3000 ideographic space normalised to a plain space (the indent artifact). {" Судзуки шёл.", " Судзуки шёл."}, } for _, c := range cases { got := stripCosmetic(c.in) if got != c.want { t.Errorf("stripCosmetic(%q) = %q, want %q", c.in, got, c.want) } if san := sanitizeOutput(got); san.total() != 0 { t.Errorf("stripCosmetic(%q) is not clean: %+v", c.in, san.Detail) } } } // TestExportNormalize pins the export-contract (D39 слой 6, D29.1a): every final text is normalised // before it ships — a CLEAN chunk (which the sanitizer never flags) now gets its cosmetic Unicode // artifacts fixed, not shipped raw (L5-normalization-fused-to-flag-path). Width fold + CJK-punct map // + combining-stress strip; «…»/«№»/«й»/«ё» preserved; idempotent. func TestExportNormalize(t *testing.T) { cases := []struct{ in, want string }{ // The finding's exact case: a clean chunk with only a U+3000 indent shipped raw before. {" Судзуки медленно шёл к башне.", " Судзуки медленно шёл к башне."}, // fullwidth ASCII folded (a clean chunk, never flagged) — the number/letters survive. {"Цена была 123 золотых, индекс ABC.", "Цена была 123 золотых, индекс ABC."}, // ideographic comma/period → ASCII on a CLEAN chunk (no Han → sanitizer never fired). {"Он вошёл в дом、дверь закрылась。", "Он вошёл в дом,дверь закрылась."}, // combining stress accent on a Cyrillic base dropped (L5-diacritics-class-lost): «источа́ло». {"Он источа́ло тепло за́мка.", "Он источало тепло замка."}, // legit typography PRESERVED (the NFKC-would-clobber regression the width fold avoids). {"Он ждал… но никто не пришёл. № 5, ½ часа.", "Он ждал… но никто не пришёл. № 5, ½ часа."}, // trailing horizontal whitespace trimmed per line; the newline is kept. {"Первая строка. \nВторая строка.", "Первая строка.\nВторая строка."}, } for _, c := range cases { got := exportNormalize(c.in) if got != c.want { t.Errorf("exportNormalize(%q) = %q, want %q", c.in, got, c.want) } if again := exportNormalize(got); again != got { t.Errorf("exportNormalize not idempotent for %q: %q → %q", c.in, got, again) } } // A DECOMPOSED «й» (и U+0438 + combining breve U+0306) / «ё» (е U+0435 + diaeresis U+0308) must be // COMPOSED by NFC, NOT corrupted to «и»/«е» by the combining-stress strip — the load-bearing reason // NFC runs BEFORE stripCombiningOnCyrillic in exportNormalize. if got := exportNormalize("\u0438\u0306"); got != "\u0439" { // decomposed й → composed «й» t.Errorf("decomposed й corrupted by the combining strip: %q (want «й» U+0439)", got) } if got := exportNormalize("\u0435\u0308"); got != "\u0451" { // decomposed ё → composed «ё» t.Errorf("decomposed ё corrupted by the combining strip: %q (want «ё» U+0451)", got) } } // TestSanitizerCJKGlossWhitelist pins the CJK-gloss whitelist (D39.2 T2, owner decision 16.07: KEEP // glosses): a balanced CJK run inside parens directly after a Cyrillic/Latin head word is NOT a leak — // detectCJKLeak does not flag it and stripCosmetic/exportNormalize preserve its bytes; a bare Han run // (no head, line start, after punctuation, CJK punctuation inside, nested paren) is STILL a leak. func TestSanitizerCJKGlossWhitelist(t *testing.T) { // Whitelisted glosses: NOT flagged, PRESERVED verbatim by both strip and normalize. preserved := []string{ "Кулак Ло Хань (羅漢拳) был силён.", // the owner's canonical case (ASCII parens) "Он изучил дар (資質) древнего клана.", // gloss with no space before paren-adjacent head "Приём Ло Хань(羅漢拳) сработал.", // head directly adjacent to «(» (no space) "Два приёма: Кулак (拳) и Нога (腿) в бою.", // two glosses in a row — both preserved "Стиль Тайцзи (太極拳 tàijíquán) он знал.", // Han + pinyin with tone marks and a space "Техника Zhang (張三豐) древних мастеров.", // Latin head word, Han gloss "Название клана(羅漢)на воротах.", // FULLWIDTH parens — preserved verbatim, not folded "Кулак (羅漢拳) был силён.", // NBSP between head and paren (finding B) — term kept "Приём (五行八卦掌 wu xing ba gua zhang) стар.", // 5 space-separated PINYIN syllables (finding A) "Он изучил приём (九阳真经 jiǔyángzhēnjīng) там.", // JOINED pinyin of a 4-char term (D39.4 finding 2) } for _, s := range preserved { if got := sanitizeOutput(s); got.CJKLeak != 0 { t.Errorf("gloss FALSE-flagged as leak in %q: %v", preview(s), got.Detail) } if got := stripCosmetic(s); got != s { t.Errorf("stripCosmetic altered a preserved gloss:\n in %q\n got %q", s, got) } if got := exportNormalize(s); got != s { t.Errorf("exportNormalize altered a preserved gloss:\n in %q\n got %q", s, got) } } // FINDING A: a space-separated pinyin gloss must NOT trip the Latin-insertion class and DROP the whole // chunk (detectLatinInsertion runs gloss-masked). The chunk must be fully clean (nothing fired). if got := sanitizeOutput("Приём (五行八卦掌 wu xing ba gua zhang) стар."); got.total() != 0 { t.Errorf("pinyin gloss must not flag the chunk (finding A), got %+v", got) } // FINDING B: a DOUBLE space between head and paren — the gloss TERM is kept and the chunk not flagged // (the tidy normalises the double space to one, so it is not byte-verbatim, hence a separate check). if got := sanitizeOutput("Кулак (羅漢拳) был силён."); got.CJKLeak != 0 { t.Errorf("double-space gloss FALSE-flagged (finding B): %+v", got) } if got := stripCosmetic("Кулак (羅漢拳) был силён."); !strings.Contains(got, "羅漢拳") { t.Errorf("double-space gloss term dropped (finding B): %q", got) } // D39.4 finding 1: a U+3000 (ideographic-space) gap binds the term to the gloss — the term is kept // and the chunk not flagged (the U+3000 in the head normalises to a plain space, so not byte-verbatim). if got := sanitizeOutput("Кулак (羅漢拳) был силён."); got.CJKLeak != 0 { t.Errorf("U+3000-bound gloss FALSE-flagged (finding 1): %+v", got) } if got := stripCosmetic("Кулак (羅漢拳) был силён."); !strings.Contains(got, "羅漢拳") { t.Errorf("U+3000-bound gloss term dropped (finding 1): %q", got) } // NOT a whitelisted gloss → the CJK stays a leak (bare run / no letter head / CJK punctuation / // nesting / a CLAUSE too long to be a term — finding D). leak := []string{ "Судзуки нашёл 特产 древнего клана.", // bare Han, no parens at all "(羅漢拳) — так назвали приём.", // paren gloss at LINE START, no letter head → leak "Он сказал: (太極) и ушёл.", // after «:» + space, no letter head → leak "Приём 5 (羅漢拳) в списке.", // digit head, not a letter → not a gloss "Клан (羅、漢) распался.", // CJK PUNCTUATION «、» inside the parens → not a term gloss "Он ушёл (却有很多事情要做), но вернулся.", // 8-CJK CLAUSE, not a term gloss (finding D) → leak "Техника (九字真言破邪法) была забыта тогда.", // 7 CJK > maxGlossCJKRunes → leak (finding D bound) "Кулак (拳 the iron fist that breaks the sky) взлетел.", // F2: English clause after a token Han → leak "Приём (拳 antidisestablishmentarianism) стар.", // F2: single over-long non-pinyin Latin word → leak "Стиль (拳 iron fist of sky) забыт.", // F2: non-proportional Latin (3 toks vs 1 CJK) → leak } for _, s := range leak { if got := sanitizeOutput(s); got.CJKLeak == 0 { t.Errorf("expected a CJK leak (not a valid gloss) in %q: %+v", preview(s), got) } } // The emptied-bracket regression must NOT reappear: a preserved gloss keeps its parens and term, so // stripCosmetic never yields «()» — and a real bare leak next to a gloss strips cleanly. if got := stripCosmetic("Кулак (羅漢拳) и 特产 рядом."); got != "Кулак (羅漢拳) и рядом." { t.Errorf("mixed gloss+leak strip = %q, want the gloss kept and the bare leak dropped", got) } // D39.4 finding 1: a U+3000-BOUND gloss must survive the strip of a CO-OCCURRING bare leak — the // fold-first detector and the original-bytes strip must agree the U+3000 gloss is protected. if got := stripCosmetic("Кулак (羅漢拳) и 特产 рядом."); !strings.Contains(got, "羅漢拳") { t.Errorf("finding 1: U+3000-bound gloss destroyed alongside a bare leak: %q", got) } } // TestSanitizerFoldFirstBypass pins F1 (D39.4): a FULLWIDTH variation of a signature must be detected, // not slip past the ASCII regexes while exportNormalize manufactures the ASCII defect in the export. // All six classes detect over the export-normalised (folded) text. func TestSanitizerFoldFirstBypass(t *testing.T) { fire := map[string]string{ "fullwidth-colon-preamble": "Вот отредактированный перевод:\n\nСудзуки шёл.", // U+FF1A colon "fullwidth-markdown": "### Глава 5\n\nСудзуки открыл дверь.", // U+FF03 hash ×3 "fullwidth-trailing-note": "Судзуки ушёл.\n\nПримечание:имя оставлено как есть.", // U+FF1A trailing "fullwidth-latin-phrase": "Он прочитал: the quick brown fox today.", // fullwidth latin } for name, s := range fire { if got := sanitizeOutput(s); got.total() == 0 { t.Errorf("F1 %s: a fullwidth signature slipped past the detector (bypass): %q → %+v", name, preview(s), got) } } } // TestSanitizerExportNormalizeInvariant pins the F1 gate/export-contract invariant: for any text the // sanitizer calls CLEAN, its export-normalised form is ALSO clean — the detector and exportNormalize // agree, so no fullwidth defect is manufactured in the export of a "clean" chunk. Runs over the whole // legitimate-prose corpus the class tests use. func TestSanitizerExportNormalizeInvariant(t *testing.T) { corpus := []string{ "Судзуки шёл по коридорам Академии магии, вспоминая вчерашнюю долгую лекцию.", "— Сегодня я открою дверь книгохранилища, — прошептал он, сжимая ключ.", "Цена была 123 золотых, и он расплатился не торгуясь.", " Судзуки медленно шёл по длинному коридору к башне.", "Цена была 123 золотых.", "Он вошёл в дом、и дверь закрылась.", "Он источа́ло тепло за́мка.", "Он ждал… но никто не пришёл. № 5, ½ часа.", "Кулак Ло Хань (羅漢拳) был силён.", "Стиль Тайцзи (太極拳 tàijíquán) он знал.", "Он купил iPhone, iPad, MacBook, Apple Watch и Google Pixel вчера.", "На гербе была выбита надпись: sic transit gloria mundi.", } for _, s := range corpus { if sanitizeOutput(s).total() != 0 { continue // not a clean input — the invariant is stated for clean verdicts } if got := sanitizeOutput(exportNormalize(s)); got.total() != 0 { t.Errorf("F1 invariant: clean %q became a defect after exportNormalize: %+v", preview(s), got) } } } // TestSanitizerDeterministic asserts the verdict is a pure function of the text (resume-safe). func TestSanitizerDeterministic(t *testing.T) { s := "### Заголовок\nВот отредактированный перевод: текст. Стольь странно.\nПримечание: правка." a, b := sanitizeOutput(s), sanitizeOutput(s) if a.total() != b.total() || strings.Join(a.Detail, "|") != strings.Join(b.Detail, "|") { t.Fatalf("sanitizeOutput not deterministic: %v vs %v", a, b) } if a.total() == 0 { t.Fatalf("expected the multi-defect fixture to flag") } }