recognize: название из метабазы санитизируется перед попаданием в план

- чистка стоит на каждой точке входа значения метабазы в план — сборка матча,
  копия кандидата для ревью, набор закреплённых значений источника и его
  чтение: гарантия, поставленная только на запись, обходится данными,
  сохранёнными прежними версиями
- название, непригодное как имя каталога (пустое или без единой буквы и
  цифры), не подставляется — раздача уходит в review с названной причиной
- гейт подтверждения матча не сдвинут: сравнение с планом идёт по значениям
  провайдера, чистится только копия, уходящая дальше
This commit is contained in:
av
2026-08-10 10:41:16 +03:00
parent bb278e8744
commit 9aecf757e0
26 changed files with 1653 additions and 53 deletions
@@ -0,0 +1,220 @@
package recognize
import (
"context"
"testing"
"git.vakhrushev.me/av/jellybit/internal/metadata"
)
// Провенанс набора входов — отчёт триажа ревью tvdb-title-locale (находка 2) и
// раздел «Воспроизведение» задачи metadata-title-sanitize. До правки каждый из
// них давал auto=true и уезжал в имя каталога библиотеки дословно.
const dunePlanResp = `{"type":"movie","title":"Dune","original_title":"Dune","year":2021,
"confidence":0.9,"files":[
{"src":"Dune.2021/movie.mkv","role":"main","season":null,"episode":null}
]}`
func duneInput() Input {
return Input{
Name: "Dune.2021.2160p.BluRay.x265",
Files: []File{{Path: "Dune.2021/movie.mkv", Size: 20 << 30}},
}
}
// recognizeWithMatchTitle прогоняет полный Recognize против базы, единственная
// запись которой названа dbTitle. Год и оригинальное название совпадают с планом,
// поэтому матч подтверждается и все прочие условия авто чисты — судим ровно
// подстановку названия.
func recognizeWithMatchTitle(t *testing.T, dbTitle string) Result {
t.Helper()
p := &fakeProvider{candidates: []metadata.Candidate{
{Provider: "tmdb", ID: "438631", Title: dbTitle, OriginalTitle: "Dune", Year: 2021},
}}
r := New(&fakeLLM{responses: []string{dunePlanResp}}, []metadata.Provider{p},
Config{MaxRetries: 2}, testLogger())
res, err := r.Recognize(context.Background(), duneInput())
if err != nil {
t.Fatalf("Recognize: %v", err)
}
return res
}
// Четыре входа из «Воспроизведения»: значение в плане обязано совпадать с тем,
// что дал бы санитайзер, а не с тем, что отдала база.
func TestRecognize_MatchTitleSanitized(t *testing.T) {
cases := []struct {
name string
db string
want string
auto bool
about string
}{
{
name: "zero-width внутри слова", db: "Du\u200bne", want: "Dune", auto: true,
about: "невидимка снимается, авто остаётся",
},
{
name: "RLO переворачивает отображение", db: "Dune\u202egnp.mkv",
want: "Dunegnp.mkv", auto: true,
about: "управляющий символ снимается",
},
{
name: "перевод строки", db: "Dune\nHACK", want: "Dune HACK", auto: true,
about: "перевод строки сводится к пробелу",
},
{
name: "кириллический двойник", db: "Dunа", want: "Duna", auto: true,
about: "двойник сворачивается в доминирующий скрипт",
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
res := recognizeWithMatchTitle(t, c.db)
if res.Match == nil {
t.Fatalf("матч должен быть подтверждён (%s)", c.about)
}
if res.Plan.Title != c.want {
t.Errorf("plan.Title = %q, want %q", res.Plan.Title, c.want)
}
if res.Plan.Title == c.db {
t.Errorf("значение базы уехало в план дословно: %q", res.Plan.Title)
}
if res.Decision.Auto != c.auto {
t.Errorf("auto = %v, want %v; reasons=%v",
res.Decision.Auto, c.auto, res.Decision.Reasons)
}
})
}
}
// Название, непригодное как имя каталога: пустое после чистки либо голая
// пунктуация. Подстановки не происходит, авто заблокирована причиной, но матч
// остаётся подтверждённым — id и год верны, распознавание не падает.
func TestRecognize_MatchTitleUnusable(t *testing.T) {
for _, db := range []string{"\u200b\u200b\u200b", ".", "...", "-", " . "} {
t.Run(db, func(t *testing.T) {
res := recognizeWithMatchTitle(t, db)
if res.Plan.Title != "Dune" {
t.Errorf("plan.Title = %q, want %q (название распознавания)",
res.Plan.Title, "Dune")
}
if res.Decision.Auto {
t.Error("авто-раскладка должна быть заблокирована")
}
if !hasReason(res.Decision.Reasons, "непригодно как имя каталога") {
t.Errorf("причина не названа: %v", res.Decision.Reasons)
}
if res.Match == nil || res.Match.ProviderID != "438631" {
t.Errorf("матч обязан остаться подтверждённым, got %+v", res.Match)
}
if res.Plan.Year != 2021 {
t.Errorf("год должен быть подставлен, got %d", res.Plan.Year)
}
})
}
}
// Нормальное название: значение базы доезжает как есть, авто разрешена. Это
// защита от регресса на TMDB — санитайзинг на чистом значении не делает ничего.
func TestRecognize_MatchTitleNormalUnchanged(t *testing.T) {
res := recognizeWithMatchTitle(t, "Dune: Part One")
if res.Plan.Title != "Dune: Part One" {
t.Errorf("plan.Title = %q, want %q", res.Plan.Title, "Dune: Part One")
}
if !res.Decision.Auto {
t.Errorf("auto = false, reasons=%v", res.Decision.Reasons)
}
}
// Кандидаты, уходящие в review, очищены: их названия закрепляет человек, и они
// становятся именем каталога так же, как каноническое.
func TestMatchMetadata_CandidatesSanitized(t *testing.T) {
p := &fakeProvider{candidates: []metadata.Candidate{
{Provider: "tmdb", ID: "1", Title: "Du\u200bne", OriginalTitle: "Dun\u200be", Year: 2021},
{Provider: "tmdb", ID: "2", Title: "Dunа Part Two", Year: 2024},
}}
r := recognizerWith(p)
_, cands := r.matchMetadata(context.Background(),
Plan{Type: MediaMovie, Title: "Nothing Matches Here", Year: 1900})
if len(cands) != 2 {
t.Fatalf("candidates = %d, want 2", len(cands))
}
if cands[0].Title != "Dune" || cands[0].OriginalTitle != "Dune" {
t.Errorf("кандидат не очищен: %+v", cands[0])
}
if cands[1].Title != "Duna Part Two" {
t.Errorf("двойник у кандидата не свёрнут: %q", cands[1].Title)
}
}
// Гейт матча не сдвинулся: кандидат, отличающийся от плана только невидимым
// символом внутри слова, сильным не считается. Сравнение идёт по значению
// провайдера — санитизируется только копия, уходящая в review.
func TestMatchMetadata_SanitizeDoesNotMoveGate(t *testing.T) {
p := &fakeProvider{candidates: []metadata.Candidate{
{Provider: "tmdb", ID: "1", Title: "Du\u200bne", Year: 2021},
}}
r := recognizerWith(p)
m, cands := r.matchMetadata(context.Background(),
Plan{Type: MediaMovie, Title: "Dune", Year: 2021})
if m != nil {
t.Errorf("невидимка внутри слова не должна давать сильный матч, got %+v", m)
}
if len(cands) != 1 || cands[0].Title != "Dune" {
t.Errorf("кандидат обязан уйти в review очищенным, got %+v", cands)
}
}
// Список кандидатов не обеднел: после подтверждённого матча у первого провайдера
// остальные продолжают пополнять список для review.
func TestMatchMetadata_CandidatesFromAllProvidersKept(t *testing.T) {
a := &fakeProvider{name: "tmdb", candidates: []metadata.Candidate{
{Provider: "tmdb", ID: "1", Title: "Dune", Year: 2021},
}}
b := &fakeProvider{name: "tvdb", candidates: []metadata.Candidate{
{Provider: "tvdb", ID: "9", Title: "Dune Other", Year: 2021},
}}
r := New(&fakeLLM{}, []metadata.Provider{a, b}, Config{}, testLogger())
m, cands := r.matchMetadata(context.Background(),
Plan{Type: MediaMovie, Title: "Dune", Year: 2021})
if m == nil {
t.Fatal("матч у первого провайдера должен подтвердиться")
}
if len(cands) != 2 {
t.Errorf("candidates = %d, want 2 (кандидаты второго провайдера не теряются)",
len(cands))
}
}
func TestUsableTitle(t *testing.T) {
usable := []string{"Dune", "2001", "Ne Zha", "Брат", "«Дюна»"}
unusable := []string{"", ".", "..", "...", "-", " . ", "—", "!?"}
for _, s := range usable {
if !UsableTitle(s) {
t.Errorf("UsableTitle(%q) = false, want true", s)
}
}
for _, s := range unusable {
if UsableTitle(s) {
t.Errorf("UsableTitle(%q) = true, want false", s)
}
}
}
// Идемпотентность несёт два утверждения сразу: что правка не двигает путей у
// раздач с нормальным названием и что «в плане нет несанитизированных полей»
// вообще проверяемо. Без теста она подразумевалась.
func TestSanitizeTitle_Idempotent(t *testing.T) {
inputs := []string{
"Du\u200bne", "Dune\u202egnp.mkv", "Dune\nHACK", "Dunа",
"Dune: Part One", " spaced out ", "\u200b\u200b\u200b", ".", "-",
"Тёмный рыцарь", "哪吒之魔童降世",
}
for _, in := range inputs {
once := SanitizeTitle(in)
if twice := SanitizeTitle(once); twice != once {
t.Errorf("sanitizeTitle не идемпотентен на %q: %q → %q", in, once, twice)
}
}
}