Files
jellybit/internal/recognize/metadata_sanitize_test.go
T
av 9aecf757e0 recognize: название из метабазы санитизируется перед попаданием в план
- чистка стоит на каждой точке входа значения метабазы в план — сборка матча,
  копия кандидата для ревью, набор закреплённых значений источника и его
  чтение: гарантия, поставленная только на запись, обходится данными,
  сохранёнными прежними версиями
- название, непригодное как имя каталога (пустое или без единой буквы и
  цифры), не подставляется — раздача уходит в review с названной причиной
- гейт подтверждения матча не сдвинут: сравнение с планом идёт по значениям
  провайдера, чистится только копия, уходящая дальше
2026-08-10 10:41:16 +03:00

221 lines
9.5 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 (
"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)
}
}
}