Files
jellybit/internal/store/recognition_test.go
T
avandClaude Opus 4.8 5c3ef79496 Раскладка: сходимость папки сериала (второй сезон в ту же папку)
При подтверждённом матче база папки (имя+год) наследуется от живой
папки-якоря того же (provider, provider_id) вместо печати заново из выхода
LLM — так второй/последующий сезон ложится в ТУ ЖЕ папку, а не заводит
рядом почти одинаковую. Отдельная сущность «тайтл» не вводится.

- layout: Plan.FolderBase перекрывает базу в папке и именах файлов;
  TitleFolder разбирает dst_path в папку тайтла и базу (снятие тега).
- store: LiveTitleFolders — dst_path живых ссылок того же матча.
- worker: resolveFolderBase (живость якоря — по наличию папки на диске,
  os.Lstat, а не по статусу ссылки в БД) в linkPlan и в превью ревью
  (превью=применение); рассинхрон нескольких живых папок → review из
  linking (deferred→review в графе нет).

Схема БД не менялась. Change series-folder-convergence заархивирован,
требования влиты в openspec/specs/file-layout.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 13:50:49 +03:00

371 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package store
import (
"context"
"database/sql"
"testing"
)
func seedDownload(t *testing.T, st *Store) string {
t.Helper()
return mustCreate(t, st, "aabbccddeeff00112233445566778899aabbccdd")
}
func TestCreateRecognition_AttemptsAndCurrent(t *testing.T) {
st := newTestStore(t)
ctx := context.Background()
dl := seedDownload(t, st)
id1, err := st.CreateRecognition(ctx, &Recognition{
DownloadID: dl,
MediaType: NullString("series"),
Title: NullString("Show"),
Year: sql.NullInt64{Int64: 2006, Valid: true},
Plan: NullString(`{"type":"series"}`),
}, []string{"нет матча в базе"})
if err != nil {
t.Fatalf("create #1: %v", err)
}
id2, err := st.CreateRecognition(ctx, &Recognition{
DownloadID: dl,
MediaType: NullString("movie"),
Title: NullString("Show v2"),
}, []string{"уточнено"})
if err != nil {
t.Fatalf("create #2: %v", err)
}
if id2 == id1 {
t.Fatal("ids must differ")
}
cur, err := st.GetCurrentRecognition(ctx, dl)
if err != nil {
t.Fatalf("get current: %v", err)
}
if cur.ID != id2 {
t.Errorf("current id = %s, want %s", cur.ID, id2)
}
if cur.AttemptNo != 2 {
t.Errorf("attempt_no = %d, want 2", cur.AttemptNo)
}
if !cur.IsCurrent {
t.Error("current recognition must have is_current = true")
}
if cur.Title.String != "Show v2" {
t.Errorf("title = %q", cur.Title.String)
}
if got := cur.ReasonList(); len(got) != 1 || got[0] != "уточнено" {
t.Errorf("reasons = %v", got)
}
}
func TestGetCurrentRecognition_None(t *testing.T) {
st := newTestStore(t)
dl := seedDownload(t, st)
cur, err := st.GetCurrentRecognition(context.Background(), dl)
if err != nil {
t.Fatalf("get current: %v", err)
}
if cur != nil {
t.Errorf("want nil, got %+v", cur)
}
}
func TestHints(t *testing.T) {
st := newTestStore(t)
ctx := context.Background()
dl := seedDownload(t, st)
for _, h := range []string{"второй сезон", "рус+англ дорожки"} {
if err := st.AddHint(ctx, dl, h); err != nil {
t.Fatalf("add hint: %v", err)
}
}
got, err := st.ListHints(ctx, dl)
if err != nil {
t.Fatalf("list hints: %v", err)
}
if len(got) != 2 || got[0] != "второй сезон" || got[1] != "рус+англ дорожки" {
t.Errorf("hints = %v", got)
}
}
func TestOverrides_Upsert(t *testing.T) {
st := newTestStore(t)
ctx := context.Background()
dl := seedDownload(t, st)
if err := st.SetOverride(ctx, dl, "media_type", "series"); err != nil {
t.Fatalf("set override: %v", err)
}
if err := st.SetOverride(ctx, dl, "media_type", "movie"); err != nil { // перезапись
t.Fatalf("override upsert: %v", err)
}
if err := st.SetOverride(ctx, dl, "ignored_files", `["sample.mkv"]`); err != nil {
t.Fatalf("set override 2: %v", err)
}
got, err := st.ListOverrides(ctx, dl)
if err != nil {
t.Fatalf("list overrides: %v", err)
}
if got["media_type"] != "movie" {
t.Errorf("media_type = %q, want movie (upsert)", got["media_type"])
}
if got["ignored_files"] != `["sample.mkv"]` {
t.Errorf("ignored_files = %q", got["ignored_files"])
}
}
func TestFileLinks_BatchLifecycle(t *testing.T) {
st := newTestStore(t)
ctx := context.Background()
dl := seedDownload(t, st)
batch := "batch-1"
links := []FileLink{
{DownloadID: dl, ApplyBatchID: batch, SrcPath: "/d/a.mkv", DstPath: "/m/A.mkv", Kind: "video", Status: "linked"},
{DownloadID: dl, ApplyBatchID: batch, SrcPath: "/d/a.srt", DstPath: "/m/A.ru.srt", Kind: "subtitle", Status: "linked"},
}
if err := st.CreateFileLinks(ctx, links); err != nil {
t.Fatalf("create links: %v", err)
}
latest, err := st.LatestBatchID(ctx, dl)
if err != nil || latest != batch {
t.Fatalf("latest batch = %q, %v", latest, err)
}
got, err := st.ListFileLinksByBatch(ctx, batch)
if err != nil {
t.Fatalf("list by batch: %v", err)
}
if len(got) != 2 || got[0].DstPath != "/m/A.mkv" {
t.Errorf("links = %+v", got)
}
if err := st.DeleteFileLinksByBatch(ctx, batch); err != nil {
t.Fatalf("delete batch: %v", err)
}
after, _ := st.ListFileLinksByBatch(ctx, batch)
if len(after) != 0 {
t.Errorf("links remain after delete: %+v", after)
}
}
func TestLayoutSizeByDownload(t *testing.T) {
st := newTestStore(t)
ctx := context.Background()
dl := seedDownload(t, st)
other := mustCreate(t, st, "bbccddeeff00112233445566778899aabbccddee")
// Активные статусы суммируются; superseded исключается; повторный apply даёт
// вторую строку с тем же dst_path — DISTINCT не даёт задвоить размер.
if err := st.CreateFileLinks(ctx, []FileLink{
{DownloadID: dl, ApplyBatchID: "b1", SrcPath: "/d/a.mkv", DstPath: "/m/A.mkv", Kind: "video", Status: "linked", Size: 100},
{DownloadID: dl, ApplyBatchID: "b1", SrcPath: "/d/a.srt", DstPath: "/m/A.ru.srt", Kind: "subtitle", Status: "copied", Size: 50},
{DownloadID: dl, ApplyBatchID: "b1", SrcPath: "/d/b.mkv", DstPath: "/m/B.mkv", Kind: "video", Status: "exists", Size: 25},
{DownloadID: dl, ApplyBatchID: "b2", SrcPath: "/d/a.mkv", DstPath: "/m/A.mkv", Kind: "video", Status: "exists", Size: 100},
{DownloadID: dl, ApplyBatchID: "b1", SrcPath: "/d/s.mkv", DstPath: "/m/S.mkv", Kind: "video", Status: "superseded", Size: 999},
}); err != nil {
t.Fatalf("create links: %v", err)
}
got, err := st.LayoutSizeByDownload(ctx, []string{dl, other})
if err != nil {
t.Fatalf("layout size: %v", err)
}
if got[dl] != 175 { // 100 + 50 + 25, повтор A.mkv и superseded не в счёт
t.Errorf("размер %s = %d, want 175", dl, got[dl])
}
if _, ok := got[other]; ok {
t.Errorf("загрузка без разложенных файлов не должна быть в карте: %v", got[other])
}
// Пустой список id — пустая карта, без ошибки.
empty, err := st.LayoutSizeByDownload(ctx, nil)
if err != nil || len(empty) != 0 {
t.Errorf("пустой ids: got %v, err %v", empty, err)
}
}
func TestSupersedeForeignLinks(t *testing.T) {
st := newTestStore(t)
ctx := context.Background()
owner := seedDownload(t, st)
foreign := mustCreate(t, st, "bbccddeeff00112233445566778899aabbccddee")
shared := "/m/Movie (2024).mkv"
// foreign разложена по shared (linked) и по своему пути (exists);
// owner разложен по shared и по третьему пути.
if err := st.CreateFileLinks(ctx, []FileLink{
{DownloadID: foreign, ApplyBatchID: "f", SrcPath: "/d/f.mkv", DstPath: shared, Kind: "video", Status: "linked"},
{DownloadID: foreign, ApplyBatchID: "f", SrcPath: "/d/g.mkv", DstPath: "/m/Other (2024).mkv", Kind: "video", Status: "exists"},
{DownloadID: owner, ApplyBatchID: "o", SrcPath: "/d/o.mkv", DstPath: shared, Kind: "video", Status: "linked"},
}); err != nil {
t.Fatalf("create links: %v", err)
}
if err := st.SupersedeForeignLinks(ctx, owner, []string{shared}); err != nil {
t.Fatalf("supersede: %v", err)
}
links, _ := st.ListFileLinksByBatch(ctx, "f")
for _, l := range links {
switch l.DstPath {
case shared:
if l.Status != "superseded" {
t.Errorf("чужая ссылка на %q = %q, want superseded", shared, l.Status)
}
default: // /m/Other — другой путь, не трогаем
if l.Status != "exists" {
t.Errorf("ссылка на %q = %q, want exists (не тронута)", l.DstPath, l.Status)
}
}
}
// Свою ссылку owner не суперсидит (download_id != self).
own, _ := st.ListFileLinksByBatch(ctx, "o")
if len(own) != 1 || own[0].Status != "linked" {
t.Errorf("своя ссылка = %+v, want linked", own)
}
// Пустой список путей — no-op, без ошибки.
if err := st.SupersedeForeignLinks(ctx, owner, nil); err != nil {
t.Errorf("пустой dstPaths должен быть no-op: %v", err)
}
}
func TestCandidates_Lifecycle(t *testing.T) {
st := newTestStore(t)
ctx := context.Background()
dl := seedDownload(t, st)
recID, err := st.CreateRecognition(ctx, &Recognition{DownloadID: dl}, nil)
if err != nil {
t.Fatalf("create recognition: %v", err)
}
cands := []MetadataCandidate{
{RecognitionID: recID, Provider: "tvdb", ProviderID: "269613",
Title: NullString("Fargo"), Year: sql.NullInt64{Int64: 2014, Valid: true}},
{RecognitionID: recID, Provider: "tmdb", ProviderID: "60622",
Title: NullString("Fargo")},
}
if err := st.CreateCandidates(ctx, cands); err != nil {
t.Fatalf("create candidates: %v", err)
}
got, err := st.ListCandidatesByRecognition(ctx, recID)
if err != nil {
t.Fatalf("list: %v", err)
}
if len(got) != 2 || got[0].Provider != "tvdb" || got[0].ProviderID != "269613" {
t.Fatalf("candidates = %+v", got)
}
chosenID := got[0].ID
if err := st.SetCandidateChosen(ctx, recID, chosenID); err != nil {
t.Fatalf("set chosen: %v", err)
}
got, _ = st.ListCandidatesByRecognition(ctx, recID)
for _, c := range got {
want := c.ID == chosenID
if c.Chosen != want {
t.Errorf("candidate %s chosen = %v, want %v", c.ID, c.Chosen, want)
}
}
// GetCandidate + переотметка.
single, err := st.GetCandidate(ctx, got[1].ID)
if err != nil || single == nil || single.Provider != "tmdb" {
t.Fatalf("get candidate = %+v, %v", single, err)
}
if err := st.SetCandidateChosen(ctx, recID, got[1].ID); err != nil {
t.Fatal(err)
}
got, _ = st.ListCandidatesByRecognition(ctx, recID)
if got[0].Chosen || !got[1].Chosen {
t.Errorf("re-choose failed: %+v", got)
}
}
func TestLiveTitleFolders(t *testing.T) {
st := newTestStore(t)
ctx := context.Background()
// anchor и other сматчены на один (tvdb, 269613); alien — на другой матч.
anchor := mustCreate(t, st, "1100ccddeeff00112233445566778899aabbccdd")
other := mustCreate(t, st, "2200ccddeeff00112233445566778899aabbccdd")
alien := mustCreate(t, st, "3300ccddeeff00112233445566778899aabbccdd")
seedMatch := func(dl, provider, id string) {
if _, err := st.CreateRecognition(ctx, &Recognition{
DownloadID: dl, Provider: NullString(provider), ProviderID: NullString(id),
}, nil); err != nil {
t.Fatalf("seed recognition: %v", err)
}
}
seedMatch(anchor, "tvdb", "269613")
seedMatch(other, "tvdb", "269613")
seedMatch(alien, "tvdb", "999")
if err := st.CreateFileLinks(ctx, []FileLink{
{DownloadID: anchor, ApplyBatchID: "a", SrcPath: "/d/a.mkv", DstPath: "/s/Фарго (2014) [tvdbid-269613]/Season 01/e.mkv", Kind: "video", Status: "linked"},
{DownloadID: other, ApplyBatchID: "o", SrcPath: "/d/o.mkv", DstPath: "/s/Фарго (2014) [tvdbid-269613]/Season 02/e.mkv", Kind: "video", Status: "exists"},
// superseded/collision у other — не живые, не должны попасть.
{DownloadID: other, ApplyBatchID: "o", SrcPath: "/d/x.mkv", DstPath: "/s/Old/e.mkv", Kind: "video", Status: "superseded"},
{DownloadID: other, ApplyBatchID: "o", SrcPath: "/d/y.mkv", DstPath: "/s/Coll/e.mkv", Kind: "video", Status: "collision"},
// alien — другой матч, мимо.
{DownloadID: alien, ApplyBatchID: "z", SrcPath: "/d/z.mkv", DstPath: "/s/Other/e.mkv", Kind: "video", Status: "linked"},
}); err != nil {
t.Fatalf("create links: %v", err)
}
// Из позиции anchor: видим только живую ссылку other (себя исключаем).
got, err := st.LiveTitleFolders(ctx, "tvdb", "269613", anchor)
if err != nil {
t.Fatalf("live title folders: %v", err)
}
if len(got) != 1 || got[0] != "/s/Фарго (2014) [tvdbid-269613]/Season 02/e.mkv" {
t.Errorf("got %v, want только живую ссылку other", got)
}
// Без исключения — обе живые ссылки матча (anchor + other), но не superseded/
// collision/alien.
got, err = st.LiveTitleFolders(ctx, "tvdb", "269613", "")
if err != nil {
t.Fatalf("live title folders: %v", err)
}
if len(got) != 2 {
t.Errorf("got %v, want 2 живые ссылки", got)
}
// Пустой provider/id → пусто (нет подтверждённого матча).
if got, _ := st.LiveTitleFolders(ctx, "", "269613", ""); got != nil {
t.Errorf("пустой provider → пусто, got %v", got)
}
if got, _ := st.LiveTitleFolders(ctx, "tvdb", "", ""); got != nil {
t.Errorf("пустой id → пусто, got %v", got)
}
}
func TestGetCandidate_None(t *testing.T) {
st := newTestStore(t)
c, err := st.GetCandidate(context.Background(), "01hzzzzzzzzzzzzzzzzzzzzzzz")
if err != nil || c != nil {
t.Errorf("want nil,nil; got %+v, %v", c, err)
}
}
func TestLatestBatchID_None(t *testing.T) {
st := newTestStore(t)
dl := seedDownload(t, st)
latest, err := st.LatestBatchID(context.Background(), dl)
if err != nil {
t.Fatalf("latest batch: %v", err)
}
if latest != "" {
t.Errorf("want empty, got %q", latest)
}
}