All checks were successful
Закрывает 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>
360 lines
16 KiB
JavaScript
360 lines
16 KiB
JavaScript
'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/);
|
||
});
|