Files
avandClaude Opus 4.8 612344bab3 конвенции: перенести механизируемое в golangci-lint и internal/archrules
Правило, которое проверяет машина, не должно оставаться прозой: файл конвенций
на сотни строк размазывает внимание по тривиальному — модель добросовестно
проверит именование полей лога и не дойдёт до формы решения.

Включены sloglint (константный msg, стиль ключ-значение), forbidigo (fmt.Print*,
os.Getenv, time.Now мимо store.Now), errorlint (сравнение ошибок), depguard
(сторонние пакеты ошибок). internal/archrules — сканеры на то, что линтером не
выражается: направление зависимостей ядро↔транспорты, AUTOINCREMENT и серверное
время в новых миграциях, матчинг ошибки по тексту.

Код приведён к правилам: logging.StartCall как единая точка отсчёта длительности
внешних вызовов, store.Now вместо time.Now в httpapi и часах воркера,
slog.DiscardHandler в тестах.

Перенесённое вычеркнуто из docs/conventions/* и openspec/config.yaml — прозой
осталось только то, что правилом не выражается.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 18:17:40 +03:00

161 lines
6.3 KiB
Go
Raw Permalink 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)
}
}