From 721b33a2f67a7d5c9ec9fb861f2804c03d09b14f Mon Sep 17 00:00:00 2001 From: claude-bot Date: Wed, 3 Jun 2026 23:01:19 +0000 Subject: [PATCH] =?UTF-8?q?fix(gps-tracks):=20address=20ET-011=20review=20?= =?UTF-8?q?=E2=80=94=20JS=20UI=20tests=20+=20flat=20403=20contract?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Закрывает 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) --- docs/work-items/ET-011/04b-ui-test-cases.md | 15 +- src/api/gps_tracks/endpoint.py | 10 +- src/web/gps_tracks.js | 9 +- tests/api/test_gps_tracks_download.py | 13 +- tests/web/test_track_download.py | 93 +++++ tests/web/track_download.test.js | 359 ++++++++++++++++++++ 6 files changed, 484 insertions(+), 15 deletions(-) create mode 100644 tests/web/test_track_download.py create mode 100644 tests/web/track_download.test.js diff --git a/docs/work-items/ET-011/04b-ui-test-cases.md b/docs/work-items/ET-011/04b-ui-test-cases.md index 5d1c1a3..1cb8bd8 100644 --- a/docs/work-items/ET-011/04b-ui-test-cases.md +++ b/docs/work-items/ET-011/04b-ui-test-cases.md @@ -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/ diff --git a/src/api/gps_tracks/endpoint.py b/src/api/gps_tracks/endpoint.py index e86e34d..5d7bc14 100644 --- a/src/api/gps_tracks/endpoint.py +++ b/src/api/gps_tracks/endpoint.py @@ -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, }, diff --git a/src/web/gps_tracks.js b/src/web/gps_tracks.js index ecc20c7..9440bc8 100644 --- a/src/web/gps_tracks.js +++ b/src/web/gps_tracks.js @@ -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}`); diff --git a/tests/api/test_gps_tracks_download.py b/tests/api/test_gps_tracks_download.py index 4eb98b4..2ef8859 100644 --- a/tests/api/test_gps_tracks_download.py +++ b/tests/api/test_gps_tracks_download.py @@ -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 diff --git a/tests/web/test_track_download.py b/tests/web/test_track_download.py new file mode 100644 index 0000000..1048b1a --- /dev/null +++ b/tests/web/test_track_download.py @@ -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}" + ) diff --git a/tests/web/track_download.test.js b/tests/web/track_download.test.js new file mode 100644 index 0000000..f04fa62 --- /dev/null +++ b/tests/web/track_download.test.js @@ -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\'\' приоритетнее 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, /
/); + // data-track-id — для делегированного обработчика (ADR-014 §3.b) + assert.match(html, /data-track-id="42"/); + // SVG download-иконка + assert.match(html, /]*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>/); + 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/); +});