205 lines
14 KiB
Go
205 lines
14 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
|
||
}
|
||
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
|
||
}
|
||
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Продолжение.",
|
||
}
|
||
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
|
||
"Судзуки шёл по коридорам, вспоминая вчерашнюю долгую лекцию.",
|
||
}
|
||
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 (адверсариальное ревью)
|
||
}
|
||
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
|
||
"Древний Первопред предок восстал из пепла забытых веков.", // junction-duplicated split «Первопред|предок»
|
||
}
|
||
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, no ≥4 junction overlap
|
||
"На валуне у реки сидел одинокий путник в сером плаще.", // «валуне» is a VALID form (recall gap)
|
||
// адверсариальное ревью false positives:
|
||
"Старик старика видел издалека, но узнал не сразу в тумане.", // polyptoton (старик + старика): containment, NOT a split
|
||
"Город города не выдаёт — так говаривал его дед у очага.", // город + города: containment
|
||
"Буква ъ раньше называлась ером и стояла в конце многих слов.", // metalinguistic mention of the letter ъ (standalone)
|
||
"Хорошо хорошо всё это выглядело только со стороны, издалека.", // capitalized emphatic repeat (case-differing) — containment excludes it
|
||
}
|
||
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, NO homoglyph and NO ≥4-rune junction
|
||
// overlap — the bare «Первок предок» from exp12 — is NOT offline-detectable without a
|
||
// morphology/dictionary pass (Ф2). It is caught only when the split leaves a junction
|
||
// overlap («Первопред предок») or an invalid character. This test PINS that gap so a future
|
||
// stronger heuristic 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)
|
||
}
|
||
}
|
||
|
||
// 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")
|
||
}
|
||
}
|