489 lines
34 KiB
Go
489 lines
34 KiB
Go
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")
|
||
}
|
||
}
|