Реализовал выбор источника и предпросмотр в ревью (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:
+220
-25
@@ -6,6 +6,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -569,20 +570,99 @@ func (w *Worker) ChooseCandidate(ctx context.Context, id, candidateID string) er
|
||||
if rec == nil || cand == nil || cand.RecognitionID != rec.ID {
|
||||
return fmt.Errorf("choose candidate: candidate %s does not belong to the current recognition", candidateID)
|
||||
}
|
||||
return w.chooseCandidateLocked(ctx, id, d, rec, *cand)
|
||||
}
|
||||
|
||||
pins := map[string]string{ovrProvider: cand.Provider, ovrProviderID: cand.ProviderID}
|
||||
if cand.Title.Valid && cand.Title.String != "" {
|
||||
pins[ovrTitle] = cand.Title.String
|
||||
// AddManualSource добавляет источник вручную по (provider, id) и выбирает его.
|
||||
// Когда автопоиск промахнулся: сохраняем кандидата (дедуп по provider:id) и
|
||||
// пиннит как выбранный. provider — из набора tmdb/tvdb/imdb.
|
||||
func (w *Worker) AddManualSource(ctx context.Context, id, provider, providerID string) error {
|
||||
provider = strings.TrimSpace(strings.ToLower(provider))
|
||||
providerID = strings.TrimSpace(providerID)
|
||||
switch provider {
|
||||
case "tmdb", "tvdb", "imdb":
|
||||
default:
|
||||
return fmt.Errorf("add source: invalid provider %q (tmdb/tvdb/imdb)", provider)
|
||||
}
|
||||
if providerID == "" {
|
||||
return fmt.Errorf("add source: empty id")
|
||||
}
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
|
||||
d, err := w.requireReviewable(ctx, id, "add source")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rec, err := w.store.GetCurrentRecognition(ctx, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("add source: %w", err)
|
||||
}
|
||||
if rec == nil {
|
||||
return fmt.Errorf("add source: no recognition")
|
||||
}
|
||||
cand, err := w.findOrCreateCandidate(ctx, rec.ID, provider, providerID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("add source: %w", err)
|
||||
}
|
||||
return w.chooseCandidateLocked(ctx, id, d, rec, *cand)
|
||||
}
|
||||
|
||||
// findOrCreateCandidate возвращает кандидата рекогниции по (provider, id),
|
||||
// создавая его при отсутствии (дедуп по provider:id). Под mu.
|
||||
func (w *Worker) findOrCreateCandidate(ctx context.Context, recognitionID, provider, providerID string) (*store.MetadataCandidate, error) {
|
||||
cands, err := w.store.ListCandidatesByRecognition(ctx, recognitionID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if c := findCandidate(cands, provider, providerID); c != nil {
|
||||
return c, nil
|
||||
}
|
||||
if err := w.store.CreateCandidates(ctx, []store.MetadataCandidate{{
|
||||
RecognitionID: recognitionID,
|
||||
Provider: provider,
|
||||
ProviderID: providerID,
|
||||
}}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cands, err = w.store.ListCandidatesByRecognition(ctx, recognitionID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if c := findCandidate(cands, provider, providerID); c != nil {
|
||||
return c, nil
|
||||
}
|
||||
return nil, fmt.Errorf("candidate %s:%s not found after create", provider, providerID)
|
||||
}
|
||||
|
||||
func findCandidate(cands []store.MetadataCandidate, provider, providerID string) *store.MetadataCandidate {
|
||||
for i := range cands {
|
||||
if cands[i].Provider == provider && cands[i].ProviderID == providerID {
|
||||
return &cands[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// chooseCandidateLocked пиннит кандидата как выбранный источник. Пишет ПОЛНЫЙ
|
||||
// самосогласованный набор пинов (provider/id/title/year): пустые title/year у
|
||||
// кандидата очищают возможный унаследованный пин прежнего источника — иначе
|
||||
// превью разошлось бы с применением (решение 1a). Под mu.
|
||||
func (w *Worker) chooseCandidateLocked(ctx context.Context, id string, d *store.Download, rec *store.Recognition, cand store.MetadataCandidate) error {
|
||||
title := ""
|
||||
if cand.Title.Valid {
|
||||
title = cand.Title.String
|
||||
}
|
||||
year := 0
|
||||
if cand.Year.Valid {
|
||||
pins[ovrYear] = strconv.FormatInt(cand.Year.Int64, 10)
|
||||
year = int(cand.Year.Int64)
|
||||
}
|
||||
for field, value := range pins {
|
||||
for field, value := range sourcePins(cand.Provider, cand.ProviderID, title, year) {
|
||||
if err := w.store.SetOverride(ctx, id, field, value); err != nil {
|
||||
return fmt.Errorf("choose candidate: %w", err)
|
||||
}
|
||||
}
|
||||
if err := w.store.SetCandidateChosen(ctx, rec.ID, candidateID); err != nil {
|
||||
if err := w.store.SetCandidateChosen(ctx, rec.ID, cand.ID); err != nil {
|
||||
return fmt.Errorf("choose candidate: %w", err)
|
||||
}
|
||||
logctx.From(w.scoped(ctx, capReview, id, d.PrimaryInfohash())).Info("review candidate chosen",
|
||||
@@ -609,18 +689,20 @@ func (w *Worker) SetProviderID(ctx context.Context, id string, provider, provide
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := w.store.SetOverride(ctx, id, ovrProvider, provider); err != nil {
|
||||
return fmt.Errorf("set provider: %w", err)
|
||||
}
|
||||
if err := w.store.SetOverride(ctx, id, ovrProviderID, providerID); err != nil {
|
||||
return fmt.Errorf("set provider: %w", err)
|
||||
// Полный набор пинов: id задан вручную, название/год берём из плана
|
||||
// (очищаем возможный унаследованный пин прежнего источника).
|
||||
for field, value := range sourcePins(provider, providerID, "", 0) {
|
||||
if err := w.store.SetOverride(ctx, id, field, value); err != nil {
|
||||
return fmt.Errorf("set provider: %w", err)
|
||||
}
|
||||
}
|
||||
logctx.From(w.scoped(ctx, capReview, id, d.PrimaryInfohash())).Info("review provider set",
|
||||
"provider", provider, "provider_id", providerID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ClearProvider — «без базы»: снимает матч (тег папки не ставится).
|
||||
// ClearProvider — «без базы»: снимает матч (тег папки не ставится) и очищает
|
||||
// пины названия/года (источник — распознавание нейронкой).
|
||||
func (w *Worker) ClearProvider(ctx context.Context, id string) error {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
@@ -629,16 +711,33 @@ func (w *Worker) ClearProvider(ctx context.Context, id string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := w.store.SetOverride(ctx, id, ovrProvider, "none"); err != nil {
|
||||
return fmt.Errorf("clear provider: %w", err)
|
||||
}
|
||||
if err := w.store.SetOverride(ctx, id, ovrProviderID, ""); err != nil {
|
||||
return fmt.Errorf("clear provider: %w", err)
|
||||
for field, value := range sourcePins("none", "", "", 0) {
|
||||
if err := w.store.SetOverride(ctx, id, field, value); err != nil {
|
||||
return fmt.Errorf("clear provider: %w", err)
|
||||
}
|
||||
}
|
||||
logctx.From(w.scoped(ctx, capReview, id, d.PrimaryInfohash())).Info("review provider cleared")
|
||||
return nil
|
||||
}
|
||||
|
||||
// sourcePins — полный самосогласованный набор пинов источника (решение 1a):
|
||||
// title/year пишутся пустой строкой, если у источника их нет; в applyOverrides
|
||||
// пустая строка трактуется как «нет override» → берётся значение плана. Так
|
||||
// выбор любого источника даёт детерминированный эффективный план, а превью
|
||||
// совпадает с применением. Используется и в коммите (SetOverride), и в превью.
|
||||
func sourcePins(provider, providerID, title string, year int) map[string]string {
|
||||
yr := ""
|
||||
if year > 0 {
|
||||
yr = strconv.Itoa(year)
|
||||
}
|
||||
return map[string]string{
|
||||
ovrProvider: provider,
|
||||
ovrProviderID: providerID,
|
||||
ovrTitle: title,
|
||||
ovrYear: yr,
|
||||
}
|
||||
}
|
||||
|
||||
// --- Данные для экрана ревью ---
|
||||
|
||||
// ReviewData — всё, что нужно транспорту для отрисовки ревью.
|
||||
@@ -646,14 +745,39 @@ type ReviewData struct {
|
||||
Download store.Download
|
||||
Recognition *store.Recognition
|
||||
Plan recognize.Plan // эффективный (с применёнными правками)
|
||||
Preview []layout.Link // целевые пути (Src — относительный, для показа)
|
||||
Preview []layout.Link // целевые пути активного источника (Src — относительный)
|
||||
Candidates []store.MetadataCandidate // кандидаты базы для ручного выбора
|
||||
Sources []SourceOption // единый список источников совпадения (нейронка + кандидаты)
|
||||
Provider string // эффективный провайдер (с учётом выбора)
|
||||
ProviderID string // эффективный id в базе
|
||||
Hints []string
|
||||
Overrides map[string]string
|
||||
}
|
||||
|
||||
// SourceKind — вид источника в едином списке ревью.
|
||||
type SourceKind string
|
||||
|
||||
const (
|
||||
SourceNeural SourceKind = "neural" // распознавание нейронкой (без базы)
|
||||
SourceCandidate SourceKind = "candidate" // кандидат метабазы (в т.ч. добавленный вручную)
|
||||
)
|
||||
|
||||
// SourceOption — источник совпадения в списке ревью: эффективные поля и
|
||||
// предпросмотр целевых путей, посчитанные эфемерно (без записи overrides).
|
||||
type SourceOption struct {
|
||||
Kind SourceKind
|
||||
CandidateID string // ULID кандидата (пусто для нейронки)
|
||||
Provider string // "none" для нейронки
|
||||
ProviderID string
|
||||
URL string // ссылка кандидата на запись (если есть)
|
||||
Title string // эффективное название для этого источника
|
||||
Year int // эффективный год
|
||||
Type string // "movie" | "series"
|
||||
Active bool // текущий эффективный источник
|
||||
Plan recognize.Plan // эффективный план (для показа файлов → раскладка)
|
||||
Preview []layout.Link // целевые пути этого источника
|
||||
}
|
||||
|
||||
// ReviewData собирает данные ревью по загрузке.
|
||||
func (w *Worker) ReviewData(ctx context.Context, id string) (*ReviewData, error) {
|
||||
d, err := w.store.GetDownload(ctx, id)
|
||||
@@ -687,27 +811,98 @@ func (w *Worker) ReviewData(ctx context.Context, id string) (*ReviewData, error)
|
||||
}
|
||||
}
|
||||
if rec != nil && rec.Plan.Valid {
|
||||
var plan recognize.Plan
|
||||
if err := json.Unmarshal([]byte(rec.Plan.String), &plan); err != nil {
|
||||
var rawPlan recognize.Plan
|
||||
if err := json.Unmarshal([]byte(rec.Plan.String), &rawPlan); err != nil {
|
||||
log.Warn("review data unmarshal plan failed", "error", err)
|
||||
} else {
|
||||
plan = applyOverrides(plan, overrides)
|
||||
rd.Plan = plan
|
||||
// Превью строим по относительным путям с provider-тегом; ошибку
|
||||
// логируем на Debug — просто покажем причины без превью.
|
||||
rd.Plan = applyOverrides(rawPlan, overrides)
|
||||
// Превью активного источника строим по относительным путям с
|
||||
// provider-тегом; ошибку логируем на Debug — покажем без превью.
|
||||
if w.layouter != nil {
|
||||
tag := providerTag(prov, pid)
|
||||
if links, lerr := w.layouter.BuildLinks(toLayoutPlan(plan, "", tag)); lerr == nil {
|
||||
if links, lerr := w.layouter.BuildLinks(toLayoutPlan(rd.Plan, "", tag)); lerr == nil {
|
||||
rd.Preview = links
|
||||
} else {
|
||||
log.Debug("review data build preview failed", "error", lerr)
|
||||
}
|
||||
}
|
||||
// Единый список источников: нейронка + кандидаты, каждый с
|
||||
// эфемерным превью из сырого плана (без записи overrides).
|
||||
rd.Sources = w.buildSources(rawPlan, overrides, prov, pid, rd.Candidates)
|
||||
}
|
||||
}
|
||||
return rd, nil
|
||||
}
|
||||
|
||||
// buildSources собирает единый список источников: нейронка (первой) +
|
||||
// кандидаты (дедуп по provider:id). Активным помечается текущий эффективный
|
||||
// источник.
|
||||
func (w *Worker) buildSources(rawPlan recognize.Plan, overrides map[string]string, prov, pid string, cands []store.MetadataCandidate) []SourceOption {
|
||||
neutral := prov == "" || prov == "none"
|
||||
out := make([]SourceOption, 0, len(cands)+1)
|
||||
out = append(out, w.sourceOption(SourceNeural, rawPlan, overrides, "", "none", "", "", "", 0, neutral))
|
||||
seen := map[string]bool{}
|
||||
for _, c := range cands {
|
||||
key := c.Provider + ":" + c.ProviderID
|
||||
if seen[key] {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
title := ""
|
||||
if c.Title.Valid {
|
||||
title = c.Title.String
|
||||
}
|
||||
year := 0
|
||||
if c.Year.Valid {
|
||||
year = int(c.Year.Int64)
|
||||
}
|
||||
active := !neutral && c.Provider == prov && c.ProviderID == pid
|
||||
out = append(out, w.sourceOption(SourceCandidate, rawPlan, overrides, c.ID, c.Provider, c.ProviderID, c.URL.String, title, year, active))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// sourceOption строит один источник: накладывает его пины на неисточниковые
|
||||
// overrides, считает эффективный план и предпросмотр путей — эфемерно, без
|
||||
// записи. Гарантия preview == apply: тот же набор пинов запишет выбор.
|
||||
func (w *Worker) sourceOption(kind SourceKind, rawPlan recognize.Plan, base map[string]string, candID, provider, providerID, url, title string, year int, active bool) SourceOption {
|
||||
eff := applyOverrides(rawPlan, mergeSourceOverrides(base, sourcePins(provider, providerID, title, year)))
|
||||
opt := SourceOption{
|
||||
Kind: kind,
|
||||
CandidateID: candID,
|
||||
Provider: provider,
|
||||
ProviderID: providerID,
|
||||
URL: url,
|
||||
Title: eff.Title,
|
||||
Year: eff.Year,
|
||||
Type: string(eff.Type),
|
||||
Active: active,
|
||||
Plan: eff,
|
||||
}
|
||||
if w.layouter != nil {
|
||||
if links, err := w.layouter.BuildLinks(toLayoutPlan(eff, "", providerTag(provider, providerID))); err == nil {
|
||||
opt.Preview = links
|
||||
}
|
||||
}
|
||||
return opt
|
||||
}
|
||||
|
||||
// mergeSourceOverrides накладывает пины источника (provider/id/title/year) на
|
||||
// неисточниковые overrides (media_type, ignored_files, force_review, ...).
|
||||
func mergeSourceOverrides(base, pins map[string]string) map[string]string {
|
||||
m := make(map[string]string, len(base)+len(pins))
|
||||
for k, v := range base {
|
||||
switch k {
|
||||
case ovrProvider, ovrProviderID, ovrTitle, ovrYear:
|
||||
continue
|
||||
default:
|
||||
m[k] = v
|
||||
}
|
||||
}
|
||||
maps.Copy(m, pins)
|
||||
return m
|
||||
}
|
||||
|
||||
// effectivePlan загружает текущий план, применяет правки и возвращает
|
||||
// provider-тег для имени папки (под mu).
|
||||
func (w *Worker) effectivePlan(ctx context.Context, id string) (recognize.Plan, string, error) {
|
||||
|
||||
@@ -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