Ревью: единый блок выбора источника, клик = выбор (review-unified-source-block)

Три секции экрана ревью (Догадка/Источник/Раскладка) слиты в один блок:
список вариантов (радио) → инфо о выбранном → предпросмотр раскладки. Клик
по варианту сразу выбирает и сохраняет источник и обновляет инфо+раскладку
частичным htmx-свопом блока, без полной перезагрузки и без кнопки «выбрать».

- httpapi: reviewBlockAction (htmx-aware, детект HX-Request) для
  candidate/nobase/source; вынос buildReviewView; поля SeasonSummary и
  BlockError; сводка сезонов (seasonSummary/seasonRanges)
- тип movie↔series убран из UI (read-only); удалён веб-роут /type и
  handleSetType, метод SetType из интерфейса httpapi (worker/Telegram не тронуты)
- шаблон: партиал review_source_block, ссылка «запись ↗» вне кликабельного
  label, фокус радио с клавиатуры; чистка мёртвого sourceView.Files/IsSeries
- тесты: htmx-своп выбора, htmx-путь ошибки, юнит-тесты сводки сезонов
- openspec: спеки review/web-ui синхронизированы, change заархивирован
- беклог: сложные сериальные раздачи; oob-обновление панели действий

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
av
2026-07-04 08:12:28 +03:00
co-authored by Claude Opus 4.8
parent 2ed9c9020f
commit 1f4267a046
17 changed files with 942 additions and 157 deletions
+53
View File
@@ -0,0 +1,53 @@
package httpapi
import (
"testing"
"git.vakhrushev.me/av/jellybit/internal/recognize"
)
func TestSeasonSummary(t *testing.T) {
// ep — эпизодный файл с заданным (или nil) сезоном.
ep := func(season *int) recognize.PlanFile {
return recognize.PlanFile{Role: recognize.RoleEpisode, Season: season}
}
n := func(v int) *int { return &v }
cases := []struct {
name string
files []recognize.PlanFile
want string
}{
{"пусто", nil, ""},
{"один сезон", []recognize.PlanFile{ep(n(2)), ep(n(2))}, "Сезон 2"},
{"диапазон", []recognize.PlanFile{ep(n(1)), ep(n(2)), ep(n(3))}, "Сезоны 13"},
{"разрыв", []recognize.PlanFile{ep(n(1)), ep(n(3)), ep(n(4))}, "Сезоны 1, 34"},
{"несортированный вход", []recognize.PlanFile{ep(n(3)), ep(n(1)), ep(n(2))}, "Сезоны 13"},
{"только спецвыпуски (nil)", []recognize.PlanFile{ep(nil)}, "Спецвыпуски"},
{"только спецвыпуски (0)", []recognize.PlanFile{ep(n(0))}, "Спецвыпуски"},
{"сезоны и спецвыпуски", []recognize.PlanFile{ep(n(1)), ep(n(2)), ep(nil)}, "Сезоны 1–2, спецвыпуски"},
{"один сезон и спецвыпуски", []recognize.PlanFile{ep(n(1)), ep(nil)}, "Сезон 1, спецвыпуски"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := seasonSummary(recognize.Plan{Files: c.files})
if got != c.want {
t.Errorf("seasonSummary = %q, want %q", got, c.want)
}
})
}
}
// Не-эпизодные файлы (main/subtitle/…) не влияют на сводку сезонов.
func TestSeasonSummary_IgnoresNonEpisodes(t *testing.T) {
s := 2
plan := recognize.Plan{Files: []recognize.PlanFile{
{Role: recognize.RoleEpisode, Season: &s},
{Role: recognize.RoleMain},
{Role: recognize.RoleSubtitle},
{Role: recognize.RoleIgnore},
}}
if got := seasonSummary(plan); got != "Сезон 2" {
t.Errorf("seasonSummary = %q, want «Сезон 2»", got)
}
}