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:
+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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user