Карточка списка на главной теперь даёт краткий обзор «от загрузки до
решения об удалении»: метка «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>
125 lines
4.8 KiB
Go
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("контекст всё ещё показан в карточке списка")
|
|
}
|
|
}
|