'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/); });