Files
jellybit/internal/recognize/validate_test.go
T
av fc9a3b4066 распознавание: большие раздачи размечаются целиком, файлы вне плана видны
- модель адресует файл номером строки нашего списка вместо копии пути: ответ
  на 180 файлов вместо ~15k токенов стоит ~2.5k, усечение сотней снято,
  max_files и max_tokens ушли в [recognition], correction-ретрай больше не
  переприсылает список
- негодный элемент ответа отбрасывается поимённой причиной, обрыв генерации и
  отказ по размеру запроса названы своими причинами, покрытие плана блокирует
  авто только при непокрытом видеофайле
- раскладка показывает все файлы раздачи со строками «не в плане» и полным
  порядком сортировки; снимок списка файлов лёг рядом с планом (миграция 0012)
2026-09-02 08:54:58 +03:00

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)
}
}