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
+121
View File
@@ -2285,3 +2285,124 @@ func TestSweepLinking_LeavesOtherStates(t *testing.T) {
t.Errorf("review task moved to %q", st.downloads["2"].State)
}
}
// Кандидат с непригодным названием (голая пунктуация — TVDB правится
// сообществом, мусорные записи там штатны): пин названия не ставится, и в
// эффективном плане остаётся название распознавания. Иначе в библиотеке
// появился бы каталог «- (2000)», а на «.» раскладка упала бы ошибкой слоя
// layout — тот же исход, что и у канонического названия на авто-пути.
func TestChooseCandidate_UnusableTitleNotPinned(t *testing.T) {
for _, title := range []string{"-", ".", "..."} {
t.Run(title, func(t *testing.T) {
w, st := reviewWithCandidate(t, store.MetadataCandidate{
Provider: "tvdb", ProviderID: "269613",
Title: store.NullString(title),
Year: sql.NullInt64{Int64: 2014, Valid: true},
})
if err := w.ChooseCandidate(context.Background(), "1", st.candidates[0].ID); err != nil {
t.Fatalf("ChooseCandidate: %v", err)
}
if ov := st.overrides["1"]; ov[ovrTitle] != "" {
t.Errorf("непригодное название закреплено: %q", ov[ovrTitle])
}
// Провайдер, id и год закрепляются как обычно — матч верен.
if ov := st.overrides["1"]; ov[ovrProviderID] != "269613" || ov[ovrYear] != "2014" {
t.Errorf("overrides = %v", st.overrides["1"])
}
plan, _, _, err := w.effectivePlan(context.Background(), "1")
if err != nil {
t.Fatalf("effectivePlan: %v", err)
}
if plan.Title != "Догадка" {
t.Errorf("plan.Title = %q, want название распознавания", plan.Title)
}
})
}
}
// Кандидат, сохранённый ПРЕЖНЕЙ версией (до чистки на входе в список), лежит в
// БД грязным. Гарантия чистоты не может держаться на времени записи: чистка
// стоит на закреплении, в sourcePins, и идемпотентна — на новых строках это
// no-op. Без неё невидимка доезжает до имени каталога, потому что
// layout.sanitizeComponent категорию Cf не трогает.
func TestChooseCandidate_DirtyLegacyTitleSanitized(t *testing.T) {
w, st := reviewWithCandidate(t, store.MetadataCandidate{
Provider: "tvdb", ProviderID: "269613",
Title: store.NullString("Far\u200bgo"),
Year: sql.NullInt64{Int64: 2014, Valid: true},
})
if err := w.ChooseCandidate(context.Background(), "1", st.candidates[0].ID); err != nil {
t.Fatalf("ChooseCandidate: %v", err)
}
if got := st.overrides["1"][ovrTitle]; got != "Fargo" {
t.Errorf("закреплено %q, want %q — грязная строка из БД не очищена", got, "Fargo")
}
plan, _, _, err := w.effectivePlan(context.Background(), "1")
if err != nil {
t.Fatalf("effectivePlan: %v", err)
}
if plan.Title != "Fargo" {
t.Errorf("plan.Title = %q", plan.Title)
}
}
// Превью = применение: строка источника показывает ровно то название, которое
// закрепится по клику. Прежде превью считалось из сырого названия кандидата, а
// гейт пригодности стоял только на закреплении — экран обещал одно, раскладка
// делала другое.
func TestBuildSources_PreviewMatchesApply(t *testing.T) {
for _, c := range []struct{ name, dbTitle, want string }{
{"непригодное название", "-", "Догадка"},
{"грязное название", "Far\u200bgo", "Fargo"},
} {
t.Run(c.name, func(t *testing.T) {
w, st := reviewWithCandidate(t, store.MetadataCandidate{
Provider: "tvdb", ProviderID: "269613",
Title: store.NullString(c.dbTitle),
Year: sql.NullInt64{Int64: 2014, Valid: true},
})
rd, err := w.ReviewData(context.Background(), "1")
if err != nil {
t.Fatalf("ReviewData: %v", err)
}
var src *SourceOption
for i := range rd.Sources {
if rd.Sources[i].Kind == SourceCandidate {
src = &rd.Sources[i]
}
}
if src == nil {
t.Fatal("кандидат не попал в источники")
}
if src.Title != c.want {
t.Errorf("превью показывает %q, want %q", src.Title, c.want)
}
if err := w.ChooseCandidate(context.Background(), "1", st.candidates[0].ID); err != nil {
t.Fatalf("ChooseCandidate: %v", err)
}
plan, _, _, err := w.effectivePlan(context.Background(), "1")
if err != nil {
t.Fatalf("effectivePlan: %v", err)
}
if plan.Title != src.Title {
t.Errorf("превью %q, применилось %q — расхождение", src.Title, plan.Title)
}
})
}
}
// Пин, закреплённый ПРЕЖНЕЙ версией, лежит в overrides грязным, и обычное
// «Применить» его не переписывает — источник заново не выбирают. Поэтому чистка
// стоит и на чтении: иначе раздача, стоящая в ревью на момент выката, создаёт
// ровно тот каталог, ради которого затевалась правка.
func TestApplyOverrides_LegacyDirtyPinSanitized(t *testing.T) {
plan := recognize.Plan{Type: recognize.MediaMovie, Title: "Догадка", Year: 2000}
got := applyOverrides(plan, map[string]string{ovrTitle: "Fa\u200brgo"})
if got.Title != "Fargo" {
t.Errorf("plan.Title = %q, want %q — грязный пин доехал до раскладки", got.Title, "Fargo")
}
// Непригодный пин названием не становится: остаётся название распознавания.
if got := applyOverrides(plan, map[string]string{ovrTitle: "-"}); got.Title != "Догадка" {
t.Errorf("plan.Title = %q, want название распознавания", got.Title)
}
}