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) } } }