package recognize import ( "context" "strconv" "strings" "testing" "git.vakhrushev.me/av/jellybit/internal/llm" ) // idxOf — резолвнутый номер файла (0 — не проставлен). func idxOf(pf PlanFile) int { if pf.Index == nil { return 0 } return int(*pf.Index) } // planWith собирает ответ модели из готовых элементов files[]. func planWith(files ...string) string { return `{"type":"series","title":"Show","confidence":0.9,"files":[` + strings.Join(files, ",") + `]}` } // Номер строки резолвится в путь нашего же списка: src подставляем мы, а не // модель — посторонний путь невыразим. func TestParsePlan_IndexResolvesToPath(t *testing.T) { in := inputWith("s1/e1.mkv", "s1/e2.mkv", "s1/e3.mkv") raw := planWith(`{"i":2,"role":"episode","season":1,"episode":2}`) p, problems, err := parsePlan(raw, in, testLogger()) if err != nil { t.Fatalf("parsePlan: %v", err) } if len(problems) != 0 { t.Errorf("претензий быть не должно: %v", problems) } if p.Files[0].Src != "s1/e2.mkv" || idxOf(p.Files[0]) != 2 { t.Errorf("file = %+v, want s1/e2.mkv по номеру 2", p.Files[0]) } } // Номер вне диапазона отбрасывает элемент, а не план. func TestParsePlan_IndexOutOfRangeDropsElementOnly(t *testing.T) { in := inputWith("a.mkv", "b.mkv") raw := planWith( `{"i":1,"role":"episode","season":1,"episode":1}`, `{"i":181,"role":"episode","season":1,"episode":2}`, `{"i":2,"role":"episode","season":1,"episode":2}`) p, problems, err := parsePlan(raw, in, testLogger()) if err != nil { t.Fatalf("план должен пережить негодный элемент: %v", err) } if len(p.Files) != 2 { t.Errorf("в плане %d файлов, ожидались 2 годных: %+v", len(p.Files), p.Files) } if len(problems) != 1 || !strings.Contains(problems[0], "вне диапазона 1..2") { t.Errorf("problems = %v", problems) } } // Повторная адресация: побеждает первый элемент, второй отброшен с причиной. func TestParsePlan_DuplicateAddressingKeepsFirst(t *testing.T) { in := inputWith("a.mkv", "b.mkv") raw := planWith( `{"i":1,"role":"episode","season":1,"episode":1}`, `{"i":1,"role":"episode","season":1,"episode":7}`) p, problems, err := parsePlan(raw, in, testLogger()) if err != nil { t.Fatalf("parsePlan: %v", err) } if len(p.Files) != 1 || *p.Files[0].Episode != 1 { t.Errorf("должен остаться первый элемент, получено %+v", p.Files) } if len(problems) != 1 || !strings.Contains(problems[0], "адресован повторно") { t.Errorf("problems = %v", problems) } } // Элемент без номера и без пути адресует ничто — отбрасывается. func TestParsePlan_NoIndexNoSrcDropped(t *testing.T) { in := inputWith("a.mkv", "b.mkv") raw := planWith( `{"role":"episode","season":1,"episode":1}`, `{"i":2,"role":"episode","season":1,"episode":2}`) p, problems, err := parsePlan(raw, in, testLogger()) if err != nil { t.Fatalf("parsePlan: %v", err) } if len(p.Files) != 1 || p.Files[0].Src != "b.mkv" { t.Errorf("files = %+v", p.Files) } if len(problems) != 1 || !strings.Contains(problems[0], "без номера файла и без пути") { t.Errorf("problems = %v", problems) } } // Запасной формат: путь вместо номера принимается при точном совпадении. func TestParsePlan_SrcFallbackAccepted(t *testing.T) { in := inputWith("a.mkv", "b.mkv") raw := planWith(`{"src":"b.mkv","role":"episode","season":1,"episode":2}`) p, problems, err := parsePlan(raw, in, testLogger()) if err != nil { t.Fatalf("parsePlan: %v", err) } if len(problems) != 0 { t.Errorf("запасной формат не должен давать претензий: %v", problems) } if p.Files[0].Src != "b.mkv" || idxOf(p.Files[0]) != 2 { t.Errorf("file = %+v", p.Files[0]) } } // Номер и путь вместе: главенствует номер; расхождение — причина ревью. func TestParsePlan_IndexWinsOverSrc(t *testing.T) { in := inputWith("a.mkv", "b.mkv") t.Run("совпадают", func(t *testing.T) { raw := planWith(`{"i":1,"src":"a.mkv","role":"episode","season":1,"episode":1}`) p, problems, err := parsePlan(raw, in, testLogger()) if err != nil { t.Fatalf("parsePlan: %v", err) } if len(problems) != 0 || p.Files[0].Src != "a.mkv" { t.Errorf("problems = %v, file = %+v", problems, p.Files[0]) } }) t.Run("расходятся", func(t *testing.T) { raw := planWith(`{"i":1,"src":"b.mkv","role":"episode","season":1,"episode":1}`) p, problems, err := parsePlan(raw, in, testLogger()) if err != nil { t.Fatalf("parsePlan: %v", err) } if p.Files[0].Src != "a.mkv" { t.Errorf("src = %q, want резолв по номеру (a.mkv)", p.Files[0].Src) } if len(problems) != 1 || !strings.Contains(problems[0], "принят файл по номеру") { t.Errorf("расхождение обязано стать причиной ревью: %v", problems) } }) } // Не осталось ни одного годного элемента — план не разобран. func TestParsePlan_AllElementsBadIsUnparsed(t *testing.T) { in := inputWith("a.mkv") raw := planWith( `{"i":9,"role":"episode","season":1,"episode":1}`, `{"src":"zzz.mkv","role":"episode","season":1,"episode":2}`) if _, problems, err := parsePlan(raw, in, testLogger()); err == nil { t.Errorf("план без годных файлов обязан считаться неразобранным (problems=%v)", problems) } } // Резолв не паникует ни на одном входе и никогда не выпускает посторонний путь. func TestParsePlan_ResolveNeverPanics(t *testing.T) { in := inputWith("a.mkv", "b.mkv") raws := []string{ planWith(`{"i":0,"src":"a.mkv","role":"main"}`), planWith(`{"i":-3,"role":"main"}`), planWith(`{"i":3,"role":"main"}`), planWith(`{"i":2.0,"role":"main"}`), planWith(`{"i":1.7,"role":"main"}`), planWith(`{"i":"2","role":"main"}`), planWith(`{"i":"два","src":"b.mkv","role":"main"}`), planWith(`{"i":null,"src":"a.mkv","role":"main"}`), planWith(`{"i":99999999999999999999,"role":"main"}`), `{"type":"movie","title":"X","files":[]}`, planWith( `{"i":1,"role":"main"}`, `{"i":2,"role":"extra"}`, `{"i":3,"role":"extra"}`, `{"i":4,"role":"extra"}`), } known := map[string]bool{"a.mkv": true, "b.mkv": true} for _, raw := range raws { p, _, err := parsePlan(raw, in, testLogger()) if err != nil { continue // неразобранный план — законный исход, паники нет } for _, f := range p.Files { if !known[f.Src] { t.Fatalf("посторонний путь %q пролез в план из %s", f.Src, raw) } } } } // Порядок списка — свойство узла: перемешанный Input даёт ту же нумерацию. func TestRecognize_NumberingIndependentOfCaller(t *testing.T) { paths := []string{"s2/e01.mkv", "s1/e02.mkv", "s1/e01.mkv", "s2/e02.mkv"} mk := func(order []int) Input { in := Input{Name: "Show"} for _, i := range order { in.Files = append(in.Files, File{Path: paths[i], Size: 1 << 30}) } return in } resp := planWith(`{"i":3,"role":"episode","season":2,"episode":1}`) var prompts []string var srcs []string for _, order := range [][]int{{0, 1, 2, 3}, {3, 2, 1, 0}, {2, 0, 3, 1}} { f := &fakeLLM{responses: []string{resp}} r := New(f, nil, Config{}, testLogger()) res, err := r.Recognize(context.Background(), mk(order)) if err != nil { t.Fatalf("Recognize: %v", err) } prompts = append(prompts, f.lastReq.Messages[1].Content) srcs = append(srcs, res.Plan.Files[0].Src) } for i := 1; i < len(prompts); i++ { if prompts[i] != prompts[0] { t.Errorf("нумерация зависит от порядка Input:\n%s\n---\n%s", prompts[0], prompts[i]) } if srcs[i] != srcs[0] { t.Errorf("резолв номера разъехался: %q != %q", srcs[i], srcs[0]) } } if srcs[0] != "s2/e01.mkv" { t.Errorf("номер 3 обязан резолвиться в третий по порядку файл, получено %q", srcs[0]) } } // Входной срез вызывающего распознавание не переупорядочивает. func TestRecognize_DoesNotReorderCallerSlice(t *testing.T) { in := Input{Name: "Show", Files: []File{ {Path: "b.mkv", Size: 1}, {Path: "a.mkv", Size: 1}, }} f := &fakeLLM{responses: []string{planWith(`{"i":1,"role":"episode","season":1,"episode":1}`)}} r := New(f, nil, Config{}, testLogger()) if _, err := r.Recognize(context.Background(), in); err != nil { t.Fatalf("Recognize: %v", err) } if in.Files[0].Path != "b.mkv" { t.Errorf("срез вызывающего переупорядочен: %+v", in.Files) } } // Обрыв ответа по длине уводит в review и НЕ порождает повторных запросов. func TestRecognize_TruncatedResponseNoRetry(t *testing.T) { in := Input{Name: "Big", Files: []File{{Path: "a.mkv", Size: 1}}} f := &truncatingLLM{} r := New(f, nil, Config{MaxRetries: 3, MaxTokens: 8000}, testLogger()) res, err := r.Recognize(context.Background(), in) if err != nil { t.Fatalf("обрыв — не транспортная ошибка: %v", err) } if f.calls != 1 { t.Errorf("calls = %d, want 1 (обрыв не повторяют)", f.calls) } if res.Decision.Auto || !hasReason(res.Decision.Reasons, "обрезан") { t.Errorf("reasons = %v", res.Decision.Reasons) } if len(res.Files) != 1 { t.Errorf("снимок списка файлов обязан быть и на этом исходе: %+v", res.Files) } } // truncatingLLM всегда отвечает обрывом генерации по длине. type truncatingLLM struct{ calls int } func (f *truncatingLLM) Complete(_ context.Context, _ llm.Request) (llm.Response, error) { f.calls++ return llm.Response{Content: `{"type":"movie","title":"X","files":[{"i":1`, FinishReason: llm.FinishLength}, nil } // Отказ модели по размеру запроса называется своей причиной, а не текстом // провайдера, и уводит в review вместо ошибки распознавания. func TestRecognize_RequestTooLargeNamedReason(t *testing.T) { in := Input{Name: "Big", Files: []File{{Path: "a.mkv", Size: 1}}} f := &fakeLLM{errs: []error{errRequestTooLarge}} r := New(f, nil, Config{MaxRetries: 3}, testLogger()) res, err := r.Recognize(context.Background(), in) if err != nil { t.Fatalf("отказ по размеру запроса — не ошибка распознавания: %v", err) } if f.calls != 1 { t.Errorf("calls = %d, want 1", f.calls) } if !hasReason(res.Decision.Reasons, "по его размеру") { t.Errorf("reasons = %v", res.Decision.Reasons) } } // Снятие лимита на список файлов не умножает число попыток: их потолок — // [llm].max_retries, а вторая попытка сопоставима по размеру с первой. func TestRecognize_RetryCostDoesNotGrowWithRelease(t *testing.T) { files := make([]File, 300) for i := range files { files[i] = File{Path: "show/" + strconv.Itoa(1000+i) + ".mkv", Size: 1 << 30} } in := Input{Name: "Big.Pack", Files: files} f := &fakeLLM{responses: []string{"мусор"}} r := New(f, nil, Config{MaxRetries: 2, MaxFiles: 500}, testLogger()) if _, err := r.Recognize(context.Background(), in); err != nil { t.Fatalf("Recognize: %v", err) } if f.calls != 3 { t.Errorf("calls = %d, want 3 (1 + max_retries)", f.calls) } first := len(f.lastReq.Messages[1].Content) total := 0 for _, m := range f.lastReq.Messages[2:] { total += len(m.Content) } // Три попытки на 300 файлов: дописанные сообщения не несут списка, поэтому // их суммарный объём заведомо меньше одной его копии. if total >= first { t.Errorf("повторные попытки весят %d при списке в %d символов — список переприслан", total, first) } for i, m := range f.lastReq.Messages { if i > 1 && strings.Contains(m.Content, "show/1000.mkv") { t.Errorf("сообщение %d повторно печатает список файлов", i) } } }