Files
jellybit/internal/httpapi/format_test.go
T
avandClaude Opus 4.8 bb245a90a3 Веб-UI: обзор жизненного цикла в карточке загрузки
Карточка списка на главной теперь даёт краткий обзор «от загрузки до
решения об удалении»: метка «ID:» перед идентификатором, дата добавления
(абсолютная + относительная, всегда), размер раздачи и рейтинг отдачи.
Спойлер контекста убран — контекст смотрят на /download/{id}.

Данные:
- рейтинг и общий размер — из живого снимка воркера (qbt total_size →
  worker.Live.TotalSize); размер доступен для любой раздачи в снимке;
- размер-фолбэк, когда торрента нет в qBittorrent (orphaned) — сумма
  размеров разложенных файлов: новая колонка file_link.size, layouter
  пишет размер при линковке, ридер LayoutSizeByDownload суммирует по
  странице одним запросом (дедуп по dst_path);
- дата — source_added_at → фолбэк created_at, показ в TZ сервера.

handleIndex читает снимок для всех карточек (map-lookup), рейтинг/размер
статичны на рендере (без поллинга). Миграция 0007, ER-схема обновлена.
Change download-card-lifecycle-overview влит в спеки и заархивирован.

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

125 lines
4.8 KiB
Go

package httpapi
import (
"net/http"
"strings"
"testing"
"time"
"git.vakhrushev.me/av/jellybit/internal/store"
"git.vakhrushev.me/av/jellybit/internal/worker"
)
func TestHumanizeAge(t *testing.T) {
now := time.Date(2026, 7, 4, 12, 0, 0, 0, time.UTC)
cases := []struct {
ago time.Duration
want string
}{
{30 * time.Second, "только что"},
{time.Minute, "1 минуту назад"},
{5 * time.Minute, "5 минут назад"},
{2 * time.Hour, "2 часа назад"},
{24 * time.Hour, "1 день назад"},
{5 * 24 * time.Hour, "5 дней назад"},
{40 * 24 * time.Hour, "1 месяц назад"},
{400 * 24 * time.Hour, "1 год назад"},
}
for _, c := range cases {
if got := humanizeAge(now.Add(-c.ago), now); got != c.want {
t.Errorf("humanizeAge(-%s) = %q, want %q", c.ago, got, c.want)
}
}
// Будущее (рассинхрон часов) не должно давать «-N»: схлопывается в «только что».
if got := humanizeAge(now.Add(time.Hour), now); got != "только что" {
t.Errorf("humanizeAge(future) = %q, want «только что»", got)
}
}
func TestPlural(t *testing.T) {
cases := []struct {
n int
want string
}{{1, "день"}, {2, "дня"}, {4, "дня"}, {5, "дней"}, {11, "дней"}, {14, "дней"}, {21, "день"}, {22, "дня"}, {25, "дней"}}
for _, c := range cases {
if got := plural(c.n, "день", "дня", "дней"); got != c.want {
t.Errorf("plural(%d) = %q, want %q", c.n, got, c.want)
}
}
}
func TestSizeAndRatioText(t *testing.T) {
// Снимок есть → размер из total_size, рейтинг из снимка.
l := worker.Live{TotalSize: 2 << 30, Ratio: 1.42}
if got := sizeText(l, true, 0); got != fmtBytes(2<<30) {
t.Errorf("size (снимок) = %q, want %q", got, fmtBytes(2<<30))
}
if got := ratioText(l, true); got != "1.42" {
t.Errorf("ratio (снимок) = %q, want 1.42", got)
}
// Снимка нет → размер из фолбэка по файлам, рейтинг «—».
if got := sizeText(worker.Live{}, false, 512); got != fmtBytes(512) {
t.Errorf("size (фолбэк) = %q, want %q", got, fmtBytes(512))
}
if got := ratioText(worker.Live{}, false); got != "—" {
t.Errorf("ratio (нет снимка) = %q, want «—»", got)
}
// Ни снимка, ни файлов → «—».
if got := sizeText(worker.Live{}, false, 0); got != "—" {
t.Errorf("size (нет данных) = %q, want «—»", got)
}
}
func TestAddedTimeFallback(t *testing.T) {
// source_added_at приоритетнее created_at.
added := time.Date(2026, 6, 30, 10, 0, 0, 0, time.UTC)
created := time.Date(2026, 6, 1, 10, 0, 0, 0, time.UTC)
d := store.Download{
CreatedAt: store.FormatTime(created),
SourceAddedAt: store.NullString(store.FormatTime(added)),
}
got, ok := addedTime(d)
if !ok || !got.Equal(added) {
t.Errorf("addedTime = %v (ok=%v), want %v", got, ok, added)
}
// Без source_added_at — фолбэк на created_at.
d.SourceAddedAt = store.NullString("")
got, ok = addedTime(d)
if !ok || !got.Equal(created) {
t.Errorf("addedTime (фолбэк) = %v (ok=%v), want %v", got, ok, created)
}
// Нечего парсить — ok=false.
if _, ok := addedTime(store.Download{}); ok {
t.Error("addedTime пустой должен вернуть ok=false")
}
}
// TestIndexCardMeta: карточка списка несёт обзорную мета-строку (метка ID, дата
// добавления, размер и рейтинг из снимка) и НЕ показывает контекст.
func TestIndexCardMeta(t *testing.T) {
added := time.Date(2026, 6, 30, 10, 0, 0, 0, time.UTC)
dl := store.Download{
ID: testULID, SourceRef: "Dune", DisplayName: "Dune (2024)", Context: "секретный контекст",
Infohashes: []store.Infohash{{DownloadID: testULID, Infohash: "ihmeta", Kind: store.HashV1}},
State: store.StateDone,
SourceAddedAt: store.NullString(store.FormatTime(added)),
}
lv := stubLive{m: map[string]worker.Live{"ihmeta": {Seeding: true, TotalSize: 2 << 30, Ratio: 1.42}}}
h := testRouterLive(t, stubReader{list: []store.Download{dl}}, stubReviewer{}, lv)
rr := get(t, h, "/")
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rr.Code)
}
body := rr.Body.String()
for _, want := range []string{"ID:", "2026-06-30", "назад", "1.42", fmtBytes(2 << 30)} {
if !strings.Contains(body, want) {
t.Errorf("карточка не содержит %q", want)
}
}
// Контекст из карточки убран (доступен на /download/{id}).
if strings.Contains(body, "секретный контекст") {
t.Error("контекст всё ещё показан в карточке списка")
}
}