Files
jellybit/internal/recognize/sanitize_test.go
T
avandClaude Opus 4.8 e2ea1840c9 Распознавание: санитайзинг названий от LLM + безгодовой фолбэк сверки
Кейс «Harold and the Purple Crayon»: LLM отдал title с кириллической
буквой-двойником, сырое название ушло в запрос TVDB дословно (не нашлось),
а гейт нормализации кир/лат двойники не сворачивал — двойной промах, пустой
список кандидатов, ручной ввод id.

- recognition: санитайзинг человекочитаемых полей плана (title/original_title/
  provider_hint) на границе разбора, до валидации: strip control/zero-width,
  collapse пробелов, потокенная свёртка homoglyph-двойников по курируемой
  кир↔лат таблице. files[].src не трогаем (обязаны биться с торрентом).
- metadata-match: тот же fold в normalize (гейт) как defense-in-depth;
  безгодовой второй проход сверки как fallback при известном годе и промахе
  первого — восстанавливает off-by-one авто-матчи и пополняет кандидатов
  review. В fallback требуем известный год кандидата (год-unknown → review,
  не авто); гейт год ±1 и инвариант авто-матча не двигаются.

Спеки recognition/metadata-match обновлены, change заархивирован.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 15:45:19 +03:00

103 lines
3.8 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 recognize
import (
"bytes"
"log/slog"
"strings"
"testing"
)
func TestFoldHomoglyphs(t *testing.T) {
cases := map[string]string{
// Latin-доминантный токен: кириллический двойник 'а' (U+0430) → 'a'.
"Hаrold and the Purple Crayon": "Harold and the Purple Crayon",
// Честная кириллица — не трогаем.
"Тёмный рыцарь": "Тёмный рыцарь",
// Честная латиница — не трогаем.
"The Dark Knight": "The Dark Knight",
// Cyrillic-доминантный токен (М р з) с латинскими 'o' (U+006F) → 'о'.
"Мoрoз": "Мороз",
// Равенство скриптов в токене (кир. О U+041E + лат. k) — не трогаем.
"Оk": "Оk",
// Двуязычные, но одно-скриптовые токены — каждый нетронут.
"Fargo Фарго": "Fargo Фарго",
// Буква-меньшинство без двойника в таблице (кир. 'б' U+0431) остаётся.
"Harбld": "Harбld",
// Пустая строка.
"": "",
}
for in, want := range cases {
if got := foldHomoglyphs(in); got != want {
t.Errorf("foldHomoglyphs(%q) = %q, want %q", in, got, want)
}
}
}
func TestSanitizeTitle(t *testing.T) {
cases := map[string]string{
// Zero-width (U+200B) внутри слова удаляется, слово склеивается.
"a\u200bb": "ab",
// Zero-width + двойник + лишние пробелы → strip + fold + collapse + trim.
" Hаrold\u200b Crayon ": "Harold Crayon",
// Табы/переводы строк как пробелы, сведены к одиночным.
"Harold\tand the": "Harold and the",
// Управляющий символ (BEL U+0007) удаляется.
"Harold\u0007": "Harold",
// BOM (U+FEFF) удаляется.
"\ufeffFargo": "Fargo",
// Уже чистое — без изменений.
"The Matrix": "The Matrix",
}
for in, want := range cases {
if got := sanitizeTitle(in); got != want {
t.Errorf("sanitizeTitle(%q) = %q, want %q", in, got, want)
}
}
}
func TestSanitizePlan_FieldsCleanedSrcUntouched(t *testing.T) {
// src намеренно содержит кириллический двойник — sanitizePlan его не трогает.
dirtySrc := "Hаrold.mkv"
p := Plan{
Title: "Hаrold",
OriginalTitle: " spaced ",
ProviderHint: "clean\u200b",
Files: []PlanFile{{Src: dirtySrc, Role: RoleMain}},
}
sanitizePlan(&p, nil)
if p.Title != "Harold" {
t.Errorf("Title = %q, want %q", p.Title, "Harold")
}
if p.OriginalTitle != "spaced" {
t.Errorf("OriginalTitle = %q, want %q", p.OriginalTitle, "spaced")
}
if p.ProviderHint != "clean" {
t.Errorf("ProviderHint = %q, want %q", p.ProviderHint, "clean")
}
if p.Files[0].Src != dirtySrc {
t.Errorf("files[].src изменён санитайзингом: %q != %q", p.Files[0].Src, dirtySrc)
}
}
func TestSanitizePlan_LogsOnChange(t *testing.T) {
var buf bytes.Buffer
log := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))
p := Plan{Title: "Hаrold"} // 'а' — кириллица, санитайзинг перепишет
sanitizePlan(&p, log)
out := buf.String()
if !strings.Contains(out, "recognition plan field sanitized") || !strings.Contains(out, "field=title") {
t.Errorf("ожидался Debug-лог о санитайзинге title, got: %q", out)
}
}
func TestSanitizePlan_NoLogWhenClean(t *testing.T) {
var buf bytes.Buffer
log := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))
p := Plan{Title: "Harold", OriginalTitle: "The Matrix"}
sanitizePlan(&p, log)
if buf.Len() != 0 {
t.Errorf("для чистых полей лога быть не должно, got: %q", buf.String())
}
}