Реализовал выбор источника и предпросмотр в ревью (review-source-selection)
Экран ревью теперь показывает единый список источников совпадения: строка «распознано нейронкой» наравне с кандидатами баз; выбор/переключение/снятие в пользу нейронки; ручное добавление по id или URL (TMDB/IMDb — по URL, TVDB — по числовому id); предпросмотр полей и целевых путей каждого источника до применения (место под режиссёра зарезервировано). «Раскладка» осталась отдельной секцией для активного источника, инлайн-превью неактивных — по клику. Ядро: единая деривация «источник → overrides» (sourcePins), общая для превью и коммита → preview == apply; заодно чинит латентный залипший override title/year при переключении источника. Превью считается эфемерно, без записи в БД; пользовательский URL только парсится (SSRF нет). Ревью дизайна и кода пройдены; правки ревьюеров учтены (сообщение об ошибке ручного ввода доходит до пользователя, URL без схемы принимается). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1179,6 +1179,158 @@ func TestClearProvider(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestClearProvider_ClearsInheritedTitle — регрессия на залипший override
|
||||
// (решение 1a): после выбора титульного кандидата переключение на нейронку
|
||||
// возвращает название/год плана, а не унаследованные от кандидата.
|
||||
func TestClearProvider_ClearsInheritedTitle(t *testing.T) {
|
||||
w, st := reviewWithCandidate(t, store.MetadataCandidate{
|
||||
Provider: "tvdb", ProviderID: "269613",
|
||||
Title: store.NullString("Fargo"), Year: sql.NullInt64{Int64: 2014, Valid: true},
|
||||
})
|
||||
candID := st.candidates[0].ID
|
||||
if err := w.ChooseCandidate(context.Background(), "1", candID); err != nil {
|
||||
t.Fatalf("ChooseCandidate: %v", err)
|
||||
}
|
||||
if err := w.ClearProvider(context.Background(), "1"); err != nil {
|
||||
t.Fatalf("ClearProvider: %v", err)
|
||||
}
|
||||
// Название/год снова из плана распознавания, тег пуст.
|
||||
plan, tag, err := w.effectivePlan(context.Background(), "1")
|
||||
if err != nil {
|
||||
t.Fatalf("effectivePlan: %v", err)
|
||||
}
|
||||
if plan.Title != "Догадка" || plan.Year != 2000 {
|
||||
t.Errorf("plan = %q (%d), want plan-defaults", plan.Title, plan.Year)
|
||||
}
|
||||
if tag != "" {
|
||||
t.Errorf("tag = %q, want empty", tag)
|
||||
}
|
||||
if ov := st.overrides["1"]; ov[ovrTitle] != "" || ov[ovrYear] != "" {
|
||||
t.Errorf("title/year override not cleared: %v", ov)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAddManualSource_ChoosesAndClears — ручной источник создаётся, выбирается
|
||||
// и (будучи безтайтловым) очищает унаследованный пин названия.
|
||||
func TestAddManualSource_ChoosesAndClears(t *testing.T) {
|
||||
w, st := reviewWithCandidate(t, store.MetadataCandidate{
|
||||
Provider: "tvdb", ProviderID: "269613",
|
||||
Title: store.NullString("Fargo"), 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 err := w.AddManualSource(context.Background(), "1", "TMDB", " 603 "); err != nil {
|
||||
t.Fatalf("AddManualSource: %v", err)
|
||||
}
|
||||
if len(st.candidates) != 2 {
|
||||
t.Fatalf("candidates = %d, want 2 (manual added)", len(st.candidates))
|
||||
}
|
||||
plan, tag, err := w.effectivePlan(context.Background(), "1")
|
||||
if err != nil {
|
||||
t.Fatalf("effectivePlan: %v", err)
|
||||
}
|
||||
if plan.Title != "Догадка" { // безтайтловый источник → название из плана
|
||||
t.Errorf("plan title = %q, want plan-default", plan.Title)
|
||||
}
|
||||
if tag != "tmdbid-603" {
|
||||
t.Errorf("tag = %q", tag)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddManualSource_DedupAndInvalid(t *testing.T) {
|
||||
w, st := reviewWithCandidate(t, store.MetadataCandidate{Provider: "tvdb", ProviderID: "269613"})
|
||||
// Дубль (provider:id уже есть) → не плодим строку, выбираем существующую.
|
||||
if err := w.AddManualSource(context.Background(), "1", "tvdb", "269613"); err != nil {
|
||||
t.Fatalf("AddManualSource dup: %v", err)
|
||||
}
|
||||
if len(st.candidates) != 1 {
|
||||
t.Errorf("candidates = %d, want 1 (dedup)", len(st.candidates))
|
||||
}
|
||||
if !st.candidates[0].Chosen {
|
||||
t.Error("существующий кандидат должен стать выбранным")
|
||||
}
|
||||
if err := w.AddManualSource(context.Background(), "1", "kinopoisk", "1"); err == nil {
|
||||
t.Error("недопустимый провайдер должен отклоняться")
|
||||
}
|
||||
if err := w.AddManualSource(context.Background(), "1", "tmdb", " "); err == nil {
|
||||
t.Error("пустой id должен отклоняться")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReviewData_BuildsSources(t *testing.T) {
|
||||
w, st := reviewWithCandidate(t, store.MetadataCandidate{
|
||||
Provider: "tvdb", ProviderID: "269613", Title: store.NullString("Fargo"),
|
||||
})
|
||||
rd, err := w.ReviewData(context.Background(), "1")
|
||||
if err != nil {
|
||||
t.Fatalf("ReviewData: %v", err)
|
||||
}
|
||||
// Построение источников (в т.ч. предпросмотр) не должно писать overrides.
|
||||
if ov := st.overrides["1"]; len(ov) != 0 {
|
||||
t.Errorf("ReviewData записал overrides: %v", ov)
|
||||
}
|
||||
if len(rd.Sources) != 2 {
|
||||
t.Fatalf("sources = %d, want 2 (neural + candidate)", len(rd.Sources))
|
||||
}
|
||||
if rd.Sources[0].Kind != SourceNeural || !rd.Sources[0].Active {
|
||||
t.Errorf("source[0] = %+v, want активная нейронка", rd.Sources[0])
|
||||
}
|
||||
if rd.Sources[0].Title != "Догадка" {
|
||||
t.Errorf("neural title = %q, want plan title", rd.Sources[0].Title)
|
||||
}
|
||||
if rd.Sources[1].Kind != SourceCandidate || rd.Sources[1].Title != "Fargo" || rd.Sources[1].Active {
|
||||
t.Errorf("source[1] = %+v, want неактивный кандидат Fargo", rd.Sources[1])
|
||||
}
|
||||
}
|
||||
|
||||
// TestReviewData_SourcePreviewMatchesChosen — предпросмотр источника совпадает
|
||||
// с превью активного источника после его выбора (эфемерное превью == то, что
|
||||
// применится). Требует реального layouter.
|
||||
func TestReviewData_SourcePreviewMatchesChosen(t *testing.T) {
|
||||
f := newApplyFixture(t, seriesResult().Plan)
|
||||
_ = f.st.CreateCandidates(context.Background(), []store.MetadataCandidate{
|
||||
{RecognitionID: "1", Provider: "tvdb", ProviderID: "269613"},
|
||||
})
|
||||
candID := f.st.candidates[0].ID
|
||||
|
||||
rd, err := f.w.ReviewData(context.Background(), "1")
|
||||
if err != nil {
|
||||
t.Fatalf("ReviewData: %v", err)
|
||||
}
|
||||
var srcPreview []layout.Link
|
||||
for _, s := range rd.Sources {
|
||||
if s.Kind == SourceCandidate && s.ProviderID == "269613" {
|
||||
srcPreview = s.Preview
|
||||
}
|
||||
}
|
||||
if len(srcPreview) == 0 {
|
||||
t.Fatal("предпросмотр кандидата пуст")
|
||||
}
|
||||
if err := f.w.ChooseCandidate(context.Background(), "1", candID); err != nil {
|
||||
t.Fatalf("ChooseCandidate: %v", err)
|
||||
}
|
||||
rd2, err := f.w.ReviewData(context.Background(), "1")
|
||||
if err != nil {
|
||||
t.Fatalf("ReviewData: %v", err)
|
||||
}
|
||||
if !sameLinks(srcPreview, rd2.Preview) {
|
||||
t.Errorf("превью источника != превью активного:\n src=%v\n act=%v", srcPreview, rd2.Preview)
|
||||
}
|
||||
}
|
||||
|
||||
func sameLinks(a, b []layout.Link) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i].Src != b[i].Src || a[i].Dst != b[i].Dst {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func TestReviewData_IncludesCandidates(t *testing.T) {
|
||||
w, st := reviewWithCandidate(t, store.MetadataCandidate{
|
||||
Provider: "tvdb", ProviderID: "269613", Title: store.NullString("Fargo"),
|
||||
|
||||
Reference in New Issue
Block a user