Files
jellybit/internal/naming/naming_test.go
T
avandClaude Opus 4.8 0c9421f4c1 Имя: восстановление display_name после распознавания + гард пустого входа
Голый magnet без dn/контекста заставлял namer звать LLM на пустом входе,
и модель галлюцинировала мусорное имя («Unknown»), которое писалось и в
display_name, и в rename qBittorrent, а заодно ломало UI-фолбэк на
распознанное название. Верное каноническое имя, вычисляемое позже при
распознавании, никуда не переливалось.

- naming: гард пустого входа в DeriveName (нет контекста и подсказки → ""
  без вызова LLM) + детерминированный форматтер FormatTitleYear.
- qbt: операция RenameTorrent (переименование существующей раздачи).
- store: SetDisplayName — обновление имени постфактум без гарда состояния.
- worker: refreshDisplayNameLocked/RefreshDisplayName — перелив канонического
  имени (эффективный план) в display_name + best-effort rename раздачи по
  реальному t.Hash; авто-триггер при подтверждении матча (choose/manual add).
- web-ui: кнопка «Обновить имя» на странице загрузки (htmx-своп заголовка,
  деградация без JS), видимая при наличии распознавания (вкл. done/orphaned).

Спека: дельты ingest/review/web-ui влиты в openspec/specs; change
refresh-display-name заархивирован.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 16:51:04 +03:00

152 lines
5.6 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 TestFormatTitleYear(t *testing.T) {
cases := []struct {
title string
year int
want string
}{
{"Harold and the Purple Crayon", 2024, "Harold and the Purple Crayon (2024)"},
{"Брат", 0, "Брат"},
{" Fargo\n", 2015, "Fargo (2015)"},
{"", 2024, ""},
{" ", 0, ""},
}
for _, c := range cases {
if got := FormatTitleYear(c.title, c.year); got != c.want {
t.Errorf("FormatTitleYear(%q, %d) = %q, want %q", c.title, c.year, 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)
}
}