package naming import ( "context" "errors" "io" "log/slog" "testing" "git.vakhrushev.me/av/jellybit/internal/llm" ) func testLogger() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) } // fakeProvider отдаёт заранее заданные ответы по очереди; считает вызовы. type fakeProvider struct { responses []string err error calls int } func (f *fakeProvider) Complete(_ context.Context, _ llm.Request) (llm.Response, error) { f.calls++ if f.err != nil { return llm.Response{}, f.err } idx := f.calls - 1 if idx >= len(f.responses) { idx = len(f.responses) - 1 } return llm.Response{Content: f.responses[idx]}, nil } const duneContext = "Дюна: Часть вторая / Dune: Part Two [2024, фантастика, WEB-DL 2160p]\nDub + MVO" func TestDeriveNameViaLLM(t *testing.T) { fp := &fakeProvider{responses: []string{ `{"type":"movie","title":"Дюна: Часть вторая","year":2024,"director":"Дени Вильнёв","is_russian":false}`, }} got := New(fp, 3, testLogger()).DeriveName(context.Background(), duneContext, "") want := "Дюна: Часть вторая (Дени Вильнёв, 2024)" if got != want { t.Errorf("DeriveName() = %q, want %q", got, want) } if fp.calls != 1 { t.Errorf("вызовов LLM = %d, want 1", fp.calls) } } func TestDeriveNameEmptyInputSkipsLLM(t *testing.T) { // Голый magnet без dn/контекста: выводить имя не из чего — LLM не зовём. fp := &fakeProvider{responses: []string{ `{"type":"movie","title":"Unknown"}`, // как раз мусор, который был бы записан }} got := New(fp, 3, testLogger()).DeriveName(context.Background(), " ", " ") if got != "" { t.Errorf("DeriveName() = %q, want empty", got) } if fp.calls != 0 { t.Errorf("вызовов LLM = %d, want 0 (пустой вход не зовёт модель)", fp.calls) } } func TestDeriveNameHintPresentSkipsGuard(t *testing.T) { // Контекст пуст, но есть подсказка (dn) — гард пустого входа НЕ срабатывает: // имя выводится фолбеком из подсказки. LLM здесь не зовётся (он работает // только по контексту, а его нет) — это прежнее поведение, гард его не меняет. fp := &fakeProvider{responses: []string{`{"type":"movie","title":"x"}`}} got := New(fp, 3, testLogger()).DeriveName(context.Background(), "", "Dune.Part.Two.2024") if got == "" { t.Error("при наличии подсказки имя не должно быть пустым (гард не должен глотать hint)") } if fp.calls != 0 { t.Errorf("вызовов LLM = %d, want 0 (LLM выводит по контексту, которого нет)", fp.calls) } } func TestLabel(t *testing.T) { cases := []struct { name string title string director string year int season string want string }{ {"title only", "Брат", "", 0, "", "Брат"}, {"title+year", "Fargo", "", 2015, "", "Fargo (2015)"}, {"title+director+year", "Дюна", "Дени Вильнёв", 2024, "", "Дюна (Дени Вильнёв, 2024)"}, {"director only", "Брат", "Алексей Балабанов", 0, "", "Брат (Алексей Балабанов)"}, {"series one season", "Сёгун", "", 2024, "Сезон 2", "Сёгун (2024). Сезон 2"}, {"series multi season", "Фарго", "", 0, "Сезоны 1–3", "Фарго. Сезоны 1–3"}, {"series specials", "Рик и Морти", "", 0, "Спецвыпуски", "Рик и Морти. Спецвыпуски"}, {"empty title", " ", "Кто-то", 2024, "Сезон 1", ""}, {"sanitized", " Fargo\n", "", 2015, "", "Fargo (2015)"}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { if got := Label(c.title, c.director, c.year, c.season); got != c.want { t.Errorf("Label(%q, %q, %d, %q) = %q, want %q", c.title, c.director, c.year, c.season, got, c.want) } }) } } func TestDeriveNameRussianTitle(t *testing.T) { fp := &fakeProvider{responses: []string{ `{"type":"movie","title":"Брат","original_title":"Brat","year":1997,"is_russian":true}`, }} got := New(fp, 3, testLogger()).DeriveName(context.Background(), "Брат / Brat [1997]", "") if got != "Брат (1997)" { t.Errorf("DeriveName() = %q, want %q", got, "Брат (1997)") } } func TestDeriveNameRetriesThenSucceeds(t *testing.T) { fp := &fakeProvider{responses: []string{ "не json вовсе", `{"type":"movie","title":"Дюна: Часть вторая","year":2024}`, }} got := New(fp, 3, testLogger()).DeriveName(context.Background(), duneContext, "") if got != "Дюна: Часть вторая (2024)" { t.Errorf("DeriveName() = %q", got) } if fp.calls != 2 { t.Errorf("вызовов LLM = %d, want 2", fp.calls) } } func TestDeriveNameExhaustsThenFallback(t *testing.T) { fp := &fakeProvider{responses: []string{"мусор"}} got := New(fp, 3, testLogger()).DeriveName(context.Background(), duneContext, "") // Бюджет исчерпан → алгоритмический фолбек: первая строка без тех. спецификаций. if got != "Дюна: Часть вторая / Dune: Part Two" { t.Errorf("DeriveName() = %q (ожидался фолбек)", got) } if fp.calls != 3 { t.Errorf("вызовов LLM = %d, want 3 (исчерпание бюджета)", fp.calls) } } func TestDeriveNameProviderErrorFallsBack(t *testing.T) { fp := &fakeProvider{err: errors.New("connection refused")} got := New(fp, 3, testLogger()).DeriveName(context.Background(), duneContext, "") if got != "Дюна: Часть вторая / Dune: Part Two" { t.Errorf("DeriveName() = %q (ожидался фолбек при ошибке)", got) } if fp.calls != 1 { t.Errorf("вызовов LLM = %d, want 1 (на транспортной ошибке не повторяем)", fp.calls) } } func TestDeriveNameNilProviderUsesFallback(t *testing.T) { got := New(nil, 3, testLogger()).DeriveName(context.Background(), duneContext, "") if got != "Дюна: Часть вторая / Dune: Part Two" { t.Errorf("DeriveName() = %q (ожидался фолбек без LLM)", got) } }