- модель адресует файл номером строки нашего списка вместо копии пути: ответ на 180 файлов вместо ~15k токенов стоит ~2.5k, усечение сотней снято, max_files и max_tokens ушли в [recognition], correction-ретрай больше не переприсылает список - негодный элемент ответа отбрасывается поимённой причиной, обрыв генерации и отказ по размеру запроса названы своими причинами, покрытие плана блокирует авто только при непокрытом видеофайле - раскладка показывает все файлы раздачи со строками «не в плане» и полным порядком сортировки; снимок списка файлов лёг рядом с планом (миграция 0012)
501 lines
21 KiB
Go
501 lines
21 KiB
Go
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)
|
|
}
|
|
}
|