Files
jellybit/internal/naming/naming_test.go
T
av 9aecf757e0 recognize: название из метабазы санитизируется перед попаданием в план
- чистка стоит на каждой точке входа значения метабазы в план — сборка матча,
  копия кандидата для ревью, набор закреплённых значений источника и его
  чтение: гарантия, поставленная только на запись, обходится данными,
  сохранёнными прежними версиями
- название, непригодное как имя каталога (пустое или без единой буквы и
  цифры), не подставляется — раздача уходит в review с названной причиной
- гейт подтверждения матча не сдвинут: сравнение с планом идёт по значениям
  провайдера, чистится только копия, уходящая дальше
2026-08-10 10:41:16 +03:00

183 lines
7.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package naming
import (
"context"
"errors"
"log/slog"
"testing"
"git.vakhrushev.me/av/jellybit/internal/llm"
)
func testLogger() *slog.Logger {
return slog.New(slog.DiscardHandler)
}
// 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, "Сезоны 13", "Фарго. Сезоны 13"},
{"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)
}
}
// Значения ярлыка приходят снаружи — из credits метабазы и из сохранённого
// контекста. Категория Cf (zero-width, BOM, RLO) обязана сниматься здесь:
// требование metadata-match не чистит режиссёра в плане именно потому, что его
// чистит рендер. RLO в карточке Telegram переворачивает отображение имени.
func TestSanitize_StripsFormatChars(t *testing.T) {
cases := []struct{ in, want string }{
{"Denis\u202eVilleneuve", "DenisVilleneuve"},
{"Denis\u200bVilleneuve", "DenisVilleneuve"},
{"Denis\ufeffVilleneuve", "DenisVilleneuve"},
{"Denis\nVilleneuve", "Denis Villeneuve"},
{"Дени Вильнёв", "Дени Вильнёв"},
}
for _, c := range cases {
if got := sanitize(c.in); got != c.want {
t.Errorf("sanitize(%q) = %q, want %q", c.in, got, c.want)
}
}
if got := Label("Dune", "Denis\u202eVilleneuve", 2021, ""); got != "Dune (DenisVilleneuve, 2021)" {
t.Errorf("Label = %q", got)
}
}