Единый источник полей отображаемого имени и один рендер полного ярлыка на всех путях (старт и «Обновить имя»/авто-перелив). Раньше старт давал полный «Название (режиссёр, год). Сезон N» но выбрасывал структуру, а перелив по распознаванию — усечённый «Title (Year)». - Слоистое разрешение скаляров имени: override → recognition(+match) → новый базовый слой «контекст» (download.parsed_context, JSON naming.Fields). - naming: публичные Fields/Label/Derive, вынесен единый рендер; удалён FormatTitleYear. Сводка сезонов вынесена в recognize.SeasonSummary. - Режиссёр из метабазы (решение A2): TMDB/TVDB credits через опциональный metadata.DirectorProvider; авто-матч кладёт в plan.Director, ручной выбор кандидата тянет credits и пиннит ovrDirector. Метабаза бьёт контекст. - refreshDisplayNameLocked строит полный ярлык из эффективных полей; инфо-панель ревью показывает загруженного режиссёра. - Миграция 0011_parsed_context + ER-схема. Всё косметика: на пути/раскладку не влияет, приём/вывод имени не валятся (best-effort). Закрывает беклог-задачу «Кнопка „Обновить имя“: полный формат ярлыка». OpenSpec: archive/2026-07-11-field-resolution-display-name (ingest, recognition, metadata-match, review). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
162 lines
6.3 KiB
Go
162 lines
6.3 KiB
Go
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)
|
||
}
|
||
}
|