package recognize import ( "runtime" "strings" "testing" ) func intp(n int) *int { return &n } func idxp(n int) *FileIndex { i := FileIndex(n); return &i } func inputWith(paths ...string) Input { files := make([]File, len(paths)) for i, p := range paths { files[i] = File{Path: p, Size: 1 << 20} } return Input{Files: files} } func TestValidateSchema_OK(t *testing.T) { in := inputWith("a.mkv", "b.mkv") p := Plan{ Type: MediaSeries, Title: "Show", Files: []PlanFile{ {Src: "a.mkv", Role: RoleEpisode, Season: intp(1), Episode: intp(1)}, {Src: "b.mkv", Role: RoleEpisode, Season: intp(1), Episode: intp(2)}, }, } problems, err := validateSchema(&p, in) if err != nil { t.Fatalf("validateSchema: %v", err) } if len(problems) != 0 { t.Errorf("чистый план не должен давать претензий: %v", problems) } // Запасной формат (src без номера) резолвится в свой же номер. if idxOf(p.Files[0]) != 1 || idxOf(p.Files[1]) != 2 { t.Errorf("индексы не проставлены: %+v", p.Files) } } func TestValidateSchema_Errors(t *testing.T) { in := inputWith("a.mkv") tests := []struct { name string p Plan want string }{ {"empty type", Plan{Title: "x", Files: []PlanFile{{Src: "a.mkv", Role: RoleMain}}}, "type is empty"}, {"bad type", Plan{Type: "show", Title: "x", Files: []PlanFile{{Src: "a.mkv", Role: RoleMain}}}, "unknown type"}, {"empty title", Plan{Type: MediaMovie, Files: []PlanFile{{Src: "a.mkv", Role: RoleMain}}}, "title is empty"}, {"no files", Plan{Type: MediaMovie, Title: "x"}, "files list is empty"}, {"bad role", Plan{Type: MediaMovie, Title: "x", Files: []PlanFile{{Src: "a.mkv", Role: "boss"}}}, "unknown role"}, {"no addressing at all", Plan{Type: MediaMovie, Title: "x", Files: []PlanFile{{Src: "", Role: RoleMain}}}, "no files[] element addresses"}, {"unknown src only", Plan{Type: MediaMovie, Title: "x", Files: []PlanFile{{Src: "z.mkv", Role: RoleMain}}}, "no files[] element addresses"}, {"episode no num", Plan{Type: MediaSeries, Title: "x", Files: []PlanFile{{Src: "a.mkv", Role: RoleEpisode, Season: intp(1)}}}, "has no episode number"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { _, err := validateSchema(&tt.p, in) if err == nil || !strings.Contains(err.Error(), tt.want) { t.Errorf("err = %v, want contains %q", err, tt.want) } }) } } func TestParsePlan_FencedJSON(t *testing.T) { in := inputWith("film.mkv") raw := "Вот результат:\n```json\n{\"type\":\"movie\",\"title\":\"Film\"," + "\"files\":[{\"src\":\"film.mkv\",\"role\":\"main\"}]}\n```" p, _, err := parsePlan(raw, in, testLogger()) if err != nil { t.Fatalf("parsePlan: %v", err) } if p.Title != "Film" || p.Type != MediaMovie { t.Errorf("plan = %+v", p) } } func TestParsePlan_UnknownFieldTolerated(t *testing.T) { in := inputWith("film.mkv") raw := `{"type":"movie","title":"Film","extra_field":123, "files":[{"src":"film.mkv","role":"main"}]}` if _, _, err := parsePlan(raw, in, testLogger()); err != nil { t.Fatalf("unknown field should be tolerated: %v", err) } } func TestStructuralWarnings_Movie(t *testing.T) { twoMains := Plan{Type: MediaMovie, Files: []PlanFile{ {Role: RoleMain}, {Role: RoleMain}, }} if w := structuralWarnings(twoMains); len(w) != 1 || !strings.Contains(w[0], "ожидался ровно 1") { t.Errorf("warnings = %v", w) } noMain := Plan{Type: MediaMovie, Files: []PlanFile{{Role: RoleSample}}} if w := structuralWarnings(noMain); len(w) != 1 { t.Errorf("want 1 warning for 0 mains, got %v", w) } clean := Plan{Type: MediaMovie, Files: []PlanFile{{Role: RoleMain}, {Role: RoleSample}}} if w := structuralWarnings(clean); len(w) != 0 { t.Errorf("clean movie should have no warnings, got %v", w) } } func TestSeriesWarnings_GapAndDup(t *testing.T) { files := []PlanFile{ {Role: RoleEpisode, Season: intp(1), Episode: intp(1)}, {Role: RoleEpisode, Season: intp(1), Episode: intp(1)}, // дубль {Role: RoleEpisode, Season: intp(1), Episode: intp(4)}, // пропуск 2,3 } w := seriesWarnings(files) var dup, gap bool for _, s := range w { if strings.Contains(s, "дубли серий") { dup = true } if strings.Contains(s, "не хватает серий") { gap = true } } if !dup || !gap { t.Errorf("want dup and gap warnings, got %v", w) } } func TestSeriesWarnings_Clean(t *testing.T) { files := []PlanFile{ {Role: RoleEpisode, Season: intp(1), Episode: intp(1)}, {Role: RoleEpisode, Season: intp(1), Episode: intp(2)}, {Role: RoleEpisode, Season: intp(2), Episode: intp(1)}, } if w := seriesWarnings(files); len(w) != 0 { t.Errorf("clean series should have no warnings, got %v", w) } } func TestConsistencyWarnings(t *testing.T) { yearMismatch := consistencyWarnings( Plan{Type: MediaMovie, Year: 2001}, PreParse{Year: 1999}, ) if len(yearMismatch) != 1 || !strings.Contains(yearMismatch[0], "год расходится") { t.Errorf("warnings = %v", yearMismatch) } typeMismatch := consistencyWarnings( Plan{Type: MediaMovie}, PreParse{Season: 2}, ) if len(typeMismatch) != 1 || !strings.Contains(typeMismatch[0], "тип расходится") { t.Errorf("warnings = %v", typeMismatch) } agree := consistencyWarnings( Plan{Type: MediaSeries, Year: 2006}, PreParse{Year: 2006, Season: 2}, ) if len(agree) != 0 { t.Errorf("agreeing signals should not warn, got %v", agree) } } func TestDecide_MetadataDisabled(t *testing.T) { p := Plan{Type: MediaMovie, Title: "X", Confidence: 0.99, Files: []PlanFile{{Role: RoleMain}}} d := decide(p, PreParse{}, nil, false, 0.85, planIssues{}) if d.Auto { t.Error("без метабаз авто недопустимо") } if len(d.Reasons) == 0 || !strings.Contains(d.Reasons[0], "метабазы отключены") { t.Errorf("first reason should be DB match, got %v", d.Reasons) } } func TestDecide_NoMatch(t *testing.T) { p := Plan{Type: MediaMovie, Title: "X", Confidence: 0.99, Files: []PlanFile{{Role: RoleMain}}} d := decide(p, PreParse{}, nil, true, 0.85, planIssues{}) if d.Auto || !strings.Contains(d.Reasons[0], "не найдено в базе") { t.Errorf("reasons = %v", d.Reasons) } } func TestDecide_AutoMovie(t *testing.T) { p := Plan{Type: MediaMovie, Title: "The Matrix", Year: 1999, Confidence: 0.95, Files: []PlanFile{{Role: RoleMain}, {Role: RoleSample}}} match := &Match{Provider: "tmdb", ProviderID: "603", Title: "The Matrix", Year: 1999} d := decide(p, PreParse{Year: 1999}, match, true, 0.85, planIssues{}) if !d.Auto { t.Errorf("clean movie with match must be auto, reasons: %v", d.Reasons) } } func TestDecide_LowConfidenceBlocksAuto(t *testing.T) { p := Plan{Type: MediaMovie, Title: "X", Year: 2000, Confidence: 0.5, Files: []PlanFile{{Role: RoleMain}}} match := &Match{Provider: "tmdb", ProviderID: "1", Title: "X", Year: 2000} d := decide(p, PreParse{}, match, true, 0.85, planIssues{}) if d.Auto || !hasReason(d.Reasons, "уверенность") { t.Errorf("low confidence must block auto, reasons: %v", d.Reasons) } } func TestDecide_AutoSeriesEpisodeCount(t *testing.T) { s := 2 mk := func(e int) PlanFile { ep := e; return PlanFile{Role: RoleEpisode, Season: &s, Episode: &ep} } p := Plan{Type: MediaSeries, Title: "Fargo", Year: 2014, Confidence: 0.9, Files: []PlanFile{mk(1), mk(2), mk(3)}} match := &Match{Provider: "tmdb", ProviderID: "1", Title: "Fargo", Year: 2014, SeasonEpisodeCounts: map[int]int{2: 3}} if d := decide(p, PreParse{Year: 2014}, match, true, 0.85, planIssues{}); !d.Auto { t.Errorf("full season must be auto, reasons: %v", d.Reasons) } // Неполный пак (в базе 10) — авто блокируется. match.SeasonEpisodeCounts = map[int]int{2: 10} if d := decide(p, PreParse{Year: 2014}, match, true, 0.85, planIssues{}); d.Auto || !hasReason(d.Reasons, "распознано серий 3, в базе 10") { t.Errorf("partial pack must block auto, reasons: %v", d.Reasons) } // Нет данных о числе серий — авто блокируется. match.SeasonEpisodeCounts = nil if d := decide(p, PreParse{Year: 2014}, match, true, 0.85, planIssues{}); d.Auto || !hasReason(d.Reasons, "нет данных о числе серий") { t.Errorf("missing counts must block auto, reasons: %v", d.Reasons) } } func TestPreParse(t *testing.T) { pre := preParse("The.Matrix.1999.1080p.BluRay.x264") if pre.Year != 1999 { t.Errorf("year = %d, want 1999", pre.Year) } if !strings.Contains(strings.ToLower(pre.Title), "matrix") { t.Errorf("title = %q", pre.Title) } series := preParse("Some.Show.S02E05.720p") if series.Season != 2 || series.Episode != 5 { t.Errorf("season/episode = %d/%d, want 2/5", series.Season, series.Episode) } } // Перечень видеорасширений — единственное место, где решается, что считать // потерянной серией: незнакомое расширение уедет в спутники и авто не заблокирует. func TestIsVideoFile(t *testing.T) { video := []string{ "a.mkv", "a.MKV", "s1/e01.avi", "film.mp4", "x.m4v", "x.mov", "x.wmv", "x.mpg", "x.mpeg", "BDMV/STREAM/00001.m2ts", "x.mts", "x.ts", "x.vob", "x.flv", "x.webm", "x.ogm", "x.divx", "x.rmvb", "x.3gp", "disc.iso", "disc.img", } for _, p := range video { if !IsVideoFile(p) { t.Errorf("IsVideoFile(%q) = false, want true", p) } } other := []string{ "a.srt", "a.ass", "a.sub", "a.idx", "a.nfo", "a.txt", "a.jpg", "a.png", "a.md5", "a.sfv", "cover", "a.mkv.txt", "", } for _, p := range other { if IsVideoFile(p) { t.Errorf("IsVideoFile(%q) = true, want false", p) } } } // Сезон с тремя дырами даёт ровно одну причину, перечисляющую недостающие серии. func TestSeriesWarnings_GapsCollapsedPerSeason(t *testing.T) { var files []PlanFile for _, e := range []int{1, 2, 3, 4, 6, 8, 9, 10, 12} { // нет 5, 7, 11 files = append(files, PlanFile{Role: RoleEpisode, Season: intp(1), Episode: intp(e)}) } w := seriesWarnings(files) if len(w) != 1 { t.Fatalf("ожидалась одна причина на сезон, получено %d: %v", len(w), w) } if !strings.Contains(w[0], "E05") || !strings.Contains(w[0], "E07") || !strings.Contains(w[0], "E11") { t.Errorf("причина не перечисляет недостающие серии: %q", w[0]) } } // Мультисезонный пак: на сезон — не больше одной причины о пропусках. func TestSeriesWarnings_OneReasonPerSeason(t *testing.T) { var files []PlanFile for s := 1; s <= 8; s++ { for _, e := range []int{1, 3, 5} { // дыры в каждом сезоне files = append(files, PlanFile{Role: RoleEpisode, Season: intp(s), Episode: intp(e)}) } } if w := seriesWarnings(files); len(w) != 8 { t.Errorf("причин %d при 8 сезонах, ожидалось 8: %v", len(w), w) } } // Непокрытый видеофайл означает потерянную серию — авто блокируется. func TestDecide_UncoveredVideoBlocksAuto(t *testing.T) { in := inputWith("s1/e01.mkv", "s1/e02.mkv") p := Plan{Type: MediaSeries, Title: "Show", Year: 2020, Confidence: 0.95, Files: []PlanFile{{Src: "s1/e01.mkv", Role: RoleEpisode, Season: intp(1), Episode: intp(1)}}} match := &Match{Provider: "tmdb", ProviderID: "1", Title: "Show", Year: 2020, SeasonEpisodeCounts: map[int]int{1: 1}} d := decide(p, PreParse{}, match, true, 0.85, planIssues{files: in.Files, all: in.Files}) if d.Auto { t.Errorf("непокрытый видеофайл обязан блокировать авто: %v", d.Reasons) } if !hasReason(d.Reasons, "вне плана осталось видеофайлов: 1") { t.Errorf("reasons = %v", d.Reasons) } if !hasReason(d.Reasons, "в план попало 1 файлов из 2") { t.Errorf("сводка покрытия обязана быть названа: %v", d.Reasons) } } // Непокрытые спутники (.nfo, скриншоты, тексты) видны в покрытии, но авто не // отменяют: модель вправе не перечислять то, что не раскладывается. func TestDecide_UncoveredCompanionsKeepAuto(t *testing.T) { in := inputWith("film.mkv", "film.nfo", "screens/01.jpg", "readme.txt") p := Plan{Type: MediaMovie, Title: "Film", Year: 1999, Confidence: 0.95, Files: []PlanFile{{Src: "film.mkv", Role: RoleMain}}} match := &Match{Provider: "tmdb", ProviderID: "1", Title: "Film", Year: 1999} d := decide(p, PreParse{Year: 1999}, match, true, 0.85, planIssues{files: in.Files, all: in.Files}) if !d.Auto { t.Errorf("непокрытые спутники не должны отменять авто: %v", d.Reasons) } if !hasReason(d.Reasons, "в план попало 1 файлов из 4") { t.Errorf("покрытие обязано быть показано человеку: %v", d.Reasons) } } // Отброшенный элемент — блокирующая причина, а полное покрытие сводкой не шумит. func TestDecide_FullCoverageIsSilent(t *testing.T) { in := inputWith("film.mkv") p := Plan{Type: MediaMovie, Title: "Film", Year: 1999, Confidence: 0.95, Files: []PlanFile{{Src: "film.mkv", Role: RoleMain}}} match := &Match{Provider: "tmdb", ProviderID: "1", Title: "Film", Year: 1999} d := decide(p, PreParse{Year: 1999}, match, true, 0.85, planIssues{files: in.Files, all: in.Files}) if !d.Auto || len(d.Reasons) != 0 { t.Errorf("полное покрытие не должно давать причин: %+v", d) } withDrop := decide(p, PreParse{Year: 1999}, match, true, 0.85, planIssues{files: in.Files, all: in.Files, dropped: []string{"элемент отброшен"}}) if withDrop.Auto || !hasReason(withDrop.Reasons, "элемент отброшен") { t.Errorf("отбраковка обязана блокировать авто: %+v", withDrop) } } // Датовая нумерация серий (обычная для ежедневных шоу) рядом с обычной даёт // разрыв в миллионы номеров. Перечисление такого разрыва материализовало бы // сотни мегабайт int при пределе печати в 12 номеров, поэтому пропуски // считаются арифметикой, а не перечислением. func TestSeriesWarnings_HugeGapNotMaterialized(t *testing.T) { files := []PlanFile{ {Role: RoleEpisode, Season: intp(1), Episode: intp(1)}, {Role: RoleEpisode, Season: intp(1), Episode: intp(20260902)}, } var before, after runtime.MemStats runtime.ReadMemStats(&before) w := seriesWarnings(files) runtime.ReadMemStats(&after) // Бюджет — на строки причины, а не на 20 млн номеров: старая реализация // брала здесь ~160 МиБ. if grew := after.TotalAlloc - before.TotalAlloc; grew > 1<<20 { t.Errorf("разрыв материализован: выделено %d байт", grew) } if len(w) != 1 { t.Fatalf("ожидалась одна причина на сезон, получено %v", w) } if !strings.Contains(w[0], "E02, E03") || !strings.Contains(w[0], "E13") { t.Errorf("причина обязана перечислять начало разрыва: %q", w[0]) } if !strings.Contains(w[0], "и ещё 20260888") { t.Errorf("хвост обязан быть назван числом: %q", w[0]) } } // Длинный хвост пропусков сворачивается: печатается предел, остальное — счётчик. func TestSeriesWarnings_LongTailFolded(t *testing.T) { files := []PlanFile{ {Role: RoleEpisode, Season: intp(1), Episode: intp(1)}, {Role: RoleEpisode, Season: intp(1), Episode: intp(20)}, // нет 2..19 — 18 штук } w := seriesWarnings(files) if len(w) != 1 { t.Fatalf("причины = %v", w) } if strings.Count(w[0], "E") != episodeListMax { t.Errorf("напечатано номеров: %d, ожидался предел %d: %q", strings.Count(w[0], "E"), episodeListMax, w[0]) } if !strings.Contains(w[0], "E13") || strings.Contains(w[0], "E14") { t.Errorf("предел печати сдвинулся: %q", w[0]) } if !strings.Contains(w[0], "и ещё 6") { t.Errorf("свёрнутый хвост не назван числом: %q", w[0]) } } // Претензии к элементам не растут без предела, а внешние значения в них // усечены: причины оседают в БД навсегда и уезжают в карточку Telegram. func TestValidateSchema_ProblemsCappedAndValuesShortened(t *testing.T) { long := "сезон 1/" + strings.Repeat("длинное-имя-", 30) + "серия.mkv" in := inputWith(long, "b.mkv") p := Plan{Type: MediaSeries, Title: "Show", Files: []PlanFile{ {Src: long, Role: RoleEpisode, Season: intp(1), Episode: intp(1)}, }} // Тридцать элементов, каждый повторно адресующий первый файл. for range 30 { p.Files = append(p.Files, PlanFile{Src: long, Role: RoleEpisode, Season: intp(1), Episode: intp(2)}) } problems, err := validateSchema(&p, in) if err != nil { t.Fatalf("validateSchema: %v", err) } if len(problems) != maxProblems+1 { t.Fatalf("претензий %d, ожидались %d поимённых плюс свёрнутый хвост: %v", len(problems), maxProblems, problems) } if !strings.Contains(problems[len(problems)-1], "и ещё 18") { t.Errorf("хвост претензий не назван числом: %q", problems[len(problems)-1]) } for _, s := range problems { if strings.Contains(s, long) { t.Errorf("внешнее значение не усечено: %q", s) } } } // Отказ по неизвестной роли называет файл так, как его адресовала модель: // проверка стоит до резолва, и pf.Src там ещё пуст. func TestValidateSchema_UnknownRoleNamesAddressing(t *testing.T) { in := inputWith("a.mkv", "b.mkv") p := Plan{Type: MediaMovie, Title: "Film", Files: []PlanFile{ {Index: idxp(2), Role: "trailer"}, }} _, err := validateSchema(&p, in) if err == nil || !strings.Contains(err.Error(), "#2") { t.Errorf("err = %v, ожидался номер адресации", err) } } // Ноль — не «номера нет»: так адресует модель, посчитавшая список с нуля, и // остальные её номера уезжают на файл. Диагноз обязан быть самоописывающимся. func TestResolveFile_ZeroIndexIsItsOwnCase(t *testing.T) { in := inputWith("a.mkv", "b.mkv") byPath := map[string]int{"a.mkv": 0, "b.mkv": 1} _, problem, ok := resolveFile(PlanFile{Index: idxp(0)}, in.Files, byPath) if ok || !strings.Contains(problem, "нумеруется с 1") { t.Errorf("явный ноль: ok=%v, problem=%q", ok, problem) } _, problem, ok = resolveFile(PlanFile{}, in.Files, byPath) if ok || !strings.Contains(problem, "без номера файла и без пути") { t.Errorf("поля нет вовсе: ok=%v, problem=%q", ok, problem) } // Ноль рядом с путём остаётся запасным форматом: путь резолвится. idx, problem, ok := resolveFile(PlanFile{Index: idxp(0), Src: "b.mkv"}, in.Files, byPath) if !ok || idx != 1 || problem != "" { t.Errorf("путь при нулевом номере обязан резолвиться: idx=%d ok=%v %q", idx, ok, problem) } } // Структурное предупреждение и рассинхрон года — блокирующие причины, а не // информационные заметки: развод «блокирует / не блокирует» обязан исполняться. func TestDecide_StructuralAndConsistencyBlockAuto(t *testing.T) { in := inputWith("a.mkv", "b.mkv") p := Plan{Type: MediaMovie, Title: "Film", Year: 1999, Confidence: 0.95, Files: []PlanFile{ {Src: "a.mkv", Role: RoleMain}, {Src: "b.mkv", Role: RoleMain}, // два main у фильма }} match := &Match{Provider: "tmdb", ProviderID: "1", Title: "Film", Year: 1999} d := decide(p, PreParse{Year: 1998}, match, true, 0.85, planIssues{files: in.Files, all: in.Files}) if d.Auto { t.Errorf("структурное предупреждение обязано блокировать авто: %v", d.Reasons) } if !hasReason(d.Reasons, "основных видеофайлов 2") { t.Errorf("структурная причина не названа: %v", d.Reasons) } if !hasReason(d.Reasons, "год расходится: пред-парс=1998, LLM=1999") { t.Errorf("рассинхрон года не назван: %v", d.Reasons) } }