Добавил реализацию
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
package naming
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// parenURL — markdown-хвост " (https://…)" в строках контекста.
|
||||
var parenURL = regexp.MustCompile(`\s*\(https?://[^)]+\)`)
|
||||
|
||||
// parenSpecs — открывающая скобка тех. характеристик «(2024, …»: круглая
|
||||
// скобка, за которой (через необяз. пробелы) идёт цифра.
|
||||
var parenSpecs = regexp.MustCompile(`\(\s*\d`)
|
||||
|
||||
// fallbackName выводит имя без сети: берёт первую содержательную строку
|
||||
// контекста, отсекает технические характеристики и обрезает по длине. Если
|
||||
// контекст пуст/бесполезен — пробует hint (dn из magnet). Возвращает "",
|
||||
// если ничего пригодного нет.
|
||||
func fallbackName(contextText, hint string) string {
|
||||
if line := firstMeaningfulLine(contextText); line != "" {
|
||||
return truncate(stripTechSpecs(line), maxNameLen)
|
||||
}
|
||||
if h := sanitize(hint); h != "" {
|
||||
return truncate(stripTechSpecs(h), maxNameLen)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// firstMeaningfulLine возвращает первую строку контекста, не являющуюся
|
||||
// ссылкой, командой бота или UI-мусором. Контекст из tgbot уже вычищен, но
|
||||
// для HTTP/CLI вход может быть сырым — отсюда лёгкая фильтрация.
|
||||
func firstMeaningfulLine(text string) string {
|
||||
for line := range strings.SplitSeq(text, "\n") {
|
||||
line = strings.TrimSpace(parenURL.ReplaceAllString(line, ""))
|
||||
line = sanitize(line)
|
||||
if line == "" || isNoiseLine(line) {
|
||||
continue
|
||||
}
|
||||
return line
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// isNoiseLine отсекает строки-ссылки и команды бота (см. tgbot для исходной
|
||||
// эвристики; здесь — минимум, нужный фолбеку для сырого ввода). Проверяем
|
||||
// префиксы, а не вхождения: голый URL внутри осмысленной строки не должен
|
||||
// выкидывать всю строку с названием.
|
||||
func isNoiseLine(line string) bool {
|
||||
switch {
|
||||
case strings.HasPrefix(line, "/"):
|
||||
return true // команда бота
|
||||
case strings.HasPrefix(line, "magnet:"),
|
||||
strings.HasPrefix(line, "http://"),
|
||||
strings.HasPrefix(line, "https://"):
|
||||
return true // строка-ссылка
|
||||
case strings.Contains(line, ": /"):
|
||||
return true // строка рейтинга/команд бота «👍: /g_… или 👎: /r_…»
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// stripTechSpecs отсекает хвост технических характеристик в скобках —
|
||||
// квадратных ("… [2024, WEB-DL 2160p …]") или круглых с годом
|
||||
// ("… (2024, WEB-DL …)") — и подчищает разделители на конце. Скобка с
|
||||
// текстом (например режиссёр «(Дени Вильнёв)») не режется.
|
||||
func stripTechSpecs(s string) string {
|
||||
cut := len(s)
|
||||
if i := strings.IndexByte(s, '['); i >= 0 && i < cut {
|
||||
cut = i
|
||||
}
|
||||
if loc := parenSpecs.FindStringIndex(s); loc != nil && loc[0] < cut {
|
||||
cut = loc[0]
|
||||
}
|
||||
s = strings.TrimRight(s[:cut], " -–—/.,")
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package naming
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestFallbackName(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
context string
|
||||
hint string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "release title with tech specs",
|
||||
context: "Дюна: Часть вторая / Dune: Part Two [2024, фантастика, WEB-DL 2160p]\nDub + MVO",
|
||||
want: "Дюна: Часть вторая / Dune: Part Two",
|
||||
},
|
||||
{
|
||||
name: "skips url and command lines",
|
||||
context: "magnet:?xt=urn:btih:abc\nhttps://example.com/x\n/help\nБрат [1997]",
|
||||
want: "Брат",
|
||||
},
|
||||
{
|
||||
name: "empty context falls back to magnet dn hint",
|
||||
context: "",
|
||||
hint: "rutracker-topic-6514485",
|
||||
want: "rutracker-topic-6514485",
|
||||
},
|
||||
{
|
||||
name: "useless context and no hint yields empty",
|
||||
context: "https://example.com/x\n/help",
|
||||
hint: "",
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "strips markdown url tail",
|
||||
context: "Сёгун (https://hashurl.ru/abc)",
|
||||
want: "Сёгун",
|
||||
},
|
||||
{
|
||||
name: "strips tech specs in round brackets with year",
|
||||
context: "Дюна (2024, фантастика, WEB-DL 2160p)",
|
||||
want: "Дюна",
|
||||
},
|
||||
{
|
||||
name: "keeps non-spec parenthesis (director)",
|
||||
context: "Брат (Алексей Балабанов) [1997, криминал]",
|
||||
want: "Брат (Алексей Балабанов)",
|
||||
},
|
||||
{
|
||||
name: "inline url does not drop the title line",
|
||||
context: "Дюна 2024 подробнее http://tracker/x",
|
||||
want: "Дюна 2024 подробнее http://tracker/x",
|
||||
},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := fallbackName(tc.context, tc.hint); got != tc.want {
|
||||
t.Errorf("fallbackName() = %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
// Package naming выводит человекочитаемое отображаемое имя торрента из
|
||||
// текстового контекста загрузки. Имя нужно лишь как ярлык в списке
|
||||
// qBittorrent (вместо безликого dn вроде rutracker-topic-6514485) и не
|
||||
// влияет на пути на диске или распознавание.
|
||||
//
|
||||
// Стратегия: сначала пробуем LLM (структурированный вывод названия/года/
|
||||
// режиссёра/сезона), при неудаче — алгоритмический фолбек без сети. Любой
|
||||
// сбой деградирует к пустой строке: приём загрузки никогда не падает из-за
|
||||
// вывода имени.
|
||||
package naming
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"git.vakhrushev.me/av/jellybit/internal/llm"
|
||||
)
|
||||
|
||||
// maxNameLen — ограничение длины отображаемого имени (символов/рун).
|
||||
const maxNameLen = 200
|
||||
|
||||
// mediaType — вид контента в извлечённой структуре.
|
||||
type mediaType string
|
||||
|
||||
const (
|
||||
typeMovie mediaType = "movie"
|
||||
typeSeries mediaType = "series"
|
||||
)
|
||||
|
||||
// extracted — структура имени, извлечённая из контекста (схема ответа LLM).
|
||||
// Year и Director опциональны (пустое значение → в ярлык не попадают).
|
||||
type extracted struct {
|
||||
Type mediaType `json:"type"`
|
||||
Title string `json:"title"`
|
||||
OriginalTitle string `json:"original_title"`
|
||||
Year int `json:"year"`
|
||||
Director string `json:"director"`
|
||||
Season *int `json:"season"`
|
||||
IsRussian bool `json:"is_russian"`
|
||||
}
|
||||
|
||||
// Namer выводит отображаемое имя. provider может быть nil — тогда работает
|
||||
// только алгоритмический фолбек.
|
||||
type Namer struct {
|
||||
provider llm.Provider
|
||||
// attempts — число попыток получить валидный ответ LLM ([llm].max_retries).
|
||||
// Здесь это ровно столько вызовов модели (в отличие от recognize, где
|
||||
// max_retries — это число ПЕРЕразборов, т.е. max_retries+1 вызовов).
|
||||
attempts int
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// New собирает Namer. provider nil → только фолбек. attempts < 1 → 1.
|
||||
// logger nil → slog.Default().
|
||||
func New(provider llm.Provider, attempts int, logger *slog.Logger) *Namer {
|
||||
if attempts < 1 {
|
||||
attempts = 1
|
||||
}
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
return &Namer{provider: provider, attempts: attempts, log: logger}
|
||||
}
|
||||
|
||||
// DeriveName выводит отображаемое имя из контекста. hint — подсказка из
|
||||
// magnet (dn), используется только фолбеком, если контекст пуст. Возвращает
|
||||
// "" если имя получить не удалось (тогда вызывающий не задаёт rename).
|
||||
func (n *Namer) DeriveName(ctx context.Context, contextText, hint string) string {
|
||||
if n.provider != nil {
|
||||
if ex, ok := n.extractViaLLM(ctx, contextText, hint); ok {
|
||||
if name := render(ex); name != "" {
|
||||
return name
|
||||
}
|
||||
}
|
||||
}
|
||||
return fallbackName(contextText, hint)
|
||||
}
|
||||
|
||||
// render собирает из структуры короткий ярлык:
|
||||
// - movie: "Title (Director, Year)" — режиссёр и год опциональны;
|
||||
// - series: то же + ". Сезон N", если сезон задан.
|
||||
//
|
||||
// Имя очищается от управляющих символов/переводов строк и обрезается по
|
||||
// длине. Пустой Title → пустая строка.
|
||||
func render(ex extracted) string {
|
||||
title := sanitize(ex.Title)
|
||||
if title == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
var paren []string
|
||||
if d := sanitize(ex.Director); d != "" {
|
||||
paren = append(paren, d)
|
||||
}
|
||||
if ex.Year > 0 {
|
||||
paren = append(paren, strconv.Itoa(ex.Year))
|
||||
}
|
||||
|
||||
name := title
|
||||
if len(paren) > 0 {
|
||||
name += " (" + strings.Join(paren, ", ") + ")"
|
||||
}
|
||||
if ex.Type == typeSeries && ex.Season != nil && *ex.Season > 0 {
|
||||
name += ". Сезон " + strconv.Itoa(*ex.Season)
|
||||
}
|
||||
return truncate(name, maxNameLen)
|
||||
}
|
||||
|
||||
// sanitize убирает управляющие символы и переводы строк, схлопывает пробелы.
|
||||
func sanitize(s string) string {
|
||||
s = strings.Map(func(r rune) rune {
|
||||
if r == '\n' || r == '\t' || r == '\r' {
|
||||
return ' '
|
||||
}
|
||||
if r < 0x20 {
|
||||
return -1
|
||||
}
|
||||
return r
|
||||
}, s)
|
||||
return strings.Join(strings.Fields(s), " ")
|
||||
}
|
||||
|
||||
// truncate обрезает строку до n рун (без разрыва символа), отбрасывая хвост.
|
||||
func truncate(s string, n int) string {
|
||||
if utf8.RuneCountInString(s) <= n {
|
||||
return s
|
||||
}
|
||||
count := 0
|
||||
for i := range s {
|
||||
if count == n {
|
||||
return strings.TrimRight(s[:i], " ")
|
||||
}
|
||||
count++
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
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 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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package naming
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"git.vakhrushev.me/av/jellybit/internal/llm"
|
||||
)
|
||||
|
||||
// systemPrompt инструктирует модель вытащить из вольного контекста короткое
|
||||
// имя. Язык названия выбирается по происхождению контента.
|
||||
const systemPrompt = `Ты извлекаешь из текста о торрент-раздаче данные для короткого названия.
|
||||
Верни СТРОГО один JSON-объект, без markdown-ограждений и пояснений.
|
||||
|
||||
Схема:
|
||||
{
|
||||
"type": "movie" | "series",
|
||||
"title": "название на нужном языке",
|
||||
"original_title": "оригинальное название или пустая строка",
|
||||
"year": число или 0,
|
||||
"director": "режиссёр или пустая строка",
|
||||
"season": число или null,
|
||||
"is_russian": true | false
|
||||
}
|
||||
|
||||
Правила:
|
||||
- Определи, фильм это или сериал ("type").
|
||||
- "is_russian" = true, если это российский/советский фильм или сериал.
|
||||
- "title": для российского контента — русское название; иначе — английское
|
||||
(оригинальное). Без года, страны и технических характеристик.
|
||||
- "year" — год выпуска (0, если не ясен). "director" — режиссёр (пустая
|
||||
строка, если не указан). "season" — номер сезона для сериала (null для
|
||||
фильма или если сезон не указан).
|
||||
- Если данных не хватает, заполняй что можешь; "title" должен быть непустым.`
|
||||
|
||||
// extractViaLLM делает до n.attempts попыток получить валидную структуру.
|
||||
// Транспортные ошибки провайдера (сеть/429/5xx) гасятся внутри llm.Provider;
|
||||
// здесь повторяем только переразбор невалидного ответа. ok=false, если за
|
||||
// все попытки валидного результата не получено.
|
||||
func (n *Namer) extractViaLLM(ctx context.Context, contextText, hint string) (extracted, bool) {
|
||||
contextText = strings.TrimSpace(contextText)
|
||||
if contextText == "" {
|
||||
return extracted{}, false
|
||||
}
|
||||
|
||||
user := "Контекст раздачи:\n" + contextText
|
||||
if hint = strings.TrimSpace(hint); hint != "" {
|
||||
user += "\n\nИмя из magnet (подсказка, может быть мусором): " + hint
|
||||
}
|
||||
|
||||
temp := 0.0
|
||||
msgs := []llm.Message{
|
||||
{Role: llm.RoleSystem, Content: systemPrompt},
|
||||
{Role: llm.RoleUser, Content: user},
|
||||
}
|
||||
|
||||
for attempt := 1; attempt <= n.attempts; attempt++ {
|
||||
resp, err := n.provider.Complete(ctx, llm.Request{
|
||||
Messages: msgs,
|
||||
JSONMode: true,
|
||||
Temperature: &temp,
|
||||
})
|
||||
if err != nil {
|
||||
// Транспортная ошибка/таймаут: дальше пробовать смысла нет —
|
||||
// уходим в фолбек, приём не валим.
|
||||
n.log.Warn("naming: llm complete failed, will fall back", "err", err)
|
||||
return extracted{}, false
|
||||
}
|
||||
|
||||
ex, perr := parseExtracted(resp.Content)
|
||||
if perr == nil {
|
||||
return ex, true
|
||||
}
|
||||
n.log.Warn("naming: unparsed llm response", "attempt", attempt, "err", perr)
|
||||
msgs = append(msgs,
|
||||
llm.Message{Role: llm.RoleAssistant, Content: resp.Content},
|
||||
llm.Message{Role: llm.RoleUser, Content: "Ответ невалиден: " + perr.Error() +
|
||||
". Верни строго один JSON-объект по схеме, без пояснений."})
|
||||
}
|
||||
return extracted{}, false
|
||||
}
|
||||
|
||||
// parseExtracted вытаскивает JSON-объект из ответа и валидирует минимум:
|
||||
// непустой title. Тип по умолчанию — movie.
|
||||
func parseExtracted(raw string) (extracted, error) {
|
||||
jsonStr, err := llm.ExtractJSONObject(raw)
|
||||
if err != nil {
|
||||
return extracted{}, err
|
||||
}
|
||||
var ex extracted
|
||||
if err := json.Unmarshal([]byte(jsonStr), &ex); err != nil {
|
||||
return extracted{}, fmt.Errorf("naming: unmarshal: %w", err)
|
||||
}
|
||||
if strings.TrimSpace(ex.Title) == "" {
|
||||
return extracted{}, fmt.Errorf("naming: empty title")
|
||||
}
|
||||
if ex.Type != typeMovie && ex.Type != typeSeries {
|
||||
ex.Type = typeMovie
|
||||
}
|
||||
return ex, nil
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package naming
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func ptr(i int) *int { return &i }
|
||||
|
||||
func TestRender(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
in extracted
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "movie with director and year",
|
||||
in: extracted{Type: typeMovie, Title: "Дюна: Часть вторая", Director: "Дени Вильнёв", Year: 2024},
|
||||
want: "Дюна: Часть вторая (Дени Вильнёв, 2024)",
|
||||
},
|
||||
{
|
||||
name: "movie with year only",
|
||||
in: extracted{Type: typeMovie, Title: "Дюна: Часть вторая", Year: 2024},
|
||||
want: "Дюна: Часть вторая (2024)",
|
||||
},
|
||||
{
|
||||
name: "movie with director only",
|
||||
in: extracted{Type: typeMovie, Title: "Брат", Director: "Алексей Балабанов"},
|
||||
want: "Брат (Алексей Балабанов)",
|
||||
},
|
||||
{
|
||||
name: "movie without director and year",
|
||||
in: extracted{Type: typeMovie, Title: "Брат"},
|
||||
want: "Брат",
|
||||
},
|
||||
{
|
||||
name: "series with season",
|
||||
in: extracted{Type: typeSeries, Title: "Сёгун", Year: 2024, Season: ptr(2)},
|
||||
want: "Сёгун (2024). Сезон 2",
|
||||
},
|
||||
{
|
||||
name: "series without season",
|
||||
in: extracted{Type: typeSeries, Title: "Сёгун", Year: 2024},
|
||||
want: "Сёгун (2024)",
|
||||
},
|
||||
{
|
||||
name: "series season zero is omitted",
|
||||
in: extracted{Type: typeSeries, Title: "Сёгун", Season: ptr(0)},
|
||||
want: "Сёгун",
|
||||
},
|
||||
{
|
||||
name: "empty title yields empty name",
|
||||
in: extracted{Type: typeMovie, Title: " ", Year: 2024},
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "control chars and newlines are sanitized",
|
||||
in: extracted{Type: typeMovie, Title: "Дюна\n\tЧасть\x00 вторая", Year: 2024},
|
||||
want: "Дюна Часть вторая (2024)",
|
||||
},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := render(tc.in); got != tc.want {
|
||||
t.Errorf("render() = %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderTruncates(t *testing.T) {
|
||||
long := strings.Repeat("я", maxNameLen+50)
|
||||
got := render(extracted{Type: typeMovie, Title: long})
|
||||
if n := len([]rune(got)); n > maxNameLen {
|
||||
t.Errorf("длина имени = %d рун, want <= %d", n, maxNameLen)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user