fix(gps-tracks): address ET-011 review — JS UI tests + flat 403 contract
All checks were successful
CI / lint (push) Successful in 4s
CI / test (push) Successful in 6s
CI / lint (pull_request) Successful in 4s
CI / build (push) Successful in 2s
CI / test (pull_request) Successful in 6s
CI / build (pull_request) Successful in 4s

Закрывает findings из docs/work-items/ET-011/12-review.md (REQUEST_CHANGES,
попытка 3/3):

P1-01 — добавлены поведенческие JS unit-тесты UI download-flow
  - tests/web/track_download.test.js — 28 кейсов (node --test):
      • _parseFilenameFromCD — RFC 5987 приоритет, plain fallback,
        битый percent-encoding, null/empty (REQ-F-05.2, AC-2 UI)
      • _handleDownloadError — 400/403/404/413/5xx тосты, defensive
        при отсутствии showToast, поддержка flat (ADR-015 §G) и legacy
        wrapped 403-форм (REQ-F-05.4, AC-7 UI)
      • _renderTrackPopupHtml — наличие кнопки, aria-label «Скачать GPX»,
        data-track-id, отсутствие при невалидном id, регрессия прочих
        полей (REQ-F-01, AC-1)
  - tests/web/test_track_download.py — pytest-обёртка (статические
    проверки + запуск Node-раннера), исполняется в обычном pytest tests/
  - 04b-ui-test-cases.md: AC-13 (mobile-bbox) явно маркирован как
    MANUAL release-smoke (Playwright-раннер в проекте не настроен;
    альтернатива согласована reviewer'ом в P1-01).

P2-01 — устранено расхождение «doc vs runtime» по контракту 403
  - endpoint.py: HTTPException(detail={...}) → JSONResponse(content={...}),
    чтобы FastAPI не оборачивал dict во второй слой «detail». Контракт
    теперь совпадает с ADR-015 §G и ADR-014 §6:
        {"detail":"source_forbidden","external_urls":[...]}
  - test_gps_tracks_download.py IT-05: упрощено — body уже плоский,
    без двухуровневого `body.get("detail", body)` workaround.
  - gps_tracks.js::_handleDownloadError: flat-форма стала приоритетной,
    wrapped-форма оставлена как defensive fallback (с комментарием).

Регрессия: 89/89 API-тестов + 24/24 предыдущих JS-тестов + 28 новых
JS-тестов download-flow проходят. ruff check — clean.

Refs: ET-011

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-03 23:01:19 +00:00
parent 716bff3126
commit 721b33a2f6
6 changed files with 484 additions and 15 deletions

View File

@@ -8,6 +8,15 @@ Playwright-сценарии для визуальной проверки. Все
> architect/builder уточнит CSS-классы новой кнопки — обновить
> селекторы в этом файле.
> **Статус автоматизации (ET-011, после review 12-review.md / P1-01):**
> Playwright-спека `tests/web/test_track_download.spec.ts` из test-plan
> §E2E-01..E2E-04 **не реализована** — в проекте нет настроенного
> Playwright-раннера. UI-сторона AC-1 / AC-2 / AC-7 закрыта поведенческими
> JS unit-тестами `tests/web/track_download.test.js` (28 кейсов,
> `node --test`, обёрнуто pytest'ом). **AC-13 (mobile bbox / тапабельность
> кнопки ≥ 32×32 CSS px на 375×667) — ручной smoke перед каждым релизом**;
> сценарий — TC-UI-02 ниже (+ TC-UI-05 для проверки реального download).
---
### TC-UI-01 — Кнопка «Скачать» в popup трека (desktop)
@@ -33,10 +42,12 @@ Playwright-сценарии для визуальной проверки. Все
---
### TC-UI-02 — Popup и кнопка на мобильном
### TC-UI-02 — Popup и кнопка на мобильном (AC-13, MANUAL release-smoke)
**Тип:** ui
**Тип:** ui (manual smoke — единственное покрытие AC-13)
**Viewport:** mobile (375×667)
**Когда:** перед каждым деплоем в test/prod, оператором — DevTools или
устройство с тем же viewport.
**Шаги:**
1. navigate: https://openclaw.mva154.duckdns.org/enduro/

View File

@@ -5,6 +5,7 @@ import os
from typing import Optional
from fastapi import APIRouter, HTTPException, Path, Query, Response
from fastapi.responses import JSONResponse
from src.api.gps_tracks.config import load_download_allowed_sources
from src.api.gps_tracks.db import get_tracks_in_bbox, init_db, open_db
@@ -380,10 +381,15 @@ def create_gps_router(
external_urls = json.loads(row["external_urls_json"] or "[]")
# ADR-015 §B1: разрешение по принципу ANY — хотя бы один разрешённый.
# ADR-015 §G: контракт ответа — одноуровневый JSON
# {"detail": "source_forbidden", "external_urls": [...]}.
# Используем JSONResponse напрямую вместо HTTPException(detail={...}),
# чтобы FastAPI не оборачивал dict в `{"detail": {...}}` (P2-01 в
# 12-review.md: контракт docs vs runtime разъезжался).
if not any(s in allowed_download_sources for s in sources):
raise HTTPException(
return JSONResponse(
status_code=403,
detail={
content={
"detail": "source_forbidden",
"external_urls": external_urls,
},

View File

@@ -556,9 +556,12 @@ function _parseFilenameFromCD(cd) {
function _handleDownloadError(status, body) {
if (typeof showToast !== 'function') return;
if (status === 403) {
// ADR-015 §G: backend кладёт external_urls в detail.
const urls = (body && body.detail && body.detail.external_urls)
|| (body && body.external_urls);
// ADR-015 §G: backend отдаёт одноуровневый JSON
// {"detail":"source_forbidden","external_urls":[...]}
// Защитный fallback на старую форму {"detail":{"external_urls":[...]}}
// оставлен на случай legacy-обёрток (см. P2-01 в 12-review.md).
const urls = (body && body.external_urls)
|| (body && body.detail && body.detail.external_urls);
const firstUrl = Array.isArray(urls) && urls.length ? urls[0] : null;
if (firstUrl) {
showToast(`Источник запрещает скачивание. Откройте трек на сайте источника: ${firstUrl}`);

View File

@@ -247,14 +247,11 @@ async def test_it05_source_forbidden_403(tmp_path):
assert resp.status_code == 403
body = resp.json()
# FastAPI обёртывает наш detail-dict в {"detail": {...}}.
detail = body.get("detail", body)
if isinstance(detail, dict):
assert detail.get("detail") == "source_forbidden"
assert detail.get("external_urls") == ["https://www.wikiloc.com/abc"]
else:
# Если FastAPI отдал просто строку — должно содержать source_forbidden
assert "source_forbidden" in str(detail)
# ADR-015 §G: одноуровневый контракт через JSONResponse в endpoint.py
# (см. P2-01 в 12-review.md). Раньше FastAPI оборачивал detail-dict
# в {"detail": {...}}; сейчас body == {"detail": "...", "external_urls": [...]}.
assert body.get("detail") == "source_forbidden"
assert body.get("external_urls") == ["https://www.wikiloc.com/abc"]
@pytest.mark.asyncio

View File

@@ -0,0 +1,93 @@
"""ET-011 — pytest-обёртка для JS unit-тестов download-UI.
Контекст: test-plan §E2E-01..E2E-04 предусматривал Playwright-спеку
`tests/web/test_track_download.spec.ts`, но в проекте нет настроенного
Playwright-раннера. Reviewer ET-011 (12-review.md, P1-01) разрешил закрыть
UI-сторону AC-1 / AC-2 / AC-7 поведенческими JS unit-тестами через
`node --test`. AC-13 (mobile-bbox) оставлен как manual smoke
(см. 04b-ui-test-cases.md TC-UI-02).
Этот файл — pytest-точка-входа, запускающая Node-раннер. Так JS-тесты
исполняются в обычном `pytest tests/` без отдельных шагов в Makefile/CI.
Запуск JS-тестов напрямую:
node --test tests/web/track_download.test.js
"""
from __future__ import annotations
import subprocess
from pathlib import Path
from shutil import which
import pytest
REPO_ROOT = Path(__file__).resolve().parents[2]
GPS_TRACKS_JS = REPO_ROOT / "src" / "web" / "gps_tracks.js"
JS_TEST = REPO_ROOT / "tests" / "web" / "track_download.test.js"
def _read(path: Path) -> str:
assert path.is_file(), f"не найден {path}"
return path.read_text(encoding="utf-8")
# ─── Статические проверки: ET-011 артефакты на месте ─────────────────────────
def test_download_helpers_defined_in_gps_tracks_js():
"""ET-011: новые функции download-UI объявлены в gps_tracks.js."""
js = _read(GPS_TRACKS_JS)
for symbol in (
"function _parseFilenameFromCD(",
"function _handleDownloadError(",
"async function _downloadPublicTrack(",
):
assert symbol in js, (
f"ET-011: символ `{symbol}` не найден в src/web/gps_tracks.js"
)
def test_popup_renders_download_button_markup():
"""AC-1: _renderTrackPopupHtml содержит маркап кнопки «Скачать GPX»."""
js = _read(GPS_TRACKS_JS)
# Существенные куски, по которым держится UI-контракт
assert 'aria-label="Скачать GPX"' in js, (
"AC-1: aria-label='Скачать GPX' отсутствует в gps_tracks.js"
)
assert "track-popup-download-btn" in js, (
"AC-1: CSS-класс кнопки track-popup-download-btn отсутствует"
)
assert "data-track-id=" in js, (
"ADR-014 §3.b: data-track-id для делегированного клика отсутствует"
)
def test_js_test_file_exists():
"""JS-тест присутствует в репозитории — иначе субтесты ниже бессмыслены."""
assert JS_TEST.is_file(), f"не найден JS-тест {JS_TEST}"
# ─── Поведенческие JS unit-тесты через Node ──────────────────────────────────
node_required = pytest.mark.skipif(
which("node") is None,
reason="node не установлен — поведенческие JS unit-тесты пропущены",
)
@node_required
def test_js_track_download_unit_tests_pass():
"""ET-011 P1-01: AC-1 / AC-2 / AC-7 (UI) — JS-тесты download-flow."""
node = which("node")
result = subprocess.run(
[node, "--test", str(JS_TEST)],
capture_output=True,
text=True,
cwd=str(REPO_ROOT),
)
assert result.returncode == 0, (
f"JS unit-тесты track_download упали (код {result.returncode}):\n"
f"STDOUT:\n{result.stdout}\n\nSTDERR:\n{result.stderr}"
)

View File

@@ -0,0 +1,359 @@
'use strict';
/**
* ET-011 — поведенческие JS unit-тесты UI для скачивания GPX из popup
* публичного трека (src/web/gps_tracks.js).
*
* Контекст: test-plan §E2E-01..E2E-04 предусматривал Playwright-спеку
* (`tests/web/test_track_download.spec.ts`), но в проекте нет настроенного
* Playwright-раннера. Reviewer ET-011 (12-review.md, P1-01) явно разрешил
* закрыть UI-сторону AC-1 / AC-2 / AC-7 этими JS unit-тестами, оставив
* AC-13 (mobile-bbox) как manual smoke (см. 04b-ui-test-cases.md TC-UI-02).
*
* Покрываются:
* - _parseFilenameFromCD — REQ-F-05.2, AC-2 (UI-чтение хедера)
* - _handleDownloadError — REQ-F-05.4, AC-7 (toast по статусу)
* - _renderTrackPopupHtml — REQ-F-01, AC-1 (кнопка в popup,
* aria-label, тапабельный data-track-id)
*
* Запуск: node --test tests/web/track_download.test.js
* В CI оборачивается pytest-тестом tests/web/test_track_download.py.
*/
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const GPS_TRACKS_PATH = path.join(__dirname, '..', '..', 'src', 'web', 'gps_tracks.js');
// ─── Загрузчик модуля ─────────────────────────────────────────────────────────
/**
* Загружает gps_tracks.js в изолированный контекст new Function, подставляя
* мок-объекты вместо браузерных глобалов (`window`, `document`, `showToast`).
*
* Возвращает приватные функции, требуемые в тестах:
* _parseFilenameFromCD, _handleDownloadError, _renderTrackPopupHtml.
*
* @param {object} [opts]
* @param {object} [opts.win] мок window
* @param {object} [opts.doc] мок document
* @param {Function|null} [opts.showToast] мок showToast (null → отсутствует)
* @returns {{
* _parseFilenameFromCD: Function,
* _handleDownloadError: Function,
* _renderTrackPopupHtml: Function,
* }}
*/
function loadDownloadModule(opts) {
const o = opts || {};
const win = o.win || {};
win.localStorage = win.localStorage || {
getItem: () => null,
setItem: () => {},
};
const doc = o.doc || {
getElementById: () => null,
querySelectorAll: () => ({ forEach: () => {} }),
};
// showToast === undefined → typeof === 'undefined' → ранний return в
// _handleDownloadError (defensive). null → typeof === 'object' → тоже return.
const showToast = Object.prototype.hasOwnProperty.call(o, 'showToast')
? o.showToast
: undefined;
const src = fs.readFileSync(GPS_TRACKS_PATH, 'utf8');
const factory = new Function(
'window', 'document', 'showToast',
src +
'\nreturn {' +
' _parseFilenameFromCD,' +
' _handleDownloadError,' +
' _renderTrackPopupHtml,' +
'};'
);
return factory(win, doc, showToast);
}
// ═══════════════════════════════════════════════════════════════════════════
// _parseFilenameFromCD — RFC 5987 + plain filename
// ═══════════════════════════════════════════════════════════════════════════
test('CD: null → null', () => {
const { _parseFilenameFromCD } = loadDownloadModule();
assert.equal(_parseFilenameFromCD(null), null);
});
test('CD: undefined → null', () => {
const { _parseFilenameFromCD } = loadDownloadModule();
assert.equal(_parseFilenameFromCD(undefined), null);
});
test('CD: пустая строка → null', () => {
const { _parseFilenameFromCD } = loadDownloadModule();
assert.equal(_parseFilenameFromCD(''), null);
});
test('CD: без параметров filename → null', () => {
const { _parseFilenameFromCD } = loadDownloadModule();
assert.equal(_parseFilenameFromCD('attachment'), null);
});
test('CD: plain filename="track.gpx" → "track.gpx"', () => {
const { _parseFilenameFromCD } = loadDownloadModule();
assert.equal(
_parseFilenameFromCD('attachment; filename="track.gpx"'),
'track.gpx',
);
});
test('CD: plain filename без кавычек → значение до ; ', () => {
const { _parseFilenameFromCD } = loadDownloadModule();
assert.equal(
_parseFilenameFromCD('attachment; filename=track.gpx'),
'track.gpx',
);
});
test('CD: filename*=UTF-8\'\'<percent> приоритетнее plain filename (RFC 5987)', () => {
const { _parseFilenameFromCD } = loadDownloadModule();
// backend всегда отдаёт оба параметра; для не-ASCII имени берётся star
const cd = 'attachment; filename="track-1.gpx"; '
+ "filename*=UTF-8''%D0%9F%D0%BE%20%D0%B3%D1%80%D1%8F%D0%B7%D0%B8.gpx";
assert.equal(_parseFilenameFromCD(cd), 'По грязи.gpx');
});
test('CD: filename* с битым percent-encoding → fallback на plain filename', () => {
const { _parseFilenameFromCD } = loadDownloadModule();
// %ZZ — невалидный percent (decodeURIComponent кинет)
const cd = 'attachment; filename="track-1.gpx"; '
+ "filename*=UTF-8''%ZZbroken.gpx";
assert.equal(_parseFilenameFromCD(cd), 'track-1.gpx');
});
test('CD: filename* без последующего ; (конец строки) — декодируется до конца', () => {
const { _parseFilenameFromCD } = loadDownloadModule();
// ADR-014 §F: backend кладёт filename* последним параметром
const cd = "attachment; filename=\"a.gpx\"; filename*=UTF-8''%D0%90.gpx";
assert.equal(_parseFilenameFromCD(cd), 'А.gpx');
});
// ═══════════════════════════════════════════════════════════════════════════
// _handleDownloadError — REQ-F-05.4, AC-7
// ═══════════════════════════════════════════════════════════════════════════
/** Создаёт мок showToast, копящий последние вызовы. */
function makeToastSpy() {
const calls = [];
const fn = (msg) => { calls.push(msg); };
return { fn, calls };
}
test('Error: 404 → toast «Трек не найден.»', () => {
const spy = makeToastSpy();
const { _handleDownloadError } = loadDownloadModule({ showToast: spy.fn });
_handleDownloadError(404, {});
assert.equal(spy.calls.length, 1);
assert.equal(spy.calls[0], 'Трек не найден.');
});
test('Error: 413 → toast про размер', () => {
const spy = makeToastSpy();
const { _handleDownloadError } = loadDownloadModule({ showToast: spy.fn });
_handleDownloadError(413, {});
assert.equal(spy.calls.length, 1);
assert.match(spy.calls[0], /слишком большой/i);
});
test('Error: 400 → toast про формат', () => {
const spy = makeToastSpy();
const { _handleDownloadError } = loadDownloadModule({ showToast: spy.fn });
_handleDownloadError(400, {});
assert.equal(spy.calls.length, 1);
assert.match(spy.calls[0], /формат/i);
});
test('Error: 500 / unknown → дефолтный toast «Не удалось скачать.»', () => {
const spy = makeToastSpy();
const { _handleDownloadError } = loadDownloadModule({ showToast: spy.fn });
_handleDownloadError(500, {});
assert.equal(spy.calls.length, 1);
assert.match(spy.calls[0], /Не удалось скачать/);
});
test('Error: 403 с external_urls (ADR-015 §G flat-форма) → toast с URL', () => {
// ADR-015 §G: backend → JSONResponse{detail, external_urls} (без вложенности).
const spy = makeToastSpy();
const { _handleDownloadError } = loadDownloadModule({ showToast: spy.fn });
_handleDownloadError(403, {
detail: 'source_forbidden',
external_urls: ['https://www.wikiloc.com/abc'],
});
assert.equal(spy.calls.length, 1);
assert.match(spy.calls[0], /Источник запрещает/);
assert.ok(
spy.calls[0].includes('https://www.wikiloc.com/abc'),
`toast должен содержать external_url, было: ${spy.calls[0]}`,
);
});
test('Error: 403 с body.detail.external_urls (legacy wrapped-форма) → URL читается', () => {
// Defensive fallback на старую форму до P2-01 (когда HTTPException
// оборачивал detail в {"detail": {...}}). Тест защищает frontend от
// регресса, если кто-то восстановит HTTPException-вариант.
const spy = makeToastSpy();
const { _handleDownloadError } = loadDownloadModule({ showToast: spy.fn });
_handleDownloadError(403, {
detail: {
detail: 'source_forbidden',
external_urls: ['https://wikiloc.com/x'],
},
});
assert.equal(spy.calls.length, 1);
assert.ok(
spy.calls[0].includes('https://wikiloc.com/x'),
`toast должен содержать external_url из legacy формы, было: ${spy.calls[0]}`,
);
});
test('Error: 403 без external_urls → toast без URL', () => {
const spy = makeToastSpy();
const { _handleDownloadError } = loadDownloadModule({ showToast: spy.fn });
_handleDownloadError(403, { detail: 'source_forbidden' });
assert.equal(spy.calls.length, 1);
assert.match(spy.calls[0], /Источник запрещает/);
// в сообщении не должно быть http-URL
assert.ok(
!/https?:\/\//.test(spy.calls[0]),
`toast не должен содержать URL когда external_urls пуст, было: ${spy.calls[0]}`,
);
});
test('Error: 403 с external_urls = [] → toast без URL', () => {
const spy = makeToastSpy();
const { _handleDownloadError } = loadDownloadModule({ showToast: spy.fn });
_handleDownloadError(403, { detail: 'source_forbidden', external_urls: [] });
assert.equal(spy.calls.length, 1);
assert.ok(!/https?:\/\//.test(spy.calls[0]));
});
test('Error: showToast отсутствует → не падаем (defensive)', () => {
// showToast не передан → typeof === 'undefined' → ранний return
const { _handleDownloadError } = loadDownloadModule();
assert.doesNotThrow(() => _handleDownloadError(404, {}));
assert.doesNotThrow(() => _handleDownloadError(403, { external_urls: ['x'] }));
assert.doesNotThrow(() => _handleDownloadError(500, {}));
});
// ═══════════════════════════════════════════════════════════════════════════
// _renderTrackPopupHtml — REQ-F-01, AC-1
// ═══════════════════════════════════════════════════════════════════════════
test('Popup: при валидном числовом id рендерится кнопка «Скачать GPX»', () => {
const { _renderTrackPopupHtml } = loadDownloadModule();
const html = _renderTrackPopupHtml({
id: 42,
name: 'Test Trail',
activity_type: 'enduro',
length_km: 5.3,
points_count: 100,
});
// AC-1: aria-label «Скачать GPX» обязателен (REQ-F-01)
assert.match(html, /aria-label="Скачать GPX"/);
// структура / классы для CSS (.track-popup-download-btn — ADR-014 §3.a)
assert.match(html, /class="track-popup-download-btn"/);
assert.match(html, /<div class="track-popup-actions">/);
// data-track-id — для делегированного обработчика (ADR-014 §3.b)
assert.match(html, /data-track-id="42"/);
// SVG download-иконка
assert.match(html, /<svg [^>]*viewBox="0 0 24 24"/);
});
test('Popup: id в виде строки "7" тоже даёт кнопку (Number() приводит)', () => {
const { _renderTrackPopupHtml } = loadDownloadModule();
const html = _renderTrackPopupHtml({
id: '7',
name: 'Test',
});
assert.match(html, /data-track-id="7"/);
assert.match(html, /aria-label="Скачать GPX"/);
});
test('Popup: id = 0 → кнопка НЕ рендерится (Path int ge=1 на бэке)', () => {
// backend требует ge=1; защищаем frontend от запроса /download/0
const { _renderTrackPopupHtml } = loadDownloadModule();
const html = _renderTrackPopupHtml({
id: 0,
name: 'Test',
});
assert.doesNotMatch(html, /track-popup-download-btn/);
assert.doesNotMatch(html, /Скачать GPX/);
});
test('Popup: id = null → кнопка НЕ рендерится', () => {
const { _renderTrackPopupHtml } = loadDownloadModule();
const html = _renderTrackPopupHtml({ id: null, name: 'Test' });
assert.doesNotMatch(html, /track-popup-download-btn/);
});
test('Popup: id отсутствует → кнопка НЕ рендерится', () => {
const { _renderTrackPopupHtml } = loadDownloadModule();
const html = _renderTrackPopupHtml({ name: 'Test' });
assert.doesNotMatch(html, /track-popup-download-btn/);
});
test('Popup: id = "abc" (мусор) → кнопка НЕ рендерится', () => {
const { _renderTrackPopupHtml } = loadDownloadModule();
const html = _renderTrackPopupHtml({ id: 'abc', name: 'Test' });
assert.doesNotMatch(html, /track-popup-download-btn/);
});
test('Popup: id = -1 → кнопка НЕ рендерится (защита от patho-кейсов)', () => {
const { _renderTrackPopupHtml } = loadDownloadModule();
const html = _renderTrackPopupHtml({ id: -1, name: 'Test' });
assert.doesNotMatch(html, /track-popup-download-btn/);
});
test('Popup-регрессия: остаются прежние поля (имя, активность, длина)', () => {
const { _renderTrackPopupHtml } = loadDownloadModule();
const html = _renderTrackPopupHtml({
id: 1,
name: 'Озеро',
activity_type: 'enduro',
length_km: 12.5,
points_count: 250,
user: 'tester',
created_at: '2024-05-01T00:00:00Z',
});
assert.match(html, /<div class="track-popup-name">Озеро<\/div>/);
assert.match(html, /Эндуро/); // GPS_ACTIVITY_LABELS.enduro
assert.match(html, /12\.5 км/);
assert.match(html, /250 точек/);
assert.match(html, /tester/);
});
test('Popup: actionsHtml идёт ПЕРЕД sourcesHtml (ADR-014 §3.a)', () => {
const { _renderTrackPopupHtml } = loadDownloadModule();
const html = _renderTrackPopupHtml({
id: 9,
name: 'X',
sources: ['osm'],
external_urls: ['https://www.openstreetmap.org/way/9'],
});
const idxActions = html.indexOf('track-popup-actions');
const idxSources = html.indexOf('track-popup-sources');
assert.notEqual(idxActions, -1, 'actionsHtml присутствует');
assert.notEqual(idxSources, -1, 'sourcesHtml присутствует');
assert.ok(
idxActions < idxSources,
'actionsHtml (кнопка) должен идти раньше sourcesHtml',
);
});
test('Popup: без источников всё равно рендерится кнопка (если id ок)', () => {
const { _renderTrackPopupHtml } = loadDownloadModule();
const html = _renderTrackPopupHtml({ id: 3, name: 'NoSources' });
assert.match(html, /track-popup-download-btn/);
assert.doesNotMatch(html, /track-popup-sources/);
});