web-ui: добавлена страница группового удаления загрузок

- выбор → поимённое подтверждение → отчёт: пачка до 20 загрузок, гарды входа
  на обеих границах, потолок времени и остановка после трёх подряд отказов
  внешнего сервиса
- допуск полного удаления сведён в единую точку store.State.CanDelete() —
  worker, страница загрузки и Telegram больше не держат своих перечней
This commit is contained in:
av
2026-08-10 17:43:36 +03:00
parent a7c1efd8eb
commit 288be8ec34
35 changed files with 3185 additions and 31 deletions
+611
View File
@@ -0,0 +1,611 @@
package httpapi
import (
"context"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"strings"
"testing"
"time"
"git.vakhrushev.me/av/jellybit/internal/store"
"git.vakhrushev.me/av/jellybit/internal/worker"
)
// Валидные lowercase-ULID для форм (parseBulkBatch прогоняет через ident.Parse).
const (
bid1 = "01arz3ndektsv4rrffq69g5fa1"
bid2 = "01arz3ndektsv4rrffq69g5fa2"
bid3 = "01arz3ndektsv4rrffq69g5fa3"
bid4 = "01arz3ndektsv4rrffq69g5fa4"
)
// bulkReviewer — Reviewer, который считает вызовы удаления и умеет отказать по
// конкретному идентификатору. Указатель: тест проверяет «ни одного вызова», а
// значение-копия этого не покажет.
type bulkReviewer struct {
stubReviewer
calls *[]string
errs map[string]error
allErr error // отказ по любому идентификатору (лежащий внешний сервис)
sleep time.Duration // медленный, но исправный сосед
}
func (b bulkReviewer) Delete(ctx context.Context, id string) error {
*b.calls = append(*b.calls, id)
// Контекст исполнения обязан пережить отмену запроса: необратимую операцию
// нельзя обрывать на середине. Проверяем во всех тестах, а не только в том,
// что об этом, — гарантия одна на все пути.
if err := ctx.Err(); err != nil {
return fmt.Errorf("bulk delete stub: %w", err)
}
time.Sleep(b.sleep)
if b.allErr != nil {
return b.allErr
}
return b.errs[id]
}
func newBulkReviewer(errs map[string]error) (bulkReviewer, *[]string) {
calls := &[]string{}
return bulkReviewer{calls: calls, errs: errs}, calls
}
func dl(id string, st store.State, title string) store.Download {
return store.Download{ID: id, State: st, DisplayName: title}
}
// byID собирает карту для поштучного чтения на экранах подтверждения и отчёта.
func byID(ds ...store.Download) map[string]store.Download {
m := make(map[string]store.Download, len(ds))
for _, d := range ds {
m[d.ID] = d
}
return m
}
// postCancelled отправляет POST с уже отменённым контекстом запроса — так
// выглядит закрытая вкладка или оборванная связь на середине пачки.
func postCancelled(t *testing.T, h http.Handler, path string, form url.Values) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
ctx, cancel := context.WithCancel(req.Context())
req = req.WithContext(ctx)
cancel()
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
return rr
}
func idForm(ids ...string) url.Values {
f := url.Values{}
for _, id := range ids {
f.Add("id", id)
}
return f
}
// П1: страница показывает строки только тех загрузок, для которых удаление
// разрешено поштучно. Читатель отдаёт задачи во всех состояниях — на странице
// оказываются ровно разрешённые.
func TestBulkDeletePageShowsOnlyDeletable(t *testing.T) {
all := []store.State{
store.StateCatched, store.StateDownloading, store.StateCompleted,
store.StateRecognizing, store.StateReview, store.StateLinking,
store.StateDone, store.StateDeferred, store.StateStuck,
store.StateFailed, store.StateCancelled, store.StateReverted,
store.StateTargetMissing, store.StateOrphaned, store.StateDeleted,
}
// Читатель отдаёт всё подряд — отбирает страница по домену, а не тест.
var rows []store.Download
for i, st := range all {
if st.CanDelete() {
rows = append(rows, dl(fmt.Sprintf("%026d", i), st, "задача "+string(st)))
}
}
rv, _ := newBulkReviewer(nil)
h := testRouterAction(t, stubReader{deletable: rows}, rv, stubCommander{}, stubLive{})
body := get(t, h, "/delete").Body.String()
for _, st := range all {
marker := "задача " + string(st)
if got := strings.Contains(body, marker); got != st.CanDelete() {
t.Errorf("%s: строка на странице=%v, CanDelete=%v", st, got, st.CanDelete())
}
}
}
// Страница не опрашивает сервер: своп разметки стёр бы отметки, и человек
// подтвердил бы необратимое удаление по выбору, которого уже не видит.
func TestBulkDeletePageDoesNotSelfPoll(t *testing.T) {
rv, _ := newBulkReviewer(nil)
h := testRouterAction(t,
stubReader{deletable: []store.Download{dl(bid1, store.StateDone, "Дюна")}},
rv, stubCommander{}, stubLive{})
body := get(t, h, "/delete").Body.String()
if strings.Contains(body, `hx-trigger="every`) {
t.Error("страница выбора не должна самообновляться")
}
// Предел пачки назван до отправки — иначе отказ по нему отнимает работу.
if !strings.Contains(body, strconv.Itoa(maxBulkDelete)) {
t.Errorf("предел пачки не назван на странице:\n%s", body)
}
}
// Пустое состояние: разрешённых нет — удаление не предлагается.
func TestBulkDeletePageEmpty(t *testing.T) {
rv, _ := newBulkReviewer(nil)
h := testRouterAction(t, stubReader{}, rv, stubCommander{}, stubLive{})
body := get(t, h, "/delete").Body.String()
if strings.Contains(body, `action="/ui/delete/confirm"`) {
t.Error("на пустой странице не должно быть формы удаления")
}
if !strings.Contains(body, "Удалять нечего") {
t.Errorf("нет пустого состояния:\n%s", body)
}
}
// П2 (первая половина): подтверждение называет каждую выбранную поимённо и не
// делает ни одного вызова удаления.
func TestBulkConfirmNamesRowsAndDeletesNothing(t *testing.T) {
rv, calls := newBulkReviewer(nil)
h := testRouterAction(t, stubReader{byID: byID(
dl(bid1, store.StateDone, "Дюна"),
dl(bid2, store.StateOrphaned, "Фарго"),
)}, rv, stubCommander{}, stubLive{})
body := post(t, h, "/ui/delete/confirm", idForm(bid1, bid2), false).Body.String()
for _, want := range []string{"Дюна", "Фарго", bid1, bid2} {
if !strings.Contains(body, want) {
t.Errorf("подтверждение не называет %q:\n%s", want, body)
}
}
if len(*calls) != 0 {
t.Errorf("подтверждение не должно удалять, вызовы: %v", *calls)
}
}
// Строка orphaned на подтверждении предупреждает о последней копии данных: гард
// последней копии в удалении выключен сознательно, и осведомлённость человека —
// единственный оставшийся предохранитель.
func TestBulkConfirmWarnsAboutLastCopy(t *testing.T) {
rv, _ := newBulkReviewer(nil)
h := testRouterAction(t, stubReader{byID: byID(
dl(bid1, store.StateOrphaned, "Фарго"),
)}, rv, stubCommander{}, stubLive{})
body := post(t, h, "/ui/delete/confirm", idForm(bid1), false).Body.String()
if !strings.Contains(body, "последняя копия данных") {
t.Errorf("нет предупреждения о последней копии:\n%s", body)
}
}
// П2 (вторая половина): без признака подтверждения не удаляется ничего.
func TestBulkDeleteWithoutConfirmDeletesNothing(t *testing.T) {
rv, calls := newBulkReviewer(nil)
h := testRouterAction(t, stubReader{byID: byID(
dl(bid1, store.StateDone, "Дюна"),
)}, rv, stubCommander{}, stubLive{})
body := post(t, h, "/ui/delete", idForm(bid1), false).Body.String()
if len(*calls) != 0 {
t.Errorf("без подтверждения не должно быть вызовов удаления: %v", *calls)
}
if !strings.Contains(body, "страницы подтверждения") {
t.Errorf("отказ не объяснён:\n%s", body)
}
}
// Гарды разбора одинаковы на обеих границах: исполняющий запрос получает
// идентификаторы формой заново и на проверки подтверждения опираться не вправе.
func TestBulkBatchGuardsOnBothBoundaries(t *testing.T) {
over := make([]string, 0, maxBulkDelete+1)
for i := range maxBulkDelete + 1 {
over = append(over, fmt.Sprintf("%026d", i))
}
cases := []struct {
name string
form url.Values
want string
}{
{"неразобранный идентификатор", idForm(bid1, "не-ulid"), "некорректный идентификатор"},
{"пачка сверх предела", idForm(over...), "не больше"},
{"пустой набор", url.Values{}, "ни одна загрузка не выбрана"},
}
for _, c := range cases {
for _, path := range []string{"/ui/delete/confirm", "/ui/delete"} {
t.Run(c.name+" "+path, func(t *testing.T) {
rv, calls := newBulkReviewer(nil)
h := testRouterAction(t, stubReader{
deletable: []store.Download{dl(bid1, store.StateDone, "Дюна")},
byID: byID(dl(bid1, store.StateDone, "Дюна")),
}, rv, stubCommander{}, stubLive{})
form := url.Values{}
for k, v := range c.form {
form[k] = v
}
if path == "/ui/delete" {
form.Set("confirm", "1")
}
body := post(t, h, path, form, false).Body.String()
if len(*calls) != 0 {
t.Errorf("отказ разбора не должен удалять: %v", *calls)
}
if !strings.Contains(body, c.want) {
t.Errorf("нет объяснения %q:\n%s", c.want, body)
}
})
}
}
}
// Отказ по пределу возвращает страницу выбора с сохранёнными отметками: иначе
// проверка отнимает всю проделанную человеком работу.
func TestBulkOverLimitKeepsSelection(t *testing.T) {
rows := []store.Download{dl(bid1, store.StateDone, "Дюна")}
over := []string{bid1}
for i := range maxBulkDelete {
over = append(over, fmt.Sprintf("%026d", i))
}
rv, _ := newBulkReviewer(nil)
h := testRouterAction(t, stubReader{deletable: rows}, rv, stubCommander{}, stubLive{})
body := post(t, h, "/ui/delete/confirm", idForm(over...), false).Body.String()
if !strings.Contains(body, `value="`+bid1+`" checked`) {
t.Errorf("отметка выбора не сохранена:\n%s", body)
}
}
// Дубликаты в пачке схлопываются: повторный вызов по той же задаче дал бы
// ложный конфликт во второй строке отчёта.
func TestBulkDeleteCollapsesDuplicates(t *testing.T) {
rv, calls := newBulkReviewer(nil)
h := testRouterAction(t, stubReader{byID: byID(
dl(bid1, store.StateDone, "Дюна"),
)}, rv, stubCommander{}, stubLive{})
form := idForm(bid1, bid1, bid1)
form.Set("confirm", "1")
post(t, h, "/ui/delete", form, false)
if len(*calls) != 1 {
t.Errorf("дубликаты не схлопнуты: %v", *calls)
}
}
// П3: отказ на одной загрузке не отменяет остальных, отчёт называет обе
// половины поимённо.
func TestBulkDeletePartialFailure(t *testing.T) {
rv, calls := newBulkReviewer(map[string]error{
bid2: errors.New("qbittorrent: connection refused"),
})
h := testRouterAction(t, stubReader{byID: byID(
dl(bid1, store.StateDone, "Дюна"),
dl(bid2, store.StateDone, "Фарго"),
dl(bid3, store.StateDone, "Оппенгеймер"),
)}, rv, stubCommander{}, stubLive{})
form := idForm(bid1, bid2, bid3)
form.Set("confirm", "1")
body := post(t, h, "/ui/delete", form, false).Body.String()
if len(*calls) != 3 {
t.Fatalf("удаление должно уйти по всем трём: %v", *calls)
}
for _, want := range []string{"Дюна", "Фарго", "Оппенгеймер", "Удалено — 2", "Отказ — 1"} {
if !strings.Contains(body, want) {
t.Errorf("отчёт не называет %q:\n%s", want, body)
}
}
// Сырой текст ошибки внешнего сервиса наружу не идёт — только публичный канал.
if strings.Contains(body, "connection refused") {
t.Errorf("сырая ошибка просочилась в разметку:\n%s", body)
}
}
// П4: групповой путь прав поштучного не расширяет — недопустимое состояние
// отклоняется тем же конфликтом, остальные выбранные удаляются.
func TestBulkDeleteConflictDoesNotWidenRights(t *testing.T) {
rv, calls := newBulkReviewer(map[string]error{
bid2: fmt.Errorf("delete: download in state downloading: %w", worker.ErrConflict),
})
h := testRouterAction(t, stubReader{byID: byID(
dl(bid1, store.StateDone, "Дюна"),
dl(bid2, store.StateDownloading, "Фарго"),
dl(bid3, store.StateDone, "Оппенгеймер"),
)}, rv, stubCommander{}, stubLive{})
form := idForm(bid1, bid2, bid3)
form.Set("confirm", "1")
body := post(t, h, "/ui/delete", form, false).Body.String()
if len(*calls) != 3 {
t.Fatalf("допуск проверяет ядро — звать надо все три: %v", *calls)
}
if !strings.Contains(body, "Удалено — 2") || !strings.Contains(body, "Отказ — 1") {
t.Errorf("отчёт не разделил исходы:\n%s", body)
}
}
// Все удалены — отчёт называет обе загрузки и отказов не содержит.
func TestBulkDeleteAllSucceed(t *testing.T) {
rv, _ := newBulkReviewer(nil)
h := testRouterAction(t, stubReader{byID: byID(
dl(bid1, store.StateDone, "Дюна"),
dl(bid2, store.StateOrphaned, "Фарго"),
)}, rv, stubCommander{}, stubLive{})
form := idForm(bid1, bid2)
form.Set("confirm", "1")
body := post(t, h, "/ui/delete", form, false).Body.String()
if !strings.Contains(body, "Удалено — 2") || strings.Contains(body, "Отказ — ") {
t.Errorf("ожидались две удалённые без отказов:\n%s", body)
}
}
// Идентификатор без записи в хранилище назван строкой и на подтверждении, и в
// отчёте: молча выброшенный, он развёл бы подтверждённое с исполненным.
func TestBulkMissingDownloadIsNamed(t *testing.T) {
rv, _ := newBulkReviewer(map[string]error{
bid2: fmt.Errorf("delete: %w", store.ErrNotFound),
})
h := testRouterAction(t, stubReader{byID: byID(
dl(bid1, store.StateDone, "Дюна"),
dl(bid3, store.StateDone, "Оппенгеймер"),
)}, rv, stubCommander{}, stubLive{})
confirm := post(t, h, "/ui/delete/confirm", idForm(bid1, bid2, bid3), false).Body.String()
if !strings.Contains(confirm, bid2) || !strings.Contains(confirm, "не найдена") {
t.Errorf("подтверждение не назвало ненайденную загрузку:\n%s", confirm)
}
form := idForm(bid1, bid2, bid3)
form.Set("confirm", "1")
res := post(t, h, "/ui/delete", form, false).Body.String()
if !strings.Contains(res, bid2) {
t.Errorf("отчёт не назвал ненайденную загрузку:\n%s", res)
}
if !strings.Contains(res, "Удалено — 2") {
t.Errorf("остальные должны быть удалены:\n%s", res)
}
}
// Системный отказ останавливает пачку: удаление снимает библиотечные ссылки
// раньше, чем сносит раздачу, поэтому при лежащем qBittorrent проход без
// остановки оставил бы без раскладки все выбранные тайтлы разом.
func TestBulkDeleteStopsOnConsecutiveSystemFailures(t *testing.T) {
calls := &[]string{}
rv := bulkReviewer{calls: calls, allErr: errors.New("qbittorrent: unreachable")}
ids := []string{bid1, bid2, bid3, bid4}
h := testRouterAction(t, stubReader{byID: byID(
dl(bid1, store.StateDone, "Дюна"),
dl(bid2, store.StateDone, "Фарго"),
dl(bid3, store.StateDone, "Оппенгеймер"),
dl(bid4, store.StateDone, "Интерстеллар"),
)}, rv, stubCommander{}, stubLive{})
form := idForm(ids...)
form.Set("confirm", "1")
body := post(t, h, "/ui/delete", form, false).Body.String()
if len(*calls) != bulkFailThreshold {
t.Fatalf("проход должен остановиться после %d отказов, вызовов: %v",
bulkFailThreshold, *calls)
}
if !strings.Contains(body, "Не выполнено — 1") {
t.Errorf("остаток пачки не назван невыполненным:\n%s", body)
}
if !strings.Contains(body, "проход остановлен") {
t.Errorf("причина остановки не названа:\n%s", body)
}
}
// Одиночный отказ пачку не рвёт: счётчик подряд идущих отказов сбрасывается на
// каждом успехе, иначе случайная сетевая ошибка обрывала бы всю уборку.
func TestBulkDeleteSingleFailureDoesNotStop(t *testing.T) {
rv, calls := newBulkReviewer(map[string]error{
bid2: errors.New("qbittorrent: temporary"),
})
h := testRouterAction(t, stubReader{byID: byID(
dl(bid1, store.StateDone, "Дюна"),
dl(bid2, store.StateDone, "Фарго"),
dl(bid3, store.StateDone, "Оппенгеймер"),
dl(bid4, store.StateDone, "Интерстеллар"),
)}, rv, stubCommander{}, stubLive{})
form := idForm(bid1, bid2, bid3, bid4)
form.Set("confirm", "1")
body := post(t, h, "/ui/delete", form, false).Body.String()
if len(*calls) != 4 {
t.Fatalf("одиночный отказ не должен останавливать проход: %v", *calls)
}
if strings.Contains(body, "Не выполнено") {
t.Errorf("остановки быть не должно:\n%s", body)
}
}
// Отмена запроса не прекращает необратимую операцию: закрытая вкладка не вправе
// оборвать пачку на середине, в том числе внутри одной загрузки.
func TestBulkDeleteSurvivesRequestCancel(t *testing.T) {
calls := &[]string{}
// Reviewer, который проверяет, что контекст исполнения жив, хотя контекст
// запроса уже отменён.
rv := bulkReviewer{calls: calls, errs: map[string]error{}}
h := testRouterAction(t, stubReader{byID: byID(
dl(bid1, store.StateDone, "Дюна"),
dl(bid2, store.StateDone, "Фарго"),
)}, rv, stubCommander{}, stubLive{})
form := idForm(bid1, bid2)
form.Set("confirm", "1")
rr := postCancelled(t, h, "/ui/delete", form)
if len(*calls) != 2 {
t.Fatalf("пачка должна дойти до конца при обрыве: %v", *calls)
}
if !strings.Contains(rr.Body.String(), "Удалено — 2") {
t.Errorf("исход не собран:\n%s", rr.Body.String())
}
}
// Формы страниц удаления работают без JavaScript: обычный POST с рабочим action
// и никаких hx-атрибутов на пути к необратимому действию.
func TestBulkDeleteWorksWithoutJS(t *testing.T) {
rv, _ := newBulkReviewer(nil)
h := testRouterAction(t, stubReader{
deletable: []store.Download{dl(bid1, store.StateDone, "Дюна")},
byID: byID(dl(bid1, store.StateDone, "Дюна")),
}, rv, stubCommander{}, stubLive{})
sel := get(t, h, "/delete").Body.String()
if !strings.Contains(sel, `<form method="post" action="/ui/delete/confirm">`) {
t.Errorf("страница выбора должна нести обычную POST-форму:\n%s", sel)
}
conf := post(t, h, "/ui/delete/confirm", idForm(bid1), false).Body.String()
if !strings.Contains(conf, `action="/ui/delete"`) || !strings.Contains(conf, `name="confirm" value="1"`) {
t.Errorf("подтверждение должно нести форму исполнения:\n%s", conf)
}
for _, page := range []string{sel, conf} {
if strings.Contains(page, "hx-post") || strings.Contains(page, "hx-get") {
t.Error("страницы группового удаления не должны зависеть от htmx")
}
}
}
// Р1: исполняется ровно подтверждённое множество — список идентификаторов, а не
// предикат «всё, что сейчас разрешено». Задача, ставшая разрешённой уже после
// показа подтверждения, в пачку не попадает.
func TestBulkDeleteRunsOnlyConfirmedSet(t *testing.T) {
rv, calls := newBulkReviewer(nil)
// Читатель отдаёт две разрешённые, но подтверждена одна.
h := testRouterAction(t, stubReader{
deletable: []store.Download{
dl(bid1, store.StateDone, "Дюна"),
dl(bid2, store.StateDone, "Фарго"),
},
byID: byID(
dl(bid1, store.StateDone, "Дюна"),
dl(bid2, store.StateDone, "Фарго"),
),
}, rv, stubCommander{}, stubLive{})
form := idForm(bid1)
form.Set("confirm", "1")
post(t, h, "/ui/delete", form, false)
if len(*calls) != 1 || (*calls)[0] != bid1 {
t.Errorf("удалено не подтверждённое множество: %v", *calls)
}
}
// Ссылка на страницу группового удаления есть в шапке любой страницы веб-UI.
// Без этой проверки удаление пункта из навигации закрыло бы единственный вход
// на страницу молча: сама страница жива, тесты зелёные, попасть некуда.
func TestHeaderLinksToBulkDelete(t *testing.T) {
dl := store.Download{ID: bid1, State: store.StateReview, DisplayName: "Дюна"}
rv, _ := newBulkReviewer(nil)
h := testRouterAction(t, stubReader{list: []store.Download{dl}, one: &dl},
rv, stubCommander{}, stubLive{})
for _, path := range []string{"/", "/delete"} {
body := get(t, h, path).Body.String()
if !strings.Contains(body, `href="/delete"`) {
t.Errorf("%s: в шапке нет ссылки на групповое удаление:\n%s", path, body)
}
}
}
// Отказ хранилища не выдаётся за «записи нет»: строка говорит, что состояние
// прочитать не удалось, и отметка о последней копии не теряется молча. Иначе
// человек подтверждает пачку, где строка обещала «удалять нечего», а загрузка
// сносится с файлами по-настоящему.
func TestBulkConfirmDistinguishesReadFailure(t *testing.T) {
rv, _ := newBulkReviewer(nil)
h := testRouterAction(t, stubReader{
getErr: errors.New("database is locked"),
}, rv, stubCommander{}, stubLive{})
body := post(t, h, "/ui/delete/confirm", idForm(bid1), false).Body.String()
if strings.Contains(body, "записи нет") || strings.Contains(body, "удалять нечего") {
t.Errorf("отказ чтения выдан за отсутствие записи:\n%s", body)
}
if !strings.Contains(body, "состояние прочитать не удалось") {
t.Errorf("отказ чтения не назван строкой:\n%s", body)
}
}
// Потолок времени останавливает проход: пока идёт пачка, общий замок ядра
// удерживается на каждом обращении к qBittorrent, и медленный (но рабочий)
// сосед иначе остановил бы фон целиком, не дав ни одного отказа.
func TestBulkDeleteStopsOnTimeBudget(t *testing.T) {
calls := &[]string{}
// Каждое удаление «идёт» дольше всего бюджета — второй единице стартовать
// уже нельзя.
// Бюджет укорочен на время теста — иначе проверка стоила бы двух минут.
orig := bulkBudget
bulkBudget = time.Millisecond
t.Cleanup(func() { bulkBudget = orig })
slow := bulkReviewer{calls: calls, sleep: 3 * time.Millisecond}
h := testRouterAction(t, stubReader{byID: byID(
dl(bid1, store.StateDone, "Дюна"),
dl(bid2, store.StateDone, "Фарго"),
dl(bid3, store.StateDone, "Оппенгеймер"),
)}, slow, stubCommander{}, stubLive{})
form := idForm(bid1, bid2, bid3)
form.Set("confirm", "1")
body := post(t, h, "/ui/delete", form, false).Body.String()
if len(*calls) != 1 {
t.Fatalf("после исчерпания бюджета новых удалений быть не должно: %v", *calls)
}
if !strings.Contains(body, "Не выполнено — 2") {
t.Errorf("остаток не назван невыполненным:\n%s", body)
}
if !strings.Contains(body, "дольше отведённого времени") {
t.Errorf("причина остановки не названа:\n%s", body)
}
}
// Форматирующие символы юникода в имени раздачи не доезжают до экрана: с ними
// заголовок читается не так, как хранится, а поимённое чтение заголовков — и
// есть предохранитель необратимого группового удаления.
func TestBulkTitleStripsFormattingRunes(t *testing.T) {
const rtl = "\u202e" // RIGHT-TO-LEFT OVERRIDE
d := dl(bid1, store.StateDone, "Дюна"+rtl+"vkm.iso \U0001F468\u200D\U0001F469\u200D\U0001F467")
rv, _ := newBulkReviewer(nil)
h := testRouterAction(t, stubReader{
deletable: []store.Download{d},
byID: byID(d),
}, rv, stubCommander{}, stubLive{})
pages := map[string]string{
"/delete": get(t, h, "/delete").Body.String(),
"/ui/delete/confirm": post(t, h, "/ui/delete/confirm",
idForm(bid1), false).Body.String(),
}
for path, body := range pages {
if strings.Contains(body, rtl) {
t.Errorf("%s: bidi-символ уехал в разметку дословно", path)
}
if !strings.Contains(body, "Дюна") {
t.Errorf("%s: заголовок потерялся целиком:\n%s", path, body)
}
// Составные эмодзи не рассыпаются: снимается класс bidi, а не весь Cf.
if !strings.Contains(body, "\U0001F468\u200D\U0001F469\u200D\U0001F467") {
t.Errorf("%s: соединитель составного эмодзи снят вместе с bidi:\n%s", path, body)
}
}
}