Единый источник полей отображаемого имени и один рендер полного ярлыка на всех путях (старт и «Обновить имя»/авто-перелив). Раньше старт давал полный «Название (режиссёр, год). Сезон N» но выбрасывал структуру, а перелив по распознаванию — усечённый «Title (Year)». - Слоистое разрешение скаляров имени: override → recognition(+match) → новый базовый слой «контекст» (download.parsed_context, JSON naming.Fields). - naming: публичные Fields/Label/Derive, вынесен единый рендер; удалён FormatTitleYear. Сводка сезонов вынесена в recognize.SeasonSummary. - Режиссёр из метабазы (решение A2): TMDB/TVDB credits через опциональный metadata.DirectorProvider; авто-матч кладёт в plan.Director, ручной выбор кандидата тянет credits и пиннит ovrDirector. Метабаза бьёт контекст. - refreshDisplayNameLocked строит полный ярлык из эффективных полей; инфо-панель ревью показывает загруженного режиссёра. - Миграция 0011_parsed_context + ER-схема. Всё косметика: на пути/раскладку не влияет, приём/вывод имени не валятся (best-effort). Закрывает беклог-задачу «Кнопка „Обновить имя“: полный формат ярлыка». OpenSpec: archive/2026-07-11-field-resolution-display-name (ingest, recognition, metadata-match, review). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
251 lines
10 KiB
Go
251 lines
10 KiB
Go
package worker
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"testing"
|
||
"time"
|
||
|
||
"git.vakhrushev.me/av/jellybit/internal/naming"
|
||
"git.vakhrushev.me/av/jellybit/internal/qbt"
|
||
"git.vakhrushev.me/av/jellybit/internal/store"
|
||
)
|
||
|
||
// fakeNamer — вывод имени для шага добавления. onCall позволяет вклиниться в
|
||
// момент (медленного) вывода имени, симулируя параллельную отмену.
|
||
type fakeNamer struct {
|
||
name string
|
||
fields naming.Fields // извлечённая структура (ok=true, если Title непуст)
|
||
gotContext string
|
||
calls int
|
||
onCall func()
|
||
}
|
||
|
||
func (f *fakeNamer) Derive(_ context.Context, contextText, _ string) (string, naming.Fields, bool) {
|
||
f.calls++
|
||
f.gotContext = contextText
|
||
if f.onCall != nil {
|
||
f.onCall()
|
||
}
|
||
return f.name, f.fields, f.fields.Title != ""
|
||
}
|
||
|
||
func catchedStore(id, infohash, createdAt, ctxText string) *fakeStore {
|
||
return &fakeStore{downloads: map[string]*store.Download{
|
||
id: {
|
||
ID: id,
|
||
State: store.StateCatched,
|
||
SourceType: store.SourceMagnet,
|
||
SourceRef: "magnet:?xt=urn:btih:" + infohash + "&dn=Dune",
|
||
Infohashes: hashesOf(id, infohash),
|
||
Context: ctxText,
|
||
CreatedAt: createdAt,
|
||
},
|
||
}}
|
||
}
|
||
|
||
const catchedIH = "541adcff3b6dd5dba7088ea83317d9d6fac331d6"
|
||
|
||
// now воркера в тестах — 2026-06-14 10:00 UTC (см. newTestWorker).
|
||
var nowStr = store.FormatTime(time.Date(2026, 6, 14, 10, 0, 0, 0, time.UTC))
|
||
|
||
// Успех: выводим имя, добавляем в qBit с rename, переводим catched → downloading
|
||
// и сохраняем display_name.
|
||
func TestProcessCatchedAddsToQbit(t *testing.T) {
|
||
st := catchedStore("1", catchedIH, nowStr, "Дюна 2")
|
||
qb := &fakeQbt{}
|
||
w := newTestWorker(st, qb)
|
||
nm := &fakeNamer{
|
||
name: "Дюна: Часть вторая (2024)",
|
||
fields: naming.Fields{Type: "movie", Title: "Дюна: Часть вторая", Director: "Дени Вильнёв", Year: 2024},
|
||
}
|
||
w.SetNamer(nm)
|
||
|
||
w.processCatched(context.Background())
|
||
|
||
if len(qb.added) != 1 {
|
||
t.Fatalf("qbt.Add calls = %d, want 1", len(qb.added))
|
||
}
|
||
add := qb.added[0]
|
||
if add.Rename != "Дюна: Часть вторая (2024)" {
|
||
t.Errorf("rename = %q", add.Rename)
|
||
}
|
||
if add.Category != "jellybit" || add.URLs[0] != st.downloads["1"].SourceRef {
|
||
t.Errorf("add = %+v", add)
|
||
}
|
||
if nm.gotContext != "Дюна 2" {
|
||
t.Errorf("namer получил контекст %q", nm.gotContext)
|
||
}
|
||
d := st.downloads["1"]
|
||
if d.State != store.StateDownloading {
|
||
t.Errorf("state = %q, want downloading", d.State)
|
||
}
|
||
if d.DisplayName != "Дюна: Часть вторая (2024)" {
|
||
t.Errorf("display_name = %q", d.DisplayName)
|
||
}
|
||
// Извлечённая структура сохранена как parsed_context (базовый слой полей имени).
|
||
if d.ParsedContext == "" {
|
||
t.Error("parsed_context пуст, ожидалась сохранённая структура")
|
||
}
|
||
var pf naming.Fields
|
||
if err := json.Unmarshal([]byte(d.ParsedContext), &pf); err != nil {
|
||
t.Fatalf("parsed_context не JSON: %v", err)
|
||
}
|
||
if pf.Director != "Дени Вильнёв" {
|
||
t.Errorf("parsed_context.director = %q, want «Дени Вильнёв»", pf.Director)
|
||
}
|
||
}
|
||
|
||
// Торрент пойманной загрузки уже присутствует в qBittorrent (добавлен ранее
|
||
// вручную/другим клиентом) → усыновляем: promote в downloading без повторного
|
||
// Add (и без 409) и без namer; имя берём из раздачи снимка.
|
||
func TestProcessCatchedPresentAdopts(t *testing.T) {
|
||
st := catchedStore("1", catchedIH, nowStr, "Дюна 2")
|
||
qb := &fakeQbt{torrents: []qbt.Torrent{
|
||
{Hash: catchedIH, Name: "Dune.2024.1080p"}, // без нашей категории — добавлен вручную
|
||
}}
|
||
w := newTestWorker(st, qb)
|
||
nm := &fakeNamer{name: "не должно вызваться"}
|
||
w.SetNamer(nm)
|
||
|
||
w.processCatched(context.Background())
|
||
|
||
if len(qb.added) != 0 {
|
||
t.Errorf("qbt.Add не должен вызываться для уже присутствующего торрента, calls = %d", len(qb.added))
|
||
}
|
||
if nm.calls != 0 {
|
||
t.Errorf("namer (LLM) не должен вызываться, calls = %d", nm.calls)
|
||
}
|
||
d := st.downloads["1"]
|
||
if d.State != store.StateDownloading {
|
||
t.Errorf("state = %q, want downloading", d.State)
|
||
}
|
||
if d.DisplayName != "Dune.2024.1080p" {
|
||
t.Errorf("display_name = %q, want имя раздачи из снимка", d.DisplayName)
|
||
}
|
||
}
|
||
|
||
// Гонка F6: список catched снят как magnet, но апгрейд до .torrent случился
|
||
// между снимком и re-read под замком. processCatched перечитывает source_type
|
||
// под w.mu, поэтому добавляет файлом (Torrents), а не magnet-ссылкой.
|
||
func TestProcessCatchedReReadsSourceTypeUnderLock(t *testing.T) {
|
||
st := catchedStore("1", catchedIH, nowStr, "ctx") // снят как magnet
|
||
st.torrents = map[string][]byte{}
|
||
qb := &fakeQbt{} // раздачи нет → absent-ветка (обычное добавление)
|
||
// Апгрейд «под носом»: между листингом (снимок magnet) и re-read под замком
|
||
// БД уже стала torrent с сохранёнными байтами.
|
||
qb.onTorrents = func() {
|
||
st.downloads["1"].SourceType = store.SourceTorrent
|
||
st.torrents["1"] = []byte("d4:infod-fake-torrent-bytes-ee")
|
||
}
|
||
w := newTestWorker(st, qb)
|
||
w.SetNamer(&fakeNamer{name: "X"})
|
||
|
||
w.processCatched(context.Background())
|
||
|
||
if len(qb.added) != 1 {
|
||
t.Fatalf("qbt.Add calls = %d, want 1", len(qb.added))
|
||
}
|
||
if len(qb.added[0].Torrents) != 1 {
|
||
t.Errorf("после апгрейда ожидалось добавление файлом (Torrents), got Torrents=%v URLs=%v",
|
||
qb.added[0].Torrents, qb.added[0].URLs)
|
||
}
|
||
if len(qb.added[0].URLs) != 0 {
|
||
t.Errorf("magnet-ссылка не должна использоваться после апгрейда в torrent: %v", qb.added[0].URLs)
|
||
}
|
||
}
|
||
|
||
// Листинг qBittorrent провалился (недоступен) → пойманную не трогаем: остаётся
|
||
// catched, ни namer, ни Add не вызываются (повтор на следующем тике).
|
||
func TestProcessCatchedListErrorKeepsCatched(t *testing.T) {
|
||
st := catchedStore("1", catchedIH, nowStr, "ctx")
|
||
qb := &fakeQbt{torrentsErr: errors.New("connection refused")}
|
||
w := newTestWorker(st, qb)
|
||
nm := &fakeNamer{name: "X"}
|
||
w.SetNamer(nm)
|
||
|
||
w.processCatched(context.Background())
|
||
|
||
if st.downloads["1"].State != store.StateCatched {
|
||
t.Errorf("state = %q, want catched (повтор)", st.downloads["1"].State)
|
||
}
|
||
if nm.calls != 0 || len(qb.added) != 0 {
|
||
t.Errorf("при недоступности qBit namer/Add не должны вызываться: namer=%d add=%d", nm.calls, len(qb.added))
|
||
}
|
||
}
|
||
|
||
// Транзиентный сбой add — остаёмся в catched для повтора на следующем тике.
|
||
func TestProcessCatchedTransientFailureKeepsCatched(t *testing.T) {
|
||
st := catchedStore("1", catchedIH, nowStr, "ctx")
|
||
qb := &fakeQbt{addErr: errors.New("connection refused")}
|
||
w := newTestWorker(st, qb)
|
||
w.SetNamer(&fakeNamer{name: "X"})
|
||
|
||
w.processCatched(context.Background())
|
||
|
||
if st.downloads["1"].State != store.StateCatched {
|
||
t.Errorf("state = %q, want catched (повтор)", st.downloads["1"].State)
|
||
}
|
||
}
|
||
|
||
// Предохранитель: catched старше catch_timeout → failed (qbit_add) + уведомление;
|
||
// add при этом не вызывается.
|
||
func TestProcessCatchedTimeoutFails(t *testing.T) {
|
||
old := store.FormatTime(time.Date(2026, 6, 14, 9, 0, 0, 0, time.UTC)) // 1 час до now
|
||
st := catchedStore("1", catchedIH, old, "ctx")
|
||
qb := &fakeQbt{}
|
||
w := newTestWorker(st, qb)
|
||
w.cfg.CatchTimeout = 10 * time.Minute
|
||
n := &recordingNotifier{ch: make(chan notifyEvent, 1)}
|
||
w.SetNotifier(n)
|
||
|
||
w.processCatched(context.Background())
|
||
|
||
d := st.downloads["1"]
|
||
if d.State != store.StateFailed || d.ErrorCode.String != errCodeQbitAdd {
|
||
t.Errorf("state = %q code = %q, want failed/qbit_add", d.State, d.ErrorCode.String)
|
||
}
|
||
if len(qb.added) != 0 {
|
||
t.Errorf("add не должен вызываться при таймауте, calls = %d", len(qb.added))
|
||
}
|
||
if e := waitNotify(t, n); e.ev != EventFailed {
|
||
t.Errorf("событие = %q, want failed", e.ev)
|
||
}
|
||
}
|
||
|
||
// Ре-валидация: если во время сетевых вызовов (вне блокировки) задачу отменили,
|
||
// переход в downloading не применяется — состояние остаётся cancelled.
|
||
func TestProcessCatchedCancelledDuringAddSkipsPromote(t *testing.T) {
|
||
st := catchedStore("1", catchedIH, nowStr, "ctx")
|
||
qb := &fakeQbt{}
|
||
w := newTestWorker(st, qb)
|
||
// namer имитирует параллельную отмену во время (медленного) вывода имени.
|
||
nm := &fakeNamer{name: "X", onCall: func() { st.downloads["1"].State = store.StateCancelled }}
|
||
w.SetNamer(nm)
|
||
|
||
w.processCatched(context.Background())
|
||
|
||
if len(qb.added) != 1 {
|
||
t.Fatal("add должен был вызваться (сеть идёт вне замка)")
|
||
}
|
||
if st.downloads["1"].State != store.StateCancelled {
|
||
t.Errorf("ре-валидация не сработала: state = %q, want cancelled", st.downloads["1"].State)
|
||
}
|
||
}
|
||
|
||
// Поллинг активных (downloading) не трогает catched: раздачи в qBittorrent у
|
||
// пойманной загрузки ещё нет по дизайну, это не «пропажа».
|
||
func TestPollIgnoresCatched(t *testing.T) {
|
||
st := catchedStore("1", catchedIH, nowStr, "ctx")
|
||
qb := &fakeQbt{} // раздач нет
|
||
w := newTestWorker(st, qb)
|
||
|
||
if err := w.Poll(context.Background()); err != nil {
|
||
t.Fatalf("Poll: %v", err)
|
||
}
|
||
if st.downloads["1"].State != store.StateCatched {
|
||
t.Errorf("catched тронут поллингом: %q", st.downloads["1"].State)
|
||
}
|
||
}
|