- чистка стоит на каждой точке входа значения метабазы в план — сборка матча, копия кандидата для ревью, набор закреплённых значений источника и его чтение: гарантия, поставленная только на запись, обходится данными, сохранёнными прежними версиями - название, непригодное как имя каталога (пустое или без единой буквы и цифры), не подставляется — раздача уходит в review с названной причиной - гейт подтверждения матча не сдвинут: сравнение с планом идёт по значениям провайдера, чистится только копия, уходящая дальше
221 lines
9.5 KiB
Go
221 lines
9.5 KiB
Go
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)
|
||
}
|
||
}
|
||
}
|