Ревью: oob-обновление панели действий при свопе источника

Кнопка «Применить» (завязана на HasLinks) лежит вне #source-block и не
обновлялась при htmx-свопе выбора источника — в краевом случае (пустой
предпросмотр из-за коллизии) рассинхронивалась с превью до полной
перезагрузки. Выделил панель в партиал review_action_bar с id=action-bar;
reviewBlockAction теперь рендерит review_source_swap — свежий #source-block
плюс oob-копию панели (hx-swap-oob), так кнопка синхронно отражает HasLinks.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
av
2026-07-11 09:28:45 +03:00
co-authored by Claude Opus 4.8
parent e8ce828296
commit 847d471852
4 changed files with 96 additions and 10 deletions
+66
View File
@@ -10,6 +10,8 @@ import (
"strings"
"testing"
"git.vakhrushev.me/av/jellybit/internal/layout"
"git.vakhrushev.me/av/jellybit/internal/recognize"
"git.vakhrushev.me/av/jellybit/internal/store"
"git.vakhrushev.me/av/jellybit/internal/worker"
)
@@ -272,6 +274,70 @@ func TestActionErrorDownloadSurface(t *testing.T) {
}
}
// reviewDataWithSource собирает ReviewData ревью с одним нейронка-источником;
// withLinks управляет наличием превью раскладки (HasLinks).
func reviewDataWithSource(withLinks bool) *worker.ReviewData {
dl := dlState(store.StateReview)
plan := recognize.Plan{Type: "movie", Title: "Fargo", Year: 1996,
Files: []recognize.PlanFile{{Src: "fargo.mkv"}}}
rd := &worker.ReviewData{
Download: dl,
Recognition: &store.Recognition{},
Plan: plan,
Sources: []worker.SourceOption{
{Kind: worker.SourceNeural, Provider: "none", Title: "Fargo", Year: 1996, Active: true},
},
}
if withLinks {
rd.Preview = []layout.Link{{Src: "fargo.mkv", Dst: "/movies/Fargo (1996)/fargo.mkv"}}
}
return rd
}
// TestSourceSwapUpdatesActionBarOOB: своп выбора источника отдаёт свежий
// #source-block и oob-обновление панели действий (#action-bar с hx-swap-oob),
// чтобы кнопка «Применить» синхронно отражала актуальный HasLinks.
func TestSourceSwapUpdatesActionBarOOB(t *testing.T) {
t.Run("с превью → есть кнопка Применить", func(t *testing.T) {
rd := reviewDataWithSource(true)
rv := actionReviewer{stubReviewer: stubReviewer{data: rd}}
h := testRouterAction(t, stubReader{one: &rd.Download}, rv, stubCommander{}, stubLive{})
rr := post(t, h, "/ui/downloads/"+testULID+"/nobase", nil, true)
if rr.Code != http.StatusOK {
t.Fatalf("nobase (htmx) = %d, want 200", rr.Code)
}
body := rr.Body.String()
if !strings.Contains(body, `id="source-block"`) {
t.Errorf("ответ без свежего #source-block: %s", body)
}
if !strings.Contains(body, `id="action-bar"`) || !strings.Contains(body, `hx-swap-oob="true"`) {
t.Errorf("ответ без oob-панели действий: %s", body)
}
if !strings.Contains(body, "/apply") {
t.Errorf("HasLinks=true, но в панели нет кнопки «Применить»: %s", body)
}
})
t.Run("без превью → кнопки Применить нет", func(t *testing.T) {
rd := reviewDataWithSource(false)
rv := actionReviewer{stubReviewer: stubReviewer{data: rd}}
h := testRouterAction(t, stubReader{one: &rd.Download}, rv, stubCommander{}, stubLive{})
rr := post(t, h, "/ui/downloads/"+testULID+"/nobase", nil, true)
if rr.Code != http.StatusOK {
t.Fatalf("nobase (htmx) = %d, want 200", rr.Code)
}
body := rr.Body.String()
if !strings.Contains(body, `id="action-bar"`) || !strings.Contains(body, `hx-swap-oob="true"`) {
t.Errorf("ответ без oob-панели действий: %s", body)
}
if strings.Contains(body, "/apply") {
t.Errorf("HasLinks=false, но в панели осталась кнопка «Применить»: %s", body)
}
})
}
// TestRetryListShowsProgress: retry из списка → карточка downloading с
// прогресс-поллером.
func TestRetryListShowsProgress(t *testing.T) {