Кейс «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>
103 lines
3.8 KiB
Go
103 lines
3.8 KiB
Go
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())
|
||
}
|
||
}
|