web-ui: карточка и страница обновляются, пока задачу может двигать фон
- условие самообновления — доменный предикат store.State.IsObservable() вместо фазы catched; один поллер на поверхность, интервалы 5 с и 15 с - отказ тика отвечает 200 и самозавершающимся фрагментом с корневым id цели вместо 404/500, который htmx не свопит - заведён ADR-2026-08-10-observability-is-not-terminality, переписан раздел «Живой поллинг» в конвенции веб-UI
This commit is contained in:
@@ -2,6 +2,7 @@ package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -337,8 +338,9 @@ func TestSourceSwapUpdatesActionBarOOB(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// TestRetryListShowsProgress: retry из списка → карточка downloading с
|
||||
// прогресс-поллером.
|
||||
// TestRetryListShowsProgress: retry из списка → карточка downloading с живым
|
||||
// прогрессом и самообновлением карточки (опрашивает себя карточка, а не
|
||||
// вложенный блок прогресса).
|
||||
func TestRetryListShowsProgress(t *testing.T) {
|
||||
dl := dlState(store.StateDownloading)
|
||||
lv := stubLive{m: map[string]worker.Live{"ihswap": {Progress: 0.42, DlSpeed: 6400000, ETA: 720}}}
|
||||
@@ -348,8 +350,11 @@ func TestRetryListShowsProgress(t *testing.T) {
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("retry (htmx) = %d, want 200", rr.Code)
|
||||
}
|
||||
if !strings.Contains(rr.Body.String(), "/fragments/downloads/"+testULID+"/progress") {
|
||||
t.Errorf("карточка downloading без прогресс-поллера: %s", rr.Body.String())
|
||||
body := rr.Body.String()
|
||||
for _, want := range []string{"width:42%", "/fragments/downloads/" + testULID + "/card"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("карточка downloading без %q: %s", want, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -407,3 +412,32 @@ func TestActionBarNamesReasonWithoutPreview(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestActionSwapErrorKeepsSwapRoot: действие человека, упавшее на чтении задачи,
|
||||
// отвечает 200 и фрагментом с корнем своей поверхности — иначе своп унёс бы
|
||||
// якорь (#card-{id} у списка, #download-main у страницы) и следующие действия
|
||||
// целились бы в несуществующий узел. Ретрая у действия нет, поэтому уровень лога
|
||||
// здесь ERROR, а не WARN, как у повторяющегося тика.
|
||||
func TestActionSwapErrorKeepsSwapRoot(t *testing.T) {
|
||||
cases := []struct{ surface, root string }{
|
||||
{"list", `id="card-` + testULID + `"`},
|
||||
{"download", `id="download-main"`},
|
||||
}
|
||||
for _, c := range cases {
|
||||
rd := stubReader{getErr: errors.New("db is gone")}
|
||||
h := testRouterAction(t, rd, actionReviewer{}, stubCommander{}, stubLive{})
|
||||
|
||||
rr := post(t, h, "/ui/downloads/"+testULID+"/cancel", url.Values{"surface": {c.surface}}, true)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Errorf("surface=%s: status = %d, want 200", c.surface, rr.Code)
|
||||
continue
|
||||
}
|
||||
body := rr.Body.String()
|
||||
if !strings.Contains(body, c.root) {
|
||||
t.Errorf("surface=%s: фрагмент отказа без корня %s:\n%s", c.surface, c.root, body)
|
||||
}
|
||||
if strings.Contains(body, "hx-trigger") {
|
||||
t.Errorf("surface=%s: фрагмент отказа не самозавершается:\n%s", c.surface, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,8 @@ type downloadDetailView struct {
|
||||
Infohashes []string // все хеши загрузки (блок «Информация о торренте»)
|
||||
Context string
|
||||
State string
|
||||
SelfPoll bool // catched → страница сама опрашивает себя до перехода
|
||||
SelfPoll bool // задача наблюдаема → страница сама опрашивает себя
|
||||
PollEvery string
|
||||
Error string
|
||||
ActionError string // ошибка действия на htmx-пути (своп download_main), не error_msg
|
||||
Note string
|
||||
@@ -89,6 +90,15 @@ func (s *server) handleDownload(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
rd, err := s.deps.Reviewer.ReviewData(r.Context(), id)
|
||||
if err != nil {
|
||||
// Тик самообновления страницы идёт этим же маршрутом (hx-get="/download/{id}"
|
||||
// с hx-select="#download-main"). Отвечать ему статусом ошибки нельзя: htmx не
|
||||
// свопит 4xx/5xx и не снимает hx-trigger — страница осталась бы навсегда
|
||||
// устаревшей, а опрос продолжался бы до закрытия вкладки. Навигационный GET
|
||||
// (адресная строка, закладка) по-прежнему получает честный статус.
|
||||
if isHTMX(r) {
|
||||
s.fragTickErr(w, err, id, "download-main")
|
||||
return
|
||||
}
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
http.Error(w, "задача не найдена", http.StatusNotFound)
|
||||
return
|
||||
@@ -113,7 +123,10 @@ func (s *server) buildDownloadView(id string, rd *worker.ReviewData) downloadDet
|
||||
Infohashes: d.HashList(),
|
||||
Context: d.Context,
|
||||
State: string(d.State),
|
||||
SelfPoll: d.State == store.StateCatched,
|
||||
SelfPoll: d.State.IsObservable(),
|
||||
// Блока живых цифр качания на странице нет вовсе, а тик считает
|
||||
// предпросмотр раскладки и ходит в ФС — интервал всегда медленный.
|
||||
PollEvery: pollSlow,
|
||||
Error: d.ErrorMsg.String,
|
||||
Note: desyncNote(d.State),
|
||||
CreatedAt: d.CreatedAt,
|
||||
|
||||
+20
-12
@@ -114,11 +114,12 @@ func NewRouter(d Deps) (http.Handler, error) {
|
||||
r.Get("/", s.handleIndex)
|
||||
r.Get("/download/{id}", s.handleDownload)
|
||||
|
||||
// Живые фрагменты телеметрии (htmx-поллинг; читают снимок воркера).
|
||||
// Партиалы телеметрии без потребителя в новой разметке: оставлены гасителями
|
||||
// вкладок, отрисованных прошлой версией (см. handleFragProgress).
|
||||
r.Get("/fragments/downloads/{id}/progress", s.handleFragProgress)
|
||||
r.Get("/fragments/downloads/{id}/seeding", s.handleFragSeeding)
|
||||
// Карточка целиком: самополлинг catched до перехода в downloading (бейдж,
|
||||
// имя и появившийся прогресс обновляются без перезагрузки).
|
||||
// Карточка целиком — тик самообновления списка: пока задача наблюдаема,
|
||||
// карточка приносит текущее состояние без перезагрузки страницы.
|
||||
r.Get("/fragments/downloads/{id}/card", s.handleFragCard)
|
||||
// Тело ревью для поллинга recognizing (htmx-своп до готового плана).
|
||||
r.Get("/fragments/downloads/{id}/review", s.handleFragReview)
|
||||
@@ -205,8 +206,9 @@ type downloadView struct {
|
||||
State string
|
||||
Error string
|
||||
Terminal bool
|
||||
IsDownloading bool // активная загрузка → живой прогресс-бар + поллинг
|
||||
SelfPoll bool // catched → карточка сама опрашивает себя до перехода
|
||||
IsDownloading bool // активная загрузка → живой прогресс-бар
|
||||
SelfPoll bool // задача наблюдаема → карточка сама опрашивает себя
|
||||
PollEvery string // интервал самообновления карточки (pollFast/pollSlow)
|
||||
Progress progressView // живой прогресс (заполняется в handleIndex из снимка)
|
||||
Reviewable bool // review/deferred — есть экран ревью
|
||||
Undoable bool // done — можно откатить раскладку
|
||||
@@ -311,10 +313,11 @@ func (s *server) handleIndex(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// buildCardView собирает представление карточки списка из доменных данных и
|
||||
// живого снимка. Общий для полной страницы (handleIndex) и htmx-свопа карточки
|
||||
// после действия (renderCardFragment): чтобы htmx-ветка не дублировала обвязку
|
||||
// (рейтинг/размер/прогресс). Для retry→downloading карточка обязана нести
|
||||
// прогресс-поллер — поэтому Progress заполняется здесь.
|
||||
// живого снимка. Общий для полной страницы (handleIndex), тика самообновления
|
||||
// (handleFragCard) и htmx-свопа после действия (renderCardFragment): чтобы
|
||||
// htmx-ветки не дублировали обвязку (рейтинг/размер/прогресс). Живые цифры едут
|
||||
// вместе с карточкой — своего опроса у блока прогресса нет, поэтому Progress
|
||||
// заполняется здесь на каждом пути.
|
||||
func (s *server) buildCardView(d store.Download, now time.Time, layoutSize int64) downloadView {
|
||||
v := s.toView(d, now)
|
||||
// Живой снимок читаем для всех карточек (map-lookup, без сети/БД): рейтинг
|
||||
@@ -494,7 +497,7 @@ func (s *server) surfaceAction(w http.ResponseWriter, r *http.Request, id string
|
||||
func (s *server) renderCardFragment(w http.ResponseWriter, r *http.Request, id string, actionErr error) {
|
||||
d, err := s.deps.Reader.GetDownload(r.Context(), id)
|
||||
if err != nil {
|
||||
s.fragErr(w, err, id)
|
||||
s.fragActionErr(w, err, id, "card-"+id)
|
||||
return
|
||||
}
|
||||
sizes, err := s.deps.Reader.LayoutSizeByDownload(r.Context(), []string{id})
|
||||
@@ -514,7 +517,7 @@ func (s *server) renderCardFragment(w http.ResponseWriter, r *http.Request, id s
|
||||
func (s *server) renderDownloadFragment(w http.ResponseWriter, r *http.Request, id string, actionErr error) {
|
||||
rd, err := s.deps.Reviewer.ReviewData(r.Context(), id)
|
||||
if err != nil {
|
||||
s.fragErr(w, err, id)
|
||||
s.fragActionErr(w, err, id, "download-main")
|
||||
return
|
||||
}
|
||||
v := s.buildDownloadView(id, rd)
|
||||
@@ -661,7 +664,8 @@ func (s *server) toView(d store.Download, now time.Time) downloadView {
|
||||
Error: d.ErrorMsg.String,
|
||||
Terminal: d.State.IsTerminal(),
|
||||
IsDownloading: d.State == store.StateDownloading,
|
||||
SelfPoll: d.State == store.StateCatched,
|
||||
SelfPoll: d.State.IsObservable(),
|
||||
PollEvery: pollSlow,
|
||||
Reviewable: d.State == store.StateReview || d.State == store.StateDeferred,
|
||||
Undoable: d.State == store.StateDone,
|
||||
Relinkable: d.State == store.StateReverted || d.State == store.StateCancelled ||
|
||||
@@ -669,6 +673,10 @@ func (s *server) toView(d store.Download, now time.Time) downloadView {
|
||||
Retriable: d.State == store.StateFailed || d.State == store.StateStuck,
|
||||
Note: desyncNote(d.State),
|
||||
}
|
||||
// Быстрый интервал — только там, где на поверхности бегут цифры качания.
|
||||
if v.IsDownloading {
|
||||
v.PollEvery = pollFast
|
||||
}
|
||||
// Дата добавления в карточке — всегда (source_added_at → фолбэк created_at,
|
||||
// как в порядке списка); неразбираемое время просто опускаем.
|
||||
if t, ok := addedTime(d); ok {
|
||||
|
||||
+94
-26
@@ -18,27 +18,43 @@ type LiveStatus interface {
|
||||
Live(infohash string) (worker.Live, bool)
|
||||
}
|
||||
|
||||
// Интервалы самообновления поверхностей (значение hx-trigger="every …").
|
||||
//
|
||||
// - pollFast — поверхность с живыми цифрами качания (карточка в downloading).
|
||||
// Равен [worker].poll_interval: воркер снимает телеметрию раз в 5 с, и
|
||||
// опрашивать чаще значит возвращать тот же кадр (docs/database.md).
|
||||
// - pollSlow — все прочие наблюдаемые поверхности, включая страницу
|
||||
// /download/{id} в любом состоянии: там меняется только состояние, а сборка
|
||||
// страницы считает предпросмотр раскладки и ходит в ФС.
|
||||
const (
|
||||
pollFast = "5s"
|
||||
pollSlow = "15s"
|
||||
)
|
||||
|
||||
// noLive — заглушка на случай, когда источник телеметрии не подключён
|
||||
// (Deps.Live == nil): живых данных нет, UI деградирует штатно.
|
||||
type noLive struct{}
|
||||
|
||||
func (noLive) Live(string) (worker.Live, bool) { return worker.Live{}, false }
|
||||
|
||||
// progressView — живой прогресс активной загрузки (для карточки и фрагмента
|
||||
// /progress). Active управляется store-состоянием (downloading), а не qbt:
|
||||
// когда задача покидает downloading, фрагмент возвращается без поллинга.
|
||||
// progressView — живой прогресс активной загрузки (вложенный блок карточки).
|
||||
// Active управляется store-состоянием (downloading), а не qbt: вне downloading
|
||||
// скорость и ETA смысла не имеют, и блок не рисуется. Своего опроса блок не
|
||||
// ведёт — цифры приезжают с тиком карточки (web-ui, «Самообновление живой
|
||||
// задачи»).
|
||||
type progressView struct {
|
||||
ID string
|
||||
Active bool // store-состояние downloading → показываем бар и поллим
|
||||
Active bool // store-состояние downloading → показываем бар
|
||||
Has bool // есть данные снимка
|
||||
Percent int
|
||||
DlSpeed string
|
||||
ETA string
|
||||
}
|
||||
|
||||
// seedingView — живая статистика раздачи (для страницы и фрагмента /seeding).
|
||||
// seedingView — живая статистика раздачи (секция страницы загрузки).
|
||||
// Has истинно только если торрент сидирует и данные есть — иначе секция
|
||||
// деградирует (пустой контейнер, поллинг прекращается).
|
||||
// деградирует (пустой контейнер). Своего опроса секция не ведёт: она лежит
|
||||
// внутри свопаемой области страницы, и её цифры приезжают с тиком страницы.
|
||||
type seedingView struct {
|
||||
ID string
|
||||
Has bool
|
||||
@@ -79,7 +95,13 @@ func buildSeeding(id string, l worker.Live, ok bool) seedingView {
|
||||
return v
|
||||
}
|
||||
|
||||
// handleFragProgress отдаёт партиал живого прогресса карточки (htmx-поллинг).
|
||||
// handleFragProgress отдаёт партиал живого прогресса карточки.
|
||||
//
|
||||
// Потребителя в новой разметке у маршрута нет: блок прогресса едет с тиком
|
||||
// карточки. Маршрут оставлен гасителем вкладок, отрисованных прошлой версией:
|
||||
// htmx не свопит 4xx/5xx и не снимает hx-trigger, поэтому удалённый маршрут
|
||||
// заставил бы старую вкладку стучать бесконечно, а партиал без поллинга гасит
|
||||
// её первым же тиком. Убирается отдельной уборкой после деплоя.
|
||||
func (s *server) handleFragProgress(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := pathID(r)
|
||||
if err != nil {
|
||||
@@ -88,7 +110,7 @@ func (s *server) handleFragProgress(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
d, err := s.deps.Reader.GetDownload(r.Context(), id)
|
||||
if err != nil {
|
||||
s.fragErr(w, err, id)
|
||||
s.fragTickErr(w, err, id, "dl-live-"+id)
|
||||
return
|
||||
}
|
||||
active := d.State == store.StateDownloading
|
||||
@@ -96,10 +118,11 @@ func (s *server) handleFragProgress(w http.ResponseWriter, r *http.Request) {
|
||||
s.render(w, "progress", buildProgress(id, active, l, ok))
|
||||
}
|
||||
|
||||
// handleFragCard отдаёт карточку списка целиком (htmx-самополлинг catched):
|
||||
// пока загрузка в catched, карточка опрашивает себя и по переходе в downloading
|
||||
// приносит обновлённый бейдж/имя и прогресс-поллер; выйдя из catched, свежая
|
||||
// карточка уже не несёт самополлинга — цикл завершается сам.
|
||||
// handleFragCard отдаёт карточку списка целиком — это тик её самообновления.
|
||||
// Пока задача наблюдаема (State.IsObservable), карточка опрашивает себя и на
|
||||
// каждом тике приносит текущее состояние целиком: бейдж, заголовок, набор
|
||||
// действий и живые цифры. Перестала быть наблюдаемой — свежая карточка уже не
|
||||
// несёт самополлинга, и цикл завершается сам.
|
||||
func (s *server) handleFragCard(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := pathID(r)
|
||||
if err != nil {
|
||||
@@ -108,15 +131,25 @@ func (s *server) handleFragCard(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
d, err := s.deps.Reader.GetDownload(r.Context(), id)
|
||||
if err != nil {
|
||||
s.fragErr(w, err, id)
|
||||
s.fragTickErr(w, err, id, "card-"+id)
|
||||
return
|
||||
}
|
||||
// layoutSize 0: у catched раскладки нет; в downloading размер берётся из
|
||||
// живого снимка внутри buildCardView.
|
||||
s.render(w, "card", s.buildCardView(*d, store.Now(), 0))
|
||||
// Размер читаем так же, как своповый путь действия: самообновление
|
||||
// обслуживает и состояния с разложенными файлами, и подмена известного
|
||||
// размера прочерком была бы потерей поля полного рендера.
|
||||
sizes, err := s.deps.Reader.LayoutSizeByDownload(r.Context(), []string{id})
|
||||
if err != nil {
|
||||
// WARN, а не ERROR: тик повторится сам (docs/conventions/logging.md).
|
||||
s.deps.Logger.Warn("layout sizes", "download_id", id, "error", err)
|
||||
sizes = nil // деградируем: размер уедет в фолбэк, тик не падает
|
||||
}
|
||||
s.render(w, "card", s.buildCardView(*d, store.Now(), sizes[id]))
|
||||
}
|
||||
|
||||
// handleFragSeeding отдаёт партиал секции «Раздача» (htmx-поллинг).
|
||||
// handleFragSeeding отдаёт партиал секции «Раздача».
|
||||
//
|
||||
// Как и у прогресса, потребителя в новой разметке нет: секция едет с тиком
|
||||
// страницы. Маршрут оставлен гасителем старых вкладок — см. handleFragProgress.
|
||||
func (s *server) handleFragSeeding(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := pathID(r)
|
||||
if err != nil {
|
||||
@@ -125,22 +158,57 @@ func (s *server) handleFragSeeding(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
d, err := s.deps.Reader.GetDownload(r.Context(), id)
|
||||
if err != nil {
|
||||
s.fragErr(w, err, id)
|
||||
s.fragTickErr(w, err, id, "seeding-"+id)
|
||||
return
|
||||
}
|
||||
l, ok := s.liveFor(*d)
|
||||
s.render(w, "seeding", buildSeeding(id, l, ok))
|
||||
}
|
||||
|
||||
// fragErr транслирует ошибку чтения задачи для фрагмент-роутов: ErrNotFound →
|
||||
// 404, прочее → 500 (полная ошибка уже залогирована на доменной границе).
|
||||
func (s *server) fragErr(w http.ResponseWriter, err error, id string) {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
http.Error(w, "не найдено", http.StatusNotFound)
|
||||
return
|
||||
// fragTickErr — отказ чтения на повторяющемся тике самообновления: 200 и
|
||||
// фрагмент, который объясняет положение дел и НЕ несёт самообновления.
|
||||
//
|
||||
// Статусом ошибки отвечать нельзя: htmx не свопит DOM на 4xx/5xx, поэтому
|
||||
// поверхность осталась бы прежней навсегда (человек не отличит «ничего не
|
||||
// изменилось» от «сервер не отвечает»), а её опрос продолжался бы бесконечно —
|
||||
// при затяжном отказе хранилища это поток записей в журнал с каждой открытой
|
||||
// вкладки. Фрагмент без hx-* завершает цикл сам (web-ui, «Самообновление живой
|
||||
// задачи»).
|
||||
//
|
||||
// Уровень WARN, а не ERROR: у тика есть штатный ретрай — следующий тик повторит
|
||||
// (docs/conventions/logging.md, «Ошибки»).
|
||||
func (s *server) fragTickErr(w http.ResponseWriter, err error, id, rootID string) {
|
||||
s.fragNote(w, err, id, rootID, false)
|
||||
}
|
||||
|
||||
// fragActionErr — отказ чтения на разовом действии человека: тот же
|
||||
// самозавершающийся фрагмент, но ERROR: ретрая у действия нет.
|
||||
func (s *server) fragActionErr(w http.ResponseWriter, err error, id, rootID string) {
|
||||
s.fragNote(w, err, id, rootID, true)
|
||||
}
|
||||
|
||||
// fragNote отдаёт фрагмент отказа с корнем rootID. Корень обязателен и
|
||||
// приходит от вызывающего: htmx свопит outerHTML, и фрагмент без целевого id
|
||||
// снёс бы узел вместе с якорем — следующее действие и поллер цели не нашли бы
|
||||
// (docs/conventions/web-ui.md, «Единый источник разметки»).
|
||||
func (s *server) fragNote(w http.ResponseWriter, err error, id, rootID string, oneShot bool) {
|
||||
text := "задача не найдена — обновите страницу"
|
||||
if !errors.Is(err, store.ErrNotFound) {
|
||||
if oneShot {
|
||||
s.deps.Logger.Error("live fragment", "download_id", id, "error", err)
|
||||
} else {
|
||||
s.deps.Logger.Warn("live fragment", "download_id", id, "error", err)
|
||||
}
|
||||
text = "не удалось обновить — обновите страницу"
|
||||
}
|
||||
s.deps.Logger.Error("live fragment", "download_id", id, "error", err)
|
||||
http.Error(w, "внутренняя ошибка", http.StatusInternalServerError)
|
||||
s.render(w, "frag_note", fragNoteView{RootID: rootID, Text: text})
|
||||
}
|
||||
|
||||
// fragNoteView — самозавершающийся фрагмент отказа (см. fragNote). RootID —
|
||||
// id узла, который фрагмент собой заменяет.
|
||||
type fragNoteView struct {
|
||||
RootID string
|
||||
Text string
|
||||
}
|
||||
|
||||
// --- форматирование телеметрии ---
|
||||
|
||||
+246
-17
@@ -1,6 +1,7 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -9,8 +10,8 @@ import (
|
||||
"git.vakhrushev.me/av/jellybit/internal/worker"
|
||||
)
|
||||
|
||||
// TestFragProgressDownloading: активная задача → фрагмент с прогрессом,
|
||||
// значениями снимка и атрибутами htmx-поллинга.
|
||||
// TestFragProgressDownloading: маршрут прогресса остался гасителем старых
|
||||
// вкладок — отдаёт цифры снимка и НЕ несёт собственного опроса.
|
||||
func TestFragProgressDownloading(t *testing.T) {
|
||||
dl := store.Download{ID: testULID, Infohashes: []store.Infohash{{DownloadID: testULID, Infohash: "ih5", Kind: store.HashV1}}, State: store.StateDownloading}
|
||||
lv := stubLive{m: map[string]worker.Live{"ih5": {Progress: 0.42, DlSpeed: 6400000, ETA: 720}}}
|
||||
@@ -21,25 +22,80 @@ func TestFragProgressDownloading(t *testing.T) {
|
||||
t.Fatalf("status = %d, want 200", rr.Code)
|
||||
}
|
||||
body := rr.Body.String()
|
||||
for _, want := range []string{`hx-trigger="every 3s"`, "/fragments/downloads/" + testULID + "/progress", "width:42%", "42%"} {
|
||||
for _, want := range []string{"width:42%", "42%"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("фрагмент прогресса не содержит %q\n%s", want, body)
|
||||
}
|
||||
}
|
||||
if strings.Contains(body, "hx-trigger") {
|
||||
t.Errorf("партиал прогресса всё ещё опрашивает сервер сам:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFragProgressStopsWhenNotDownloading: когда задача покинула downloading,
|
||||
// фрагмент отдаётся без атрибутов поллинга (поллинг прекращается).
|
||||
func TestFragProgressStopsWhenNotDownloading(t *testing.T) {
|
||||
// TestProgressBlockHiddenOutsideDownloading: вне downloading блок живых цифр не
|
||||
// рисуется вовсе — скорость и ETA там смысла не имеют. Проверка на отсутствие
|
||||
// hx-trigger сюда не годится: партиал не несёт его ни при каком входе, и такой
|
||||
// тест был бы зелёным независимо от логики.
|
||||
func TestProgressBlockHiddenOutsideDownloading(t *testing.T) {
|
||||
dl := store.Download{ID: testULID, Infohashes: []store.Infohash{{DownloadID: testULID, Infohash: "ih5", Kind: store.HashV1}}, State: store.StateDone}
|
||||
h := testRouterLive(t, stubReader{one: &dl}, stubReviewer{}, stubLive{})
|
||||
lv := stubLive{m: map[string]worker.Live{"ih5": {Progress: 0.9, DlSpeed: 6400000, ETA: 720}}}
|
||||
h := testRouterLive(t, stubReader{one: &dl}, stubReviewer{}, lv)
|
||||
|
||||
rr := get(t, h, "/fragments/downloads/"+testULID+"/progress")
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rr.Code)
|
||||
}
|
||||
if body := rr.Body.String(); strings.Contains(body, "hx-trigger") {
|
||||
t.Errorf("завершённая задача всё ещё поллит:\n%s", body)
|
||||
body := rr.Body.String()
|
||||
for _, unwanted := range []string{`class="progress"`, "dl-stats", "90%"} {
|
||||
if strings.Contains(body, unwanted) {
|
||||
t.Errorf("вне downloading блок цифр не должен рисоваться, есть %q:\n%s", unwanted, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestFragErrKeepsSwapRoot: фрагмент отказа несёт корневой id того узла, который
|
||||
// он собой заменяет. Иначе своп уносит якорь поверхности: экран ревью или
|
||||
// страница загрузки теряют цель для всех своих действий и мертвы до перезагрузки
|
||||
// (docs/conventions/web-ui.md, «Единый источник разметки»).
|
||||
func TestFragErrKeepsSwapRoot(t *testing.T) {
|
||||
cases := []struct{ path, root string }{
|
||||
{"/fragments/downloads/" + testULID + "/card", `id="card-` + testULID + `"`},
|
||||
{"/fragments/downloads/" + testULID + "/progress", `id="dl-live-` + testULID + `"`},
|
||||
{"/fragments/downloads/" + testULID + "/seeding", `id="seeding-` + testULID + `"`},
|
||||
{"/fragments/downloads/" + testULID + "/review", `id="review-main"`},
|
||||
}
|
||||
h := testRouterLive(t, stubReader{getErr: errors.New("db is gone")}, stubReviewer{}, stubLive{})
|
||||
for _, c := range cases {
|
||||
rr := get(t, h, c.path)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Errorf("%s: status = %d, want 200", c.path, rr.Code)
|
||||
continue
|
||||
}
|
||||
if body := rr.Body.String(); !strings.Contains(body, c.root) {
|
||||
t.Errorf("%s: фрагмент отказа без корня %s:\n%s", c.path, c.root, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestPageTickFailureSelfTerminates: тик страницы идёт тем же маршрутом, что и
|
||||
// навигация, поэтому отказ на htmx-пути обязан отвечать 200 и фрагментом с
|
||||
// корнем #download-main без hx-*; навигационный GET по-прежнему получает статус.
|
||||
func TestPageTickFailureSelfTerminates(t *testing.T) {
|
||||
h := testRouterLive(t, stubReader{}, stubReviewer{}, stubLive{})
|
||||
|
||||
rr := getHTMX(t, h, "/download/"+testULID)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("тик страницы: status = %d, want 200", rr.Code)
|
||||
}
|
||||
body := rr.Body.String()
|
||||
if !strings.Contains(body, `id="download-main"`) {
|
||||
t.Errorf("фрагмент отказа страницы без корня #download-main:\n%s", body)
|
||||
}
|
||||
if strings.Contains(body, "hx-trigger") {
|
||||
t.Errorf("фрагмент отказа страницы не самозавершается:\n%s", body)
|
||||
}
|
||||
if rr := get(t, h, "/download/"+testULID); rr.Code != http.StatusNotFound {
|
||||
t.Errorf("навигационный GET: status = %d, want 404", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,11 +113,15 @@ func TestFragSeeding(t *testing.T) {
|
||||
t.Fatalf("status = %d, want 200", rr.Code)
|
||||
}
|
||||
body := rr.Body.String()
|
||||
for _, want := range []string{"Раздача", "2.41", "38 / 14", `hx-trigger="every 3s"`} {
|
||||
for _, want := range []string{"Раздача", "2.41", "38 / 14"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("фрагмент раздачи не содержит %q\n%s", want, body)
|
||||
}
|
||||
}
|
||||
// Секция лежит внутри свопаемой области страницы — своего опроса не ведёт.
|
||||
if strings.Contains(body, "hx-trigger") {
|
||||
t.Errorf("секция раздачи всё ещё опрашивает сервер сама:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFragSeedingDegrades: нет живых данных → секция отсутствует, поллинга нет.
|
||||
@@ -80,7 +140,8 @@ func TestFragSeedingDegrades(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestIndexCardShowsLiveProgress: активная карточка в списке несёт прогресс уже
|
||||
// в первом кадре (значения снимка) и атрибуты поллинга.
|
||||
// в первом кадре (значения снимка), а опрашивает себя сама карточка — один
|
||||
// поллер на поверхность, во вложенном блоке прогресса его нет.
|
||||
func TestIndexCardShowsLiveProgress(t *testing.T) {
|
||||
dl := store.Download{ID: testULID, SourceRef: "The.Bear.S03", Infohashes: []store.Infohash{{DownloadID: testULID, Infohash: "ih3", Kind: store.HashV1}}, State: store.StateDownloading}
|
||||
lv := stubLive{m: map[string]worker.Live{"ih3": {Progress: 0.46, DlSpeed: 6400000, ETA: 720}}}
|
||||
@@ -91,21 +152,189 @@ func TestIndexCardShowsLiveProgress(t *testing.T) {
|
||||
t.Fatalf("status = %d, want 200", rr.Code)
|
||||
}
|
||||
body := rr.Body.String()
|
||||
for _, want := range []string{`class="progress"`, "width:46%", "/fragments/downloads/" + testULID + "/progress"} {
|
||||
for _, want := range []string{`class="progress"`, "width:46%", "/fragments/downloads/" + testULID + "/card"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("карточка без живого прогресса: нет %q", want)
|
||||
}
|
||||
}
|
||||
if strings.Contains(body, "/fragments/downloads/"+testULID+"/progress") {
|
||||
t.Errorf("вложенный блок прогресса опрашивает себя сам:\n%s", body)
|
||||
}
|
||||
if n := strings.Count(body, `hx-trigger="every`); n != 1 {
|
||||
t.Errorf("объявлений самообновления на карточке = %d, want 1\n%s", n, body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFragNotFound: фрагмент несуществующей задачи → 404.
|
||||
func TestFragNotFound(t *testing.T) {
|
||||
// TestFragTickOnMissingDownload: тик по исчезнувшей задаче отвечает 200 и
|
||||
// фрагментом без hx-* — htmx не свопит 4xx/5xx, поэтому отказ статусом оставил
|
||||
// бы карточку прежней навсегда, а опрос — бесконечным.
|
||||
func TestFragTickOnMissingDownload(t *testing.T) {
|
||||
h := testRouterLive(t, stubReader{}, stubReviewer{}, stubLive{})
|
||||
if rr := get(t, h, "/fragments/downloads/01arz3ndektsv4rrffq69g5fff/progress"); rr.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404", rr.Code)
|
||||
|
||||
rr := get(t, h, "/fragments/downloads/01arz3ndektsv4rrffq69g5fff/card")
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rr.Code)
|
||||
}
|
||||
// Невалидный id → 404 без похода в БД.
|
||||
body := rr.Body.String()
|
||||
if !strings.Contains(body, "не найдена") {
|
||||
t.Errorf("фрагмент не объясняет отказ тика:\n%s", body)
|
||||
}
|
||||
if strings.Contains(body, "hx-trigger") || strings.Contains(body, "hx-get") {
|
||||
t.Errorf("фрагмент отказа не самозавершается:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFragInvalidID: невалидный id → 404 без похода в БД (это не тик живой
|
||||
// поверхности, а запрос по несуществующему адресу).
|
||||
func TestFragInvalidID(t *testing.T) {
|
||||
h := testRouterLive(t, stubReader{}, stubReviewer{}, stubLive{})
|
||||
if rr := get(t, h, "/fragments/downloads/404/progress"); rr.Code != http.StatusNotFound {
|
||||
t.Fatalf("status(invalid id) = %d, want 404", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFragTickOnStoreFailure: отказ хранилища на тике — тоже 200 и
|
||||
// самозавершающийся фрагмент, но с другим текстом: «не найдена» здесь соврало бы.
|
||||
func TestFragTickOnStoreFailure(t *testing.T) {
|
||||
h := testRouterLive(t, stubReader{getErr: errors.New("db is gone")}, stubReviewer{}, stubLive{})
|
||||
|
||||
rr := get(t, h, "/fragments/downloads/"+testULID+"/card")
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rr.Code)
|
||||
}
|
||||
body := rr.Body.String()
|
||||
if !strings.Contains(body, "не удалось обновить") {
|
||||
t.Errorf("отказ хранилища выдан за пропажу задачи:\n%s", body)
|
||||
}
|
||||
if strings.Contains(body, "hx-trigger") {
|
||||
t.Errorf("фрагмент отказа не самозавершается:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFragCardSurvivesSizeFailure: отказ чтения размеров не роняет тик —
|
||||
// карточка деградирует на прочерк, а не на пустой ответ.
|
||||
func TestFragCardSurvivesSizeFailure(t *testing.T) {
|
||||
dl := store.Download{ID: testULID, SourceRef: "Rel", State: store.StateReview}
|
||||
rd := stubReader{one: &dl, sizesErr: errors.New("db is busy")}
|
||||
h := testRouterLive(t, rd, stubReviewer{}, stubLive{})
|
||||
|
||||
rr := get(t, h, "/fragments/downloads/"+testULID+"/card")
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rr.Code)
|
||||
}
|
||||
if body := rr.Body.String(); !strings.Contains(body, "Ревью →") {
|
||||
t.Errorf("тик не пережил отказ чтения размеров:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCardSelfPollFollowsObservability: карточка опрашивает себя, пока задача
|
||||
// наблюдаема, и замолкает, когда двигать её может только человек. failed,
|
||||
// target_missing и orphaned наблюдаются: их возвращает в поток фоновая сверка.
|
||||
func TestCardSelfPollFollowsObservability(t *testing.T) {
|
||||
polling := []store.State{
|
||||
store.StateCatched, store.StateDownloading, store.StateCompleted,
|
||||
store.StateRecognizing, store.StateReview, store.StateLinking,
|
||||
store.StateDeferred, store.StateStuck,
|
||||
store.StateFailed, store.StateTargetMissing, store.StateOrphaned,
|
||||
}
|
||||
silent := []store.State{
|
||||
store.StateDone, store.StateCancelled, store.StateReverted, store.StateDeleted,
|
||||
}
|
||||
|
||||
for _, st := range polling {
|
||||
dl := store.Download{ID: testULID, SourceRef: "Rel", State: st}
|
||||
h := testRouterLive(t, stubReader{one: &dl}, stubReviewer{}, stubLive{})
|
||||
body := get(t, h, "/fragments/downloads/"+testULID+"/card").Body.String()
|
||||
if !strings.Contains(body, "/fragments/downloads/"+testULID+"/card") {
|
||||
t.Errorf("%s: наблюдаемая карточка не опрашивает себя:\n%s", st, body)
|
||||
}
|
||||
}
|
||||
for _, st := range silent {
|
||||
dl := store.Download{ID: testULID, SourceRef: "Rel", State: st}
|
||||
h := testRouterLive(t, stubReader{one: &dl}, stubReviewer{}, stubLive{})
|
||||
body := get(t, h, "/fragments/downloads/"+testULID+"/card").Body.String()
|
||||
if strings.Contains(body, "hx-trigger") {
|
||||
t.Errorf("%s: ненаблюдаемая карточка продолжает опрос:\n%s", st, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCardPollInterval: быстрый интервал — только там, где бегут цифры качания.
|
||||
func TestCardPollInterval(t *testing.T) {
|
||||
cases := []struct {
|
||||
state store.State
|
||||
want string
|
||||
}{
|
||||
{store.StateDownloading, `hx-trigger="every ` + pollFast + `"`},
|
||||
{store.StateReview, `hx-trigger="every ` + pollSlow + `"`},
|
||||
{store.StateCatched, `hx-trigger="every ` + pollSlow + `"`},
|
||||
}
|
||||
for _, c := range cases {
|
||||
dl := store.Download{ID: testULID, SourceRef: "Rel", State: c.state}
|
||||
h := testRouterLive(t, stubReader{one: &dl}, stubReviewer{}, stubLive{})
|
||||
body := get(t, h, "/fragments/downloads/"+testULID+"/card").Body.String()
|
||||
if !strings.Contains(body, c.want) {
|
||||
t.Errorf("%s: нет %q\n%s", c.state, c.want, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestFragCardBringsNewStateAndActions: первый ответ фрагмента после смены
|
||||
// состояния приносит новый бейдж и новый набор действий — ради этого change и
|
||||
// затевался.
|
||||
func TestFragCardBringsNewStateAndActions(t *testing.T) {
|
||||
cases := []struct {
|
||||
state store.State
|
||||
want string
|
||||
}{
|
||||
{store.StateReview, "Ревью →"},
|
||||
{store.StateDone, "Откатить"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
dl := store.Download{ID: testULID, SourceRef: "Rel", State: c.state}
|
||||
h := testRouterLive(t, stubReader{one: &dl}, stubReviewer{}, stubLive{})
|
||||
body := get(t, h, "/fragments/downloads/"+testULID+"/card").Body.String()
|
||||
if !strings.Contains(body, c.want) {
|
||||
t.Errorf("%s: фрагмент не принёс действие %q\n%s", c.state, c.want, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestDownloadPageSelfPoll: страница живёт по тому же правилу наблюдаемости,
|
||||
// интервал у неё всегда медленный (блока живых цифр качания на ней нет), а
|
||||
// секция «Раздача» своего опроса не ведёт — один поллер на поверхность.
|
||||
func TestDownloadPageSelfPoll(t *testing.T) {
|
||||
seedLive := stubLive{m: map[string]worker.Live{"ihp": {Seeding: true, Progress: 1, Ratio: 2.4, Seeds: 3, Peers: 1}}}
|
||||
hashes := []store.Infohash{{DownloadID: testULID, Infohash: "ihp", Kind: store.HashV1}}
|
||||
|
||||
// Наблюдаемая задача с сидирующей раздачей: ровно одно объявление опроса.
|
||||
dl := store.Download{ID: testULID, SourceRef: "Rel", State: store.StateReview, Infohashes: hashes}
|
||||
h := testRouterLive(t, stubReader{one: &dl}, stubReviewer{data: &worker.ReviewData{Download: dl}}, seedLive)
|
||||
body := get(t, h, "/download/"+testULID).Body.String()
|
||||
if !strings.Contains(body, `hx-trigger="every `+pollSlow+`"`) {
|
||||
t.Errorf("страница наблюдаемой задачи без медленного самообновления:\n%s", body)
|
||||
}
|
||||
if n := strings.Count(body, `hx-trigger="every`); n != 1 {
|
||||
t.Errorf("объявлений самообновления на странице = %d, want 1", n)
|
||||
}
|
||||
|
||||
// Ненаблюдаемая задача: страница замолкает.
|
||||
done := store.Download{ID: testULID, SourceRef: "Rel", State: store.StateDone, Infohashes: hashes}
|
||||
h = testRouterLive(t, stubReader{one: &done}, stubReviewer{data: &worker.ReviewData{Download: done}}, seedLive)
|
||||
if body := get(t, h, "/download/"+testULID).Body.String(); strings.Contains(body, `hx-trigger="every`) {
|
||||
t.Errorf("страница ненаблюдаемой задачи продолжает опрос:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFragCardKeepsLayoutSize: самообновление не теряет полей полного рендера —
|
||||
// размер разложенных файлов при отсутствии раздачи в снимке.
|
||||
func TestFragCardKeepsLayoutSize(t *testing.T) {
|
||||
dl := store.Download{ID: testULID, SourceRef: "Rel", State: store.StateOrphaned}
|
||||
rd := stubReader{one: &dl, sizes: map[string]int64{testULID: 3 << 30}}
|
||||
h := testRouterLive(t, rd, stubReviewer{}, stubLive{})
|
||||
|
||||
body := get(t, h, "/fragments/downloads/"+testULID+"/card").Body.String()
|
||||
if !strings.Contains(body, "3.0 ГиБ") {
|
||||
t.Errorf("фрагмент карточки потерял размер раскладки:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,9 +15,11 @@ import (
|
||||
|
||||
// stubReader — минимальный Reader для проверки рендера списка.
|
||||
type stubReader struct {
|
||||
list []store.Download
|
||||
one *store.Download
|
||||
sizes map[string]int64 // размеры разложенных файлов по download_id (фолбэк)
|
||||
list []store.Download
|
||||
one *store.Download
|
||||
sizes map[string]int64 // размеры разложенных файлов по download_id (фолбэк)
|
||||
getErr error // отказ чтения задачи (не ErrNotFound)
|
||||
sizesErr error // отказ чтения размеров раскладки
|
||||
}
|
||||
|
||||
func (s stubReader) ListDownloads(context.Context) ([]store.Download, error) { return s.list, nil }
|
||||
@@ -25,12 +27,18 @@ func (s stubReader) ListDownloadsPage(context.Context, store.ListFilter) ([]stor
|
||||
return s.list, len(s.list), nil
|
||||
}
|
||||
func (s stubReader) GetDownload(context.Context, string) (*store.Download, error) {
|
||||
if s.getErr != nil {
|
||||
return nil, s.getErr
|
||||
}
|
||||
if s.one == nil {
|
||||
return nil, store.ErrNotFound
|
||||
}
|
||||
return s.one, nil
|
||||
}
|
||||
func (s stubReader) LayoutSizeByDownload(context.Context, []string) (map[string]int64, error) {
|
||||
if s.sizesErr != nil {
|
||||
return nil, s.sizesErr
|
||||
}
|
||||
return s.sizes, nil
|
||||
}
|
||||
|
||||
@@ -94,6 +102,16 @@ func get(t *testing.T, h http.Handler, path string) *httptest.ResponseRecorder {
|
||||
return rr
|
||||
}
|
||||
|
||||
// getHTMX — тот же GET, но помеченный как htmx-запрос (тик самообновления).
|
||||
func getHTMX(t *testing.T, h http.Handler, path string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
rr := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
req.Header.Set("HX-Request", "true")
|
||||
h.ServeHTTP(rr, req)
|
||||
return rr
|
||||
}
|
||||
|
||||
// testULID — валидный lowercase-ULID для маршрутов (pathID валидирует формат).
|
||||
const testULID = "01arz3ndektsv4rrffq69g5fav"
|
||||
|
||||
|
||||
@@ -455,7 +455,7 @@ func (s *server) handleFragReview(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
rd, err := s.deps.Reviewer.ReviewData(r.Context(), id)
|
||||
if err != nil {
|
||||
s.fragErr(w, err, id)
|
||||
s.fragTickErr(w, err, id, "review-main")
|
||||
return
|
||||
}
|
||||
s.render(w, "review_main", buildReviewView(id, rd, ""))
|
||||
|
||||
Reference in New Issue
Block a user