Конфиг-only активация двух новых источников GPS-треков поверх pipeline ET-008. Не вводит новых компонентов, БД-таблиц, endpoint'ов. Config: - config/gps_sources.yaml: enduro_russia enabled=true, base_url исправлен на endurorussia.ru (без дефиса); добавлена запись wikiloc с max_tracks_per_run=50, activity_filter=[motorcycle, enduro]. - config/gps_regions.yaml: wikiloc добавлен в tsfo_plus_chuvashia.sources. Parser: - wikiloc.py: добавлен soft-cap max_tracks_per_run в collect(), извлечение created_at из GPX metadata/первого trkpt — для корректной межисточниковой дедупликации с EnduroRussia. UI (src/web/gps_tracks.js): - GPS_SOURCE_COLORS: добавлен цвет wikiloc (#4363d8). - Дефолтный фильтр sources включает wikiloc. - GPS_SOURCE_ATTRIBUTIONS: маппинг source_id → строка атрибуции; _updateGpsAttribution() подтягивает /api/gps-tracks/health и выставляет attribution с теми источниками, у которых tracks > 0. - _buildGpsFiltersUI: чекбокс «Wikiloc» в #gps-source-grid. Tests: - Fixtures: 7 файлов в tests/fixtures/gps-tracks/. - Unit: 10 UT-ER + 10 UT-WL — парсеры, MAPPING, bbox-фильтр, pagination, 429/403 graceful-stop, rate-limit, max_tracks_per_run. - Integration: IT-ER-01, IT-WL-01, IT-WL-02, IT-DEDUP-01, IT-LIC-01 через scripts.gps_collect.main + httpx.MockTransport. - Contract: 2 CT-ER с маркером @pytest.mark.network (nightly only). - JS: 2 новых теста на наличие wikiloc в SOURCE_COLORS и в фильтрах. Linters/Tests: ruff clean (новые файлы), 166 pytest passed, 24 JS-tests passed. Refs: ET-009 Acceptance: AC-01..AC-08, AC-14..AC-17 (для AC-09..AC-13 — продакшн-прогон) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
307 lines
14 KiB
JavaScript
307 lines
14 KiB
JavaScript
'use strict';
|
||
|
||
/**
|
||
* ET-008 — unit-тесты модуля публичных GPS-треков (gps_tracks.js).
|
||
*
|
||
* Покрывают:
|
||
* - _findGpsInsertPosition: логика приоритетного поиска позиции вставки
|
||
* (F-05: gpx-layer-* > route-*)
|
||
* - Filter state management: начальное состояние window.gpsTracksLayer.filters
|
||
* - Color palette mapping: GPS_SOURCE_COLORS, GPS_ACTIVITY_COLORS,
|
||
* GPS_FALLBACK_COLORS и _buildColorExpression()
|
||
*
|
||
* Тесты запускают РЕАЛЬНЫЙ код src/web/gps_tracks.js через new Function,
|
||
* подставляя мок-окружение (window, document) вместо браузерных глобалов.
|
||
* Браузерный примитив `maplibregl`, `fetch`, `AbortController` не нужны —
|
||
* тестируемые пути кода к ним не обращаются при инициализации.
|
||
*
|
||
* Запуск: `node --test tests/web/gps_tracks.test.js`
|
||
* (в CI оборачивается pytest-тестом tests/web/test_gps_tracks.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,
|
||
* подставляя мок-объекты вместо браузерных глобалов.
|
||
*
|
||
* После загрузки в mockWin появится свойство .gpsTracksLayer с начальным
|
||
* состоянием модуля. Возвращает приватные функции и константы.
|
||
*
|
||
* @param {object} [mockWin={}] мок-объект window
|
||
* @param {object} [mockDoc={}] мок-объект document
|
||
* @returns {{
|
||
* _findGpsInsertPosition: Function,
|
||
* _buildColorExpression: Function,
|
||
* GPS_SOURCE_COLORS: object,
|
||
* GPS_ACTIVITY_COLORS: object,
|
||
* GPS_FALLBACK_COLORS: string[],
|
||
* GPS_ACTIVITY_ICONS: object,
|
||
* GPS_ACTIVITY_LABELS: object,
|
||
* }}
|
||
*/
|
||
function loadGpsTracksModule(mockWin, mockDoc) {
|
||
const win = mockWin || {};
|
||
// Stub localStorage — используется в onPublicTracksCheckbox/restorePublicTracksState,
|
||
// но не при инициализации модуля.
|
||
win.localStorage = win.localStorage || {
|
||
getItem: () => null,
|
||
setItem: () => {},
|
||
};
|
||
|
||
const doc = mockDoc || {
|
||
getElementById: () => null,
|
||
querySelectorAll: () => ({ forEach: () => {} }),
|
||
};
|
||
|
||
const src = fs.readFileSync(GPS_TRACKS_PATH, 'utf8');
|
||
|
||
// new Function создаёт функцию в глобальном контексте Node.js,
|
||
// поэтому clearTimeout/setTimeout доступны как Node.js-глобалы.
|
||
const factory = new Function(
|
||
'window', 'document',
|
||
src +
|
||
'\nreturn {' +
|
||
' _findGpsInsertPosition,' +
|
||
' _buildColorExpression,' +
|
||
' GPS_SOURCE_COLORS,' +
|
||
' GPS_ACTIVITY_COLORS,' +
|
||
' GPS_FALLBACK_COLORS,' +
|
||
' GPS_ACTIVITY_ICONS,' +
|
||
' GPS_ACTIVITY_LABELS,' +
|
||
'};'
|
||
);
|
||
|
||
return factory(win, doc);
|
||
}
|
||
|
||
// ─── Вспомогательные функции ──────────────────────────────────────────────────
|
||
|
||
/** Создаёт мок-карту с заданным списком слоёв. */
|
||
function makeMap(layerIds) {
|
||
return {
|
||
getStyle: () => ({ layers: layerIds.map((id) => ({ id })) }),
|
||
};
|
||
}
|
||
|
||
// ─── _findGpsInsertPosition: логика приоритетов ───────────────────────────────
|
||
|
||
test('F-05: нет подходящих слоёв → undefined', () => {
|
||
const { _findGpsInsertPosition } = loadGpsTracksModule();
|
||
const map = makeMap(['background', 'osm-base', 'trails-track', 'poi-circles']);
|
||
assert.equal(_findGpsInsertPosition(map), undefined);
|
||
});
|
||
|
||
test('F-05: только gpx-layer-* → возвращает первый gpx-layer-*', () => {
|
||
const { _findGpsInsertPosition } = loadGpsTracksModule();
|
||
const map = makeMap(['background', 'gpx-layer-file1', 'gpx-layer-file2', 'trails-track']);
|
||
assert.equal(_findGpsInsertPosition(map), 'gpx-layer-file1');
|
||
});
|
||
|
||
test('F-05: только route-* → возвращает первый route-*', () => {
|
||
const { _findGpsInsertPosition } = loadGpsTracksModule();
|
||
const map = makeMap(['background', 'osm-base', 'route-line', 'route-alt-1']);
|
||
assert.equal(_findGpsInsertPosition(map), 'route-line');
|
||
});
|
||
|
||
test('F-05: gpx-layer-* приоритетнее route-* даже когда route-* идёт раньше в списке', () => {
|
||
const { _findGpsInsertPosition } = loadGpsTracksModule();
|
||
const map = makeMap(['background', 'route-line', 'gpx-layer-file1', 'poi-labels']);
|
||
// gpx-layer-* — приоритет 1, должен победить route-line несмотря на порядок
|
||
assert.equal(_findGpsInsertPosition(map), 'gpx-layer-file1');
|
||
});
|
||
|
||
test('F-05: gpx-layer-* и route-* присутствуют — возвращает gpx-layer-* (приоритет 1)', () => {
|
||
const { _findGpsInsertPosition } = loadGpsTracksModule();
|
||
const map = makeMap(['background', 'gpx-layer-abc', 'route-line', 'route-alt-2']);
|
||
assert.equal(_findGpsInsertPosition(map), 'gpx-layer-abc');
|
||
});
|
||
|
||
test('F-05: map.getStyle() возвращает null → undefined', () => {
|
||
const { _findGpsInsertPosition } = loadGpsTracksModule();
|
||
const map = { getStyle: () => null };
|
||
assert.equal(_findGpsInsertPosition(map), undefined);
|
||
});
|
||
|
||
test('F-05: map.getStyle отсутствует → undefined (нет TypeError)', () => {
|
||
const { _findGpsInsertPosition } = loadGpsTracksModule();
|
||
assert.doesNotThrow(() => {
|
||
const result = _findGpsInsertPosition({});
|
||
assert.equal(result, undefined);
|
||
});
|
||
});
|
||
|
||
test('F-05: style.layers пустой → undefined', () => {
|
||
const { _findGpsInsertPosition } = loadGpsTracksModule();
|
||
const map = { getStyle: () => ({ layers: [] }) };
|
||
assert.equal(_findGpsInsertPosition(map), undefined);
|
||
});
|
||
|
||
test('F-05: слой с именем ровно "route-" (без суффикса) распознаётся как route-*', () => {
|
||
const { _findGpsInsertPosition } = loadGpsTracksModule();
|
||
const map = makeMap(['background', 'route-']);
|
||
assert.equal(_findGpsInsertPosition(map), 'route-');
|
||
});
|
||
|
||
// ─── Filter state management ──────────────────────────────────────────────────
|
||
|
||
test('Filters: начальный список активностей содержит все 7 типов', () => {
|
||
const win = {};
|
||
loadGpsTracksModule(win);
|
||
const { activities } = win.gpsTracksLayer.filters;
|
||
const expected = ['enduro', 'moto', 'offroad', 'bicycle', 'hike', 'ski', 'other'];
|
||
assert.deepEqual(
|
||
[...activities].sort(),
|
||
[...expected].sort(),
|
||
'начальный filters.activities не совпадает с ожидаемым набором',
|
||
);
|
||
});
|
||
|
||
test('Filters: начальный colorMode === "source"', () => {
|
||
const win = {};
|
||
loadGpsTracksModule(win);
|
||
assert.equal(win.gpsTracksLayer.filters.colorMode, 'source');
|
||
});
|
||
|
||
test('Filters: начальные источники включают osm, enduro_russia, wikiloc, ttrails (ET-009)', () => {
|
||
const win = {};
|
||
loadGpsTracksModule(win);
|
||
const { sources } = win.gpsTracksLayer.filters;
|
||
assert.ok(Array.isArray(sources), 'filters.sources должен быть массивом');
|
||
assert.ok(sources.includes('osm'), 'отсутствует источник osm');
|
||
assert.ok(sources.includes('enduro_russia'), 'отсутствует источник enduro_russia');
|
||
assert.ok(sources.includes('wikiloc'), 'отсутствует источник wikiloc');
|
||
assert.ok(sources.includes('ttrails'), 'отсутствует источник ttrails');
|
||
});
|
||
|
||
// ET-009 — REQ-F-13/REQ-F-14: цвета и атрибуция новых источников
|
||
test('ET-009: GPS_SOURCE_COLORS содержит цвет для wikiloc', () => {
|
||
const { GPS_SOURCE_COLORS } = loadGpsTracksModule();
|
||
assert.ok(GPS_SOURCE_COLORS.wikiloc, 'GPS_SOURCE_COLORS.wikiloc отсутствует');
|
||
assert.match(GPS_SOURCE_COLORS.wikiloc, /^#[0-9a-fA-F]{6}$/);
|
||
});
|
||
|
||
test('ET-009: цвета osm, enduro_russia, wikiloc различны', () => {
|
||
const { GPS_SOURCE_COLORS } = loadGpsTracksModule();
|
||
const colors = new Set([
|
||
GPS_SOURCE_COLORS.osm,
|
||
GPS_SOURCE_COLORS.enduro_russia,
|
||
GPS_SOURCE_COLORS.wikiloc,
|
||
]);
|
||
assert.equal(colors.size, 3, 'цвета osm/enduro_russia/wikiloc должны быть уникальны');
|
||
});
|
||
|
||
test('Filters: enabled=false при инициализации', () => {
|
||
const win = {};
|
||
loadGpsTracksModule(win);
|
||
assert.equal(win.gpsTracksLayer.enabled, false);
|
||
});
|
||
|
||
test('Filters: filters.activities — массив, не объект', () => {
|
||
const win = {};
|
||
loadGpsTracksModule(win);
|
||
assert.ok(Array.isArray(win.gpsTracksLayer.filters.activities));
|
||
});
|
||
|
||
// ─── Color palette mapping ────────────────────────────────────────────────────
|
||
|
||
test('Colors: GPS_SOURCE_COLORS содержит ключи osm, enduro_russia, ttrails, offmaps, nakarte', () => {
|
||
const { GPS_SOURCE_COLORS } = loadGpsTracksModule();
|
||
for (const src of ['osm', 'enduro_russia', 'ttrails', 'offmaps', 'nakarte']) {
|
||
assert.ok(
|
||
GPS_SOURCE_COLORS[src],
|
||
`GPS_SOURCE_COLORS: отсутствует источник ${src}`,
|
||
);
|
||
}
|
||
});
|
||
|
||
test('Colors: GPS_ACTIVITY_COLORS содержит все 7 типов активности', () => {
|
||
const { GPS_ACTIVITY_COLORS } = loadGpsTracksModule();
|
||
for (const act of ['enduro', 'moto', 'offroad', 'bicycle', 'hike', 'ski', 'other']) {
|
||
assert.ok(
|
||
GPS_ACTIVITY_COLORS[act],
|
||
`GPS_ACTIVITY_COLORS: отсутствует активность ${act}`,
|
||
);
|
||
}
|
||
});
|
||
|
||
test('Colors: GPS_FALLBACK_COLORS — массив из 8 уникальных цветов', () => {
|
||
const { GPS_FALLBACK_COLORS } = loadGpsTracksModule();
|
||
assert.ok(Array.isArray(GPS_FALLBACK_COLORS), 'GPS_FALLBACK_COLORS не является массивом');
|
||
assert.equal(GPS_FALLBACK_COLORS.length, 8, 'GPS_FALLBACK_COLORS должен содержать 8 цветов');
|
||
const unique = new Set(GPS_FALLBACK_COLORS);
|
||
assert.equal(
|
||
unique.size,
|
||
GPS_FALLBACK_COLORS.length,
|
||
'GPS_FALLBACK_COLORS содержит дубли',
|
||
);
|
||
});
|
||
|
||
test('Colors: все цвета GPS_SOURCE_COLORS — строки в формате #RRGGBB', () => {
|
||
const { GPS_SOURCE_COLORS } = loadGpsTracksModule();
|
||
const hexRe = /^#[0-9a-fA-F]{6}$/;
|
||
for (const [src, color] of Object.entries(GPS_SOURCE_COLORS)) {
|
||
assert.match(color, hexRe, `GPS_SOURCE_COLORS[${src}] = "${color}" не является hex-цветом`);
|
||
}
|
||
});
|
||
|
||
test('Colors: все цвета GPS_ACTIVITY_COLORS — строки в формате #RRGGBB', () => {
|
||
const { GPS_ACTIVITY_COLORS } = loadGpsTracksModule();
|
||
const hexRe = /^#[0-9a-fA-F]{6}$/;
|
||
for (const [act, color] of Object.entries(GPS_ACTIVITY_COLORS)) {
|
||
assert.match(color, hexRe, `GPS_ACTIVITY_COLORS[${act}] = "${color}" не является hex-цветом`);
|
||
}
|
||
});
|
||
|
||
test('Colors: _buildColorExpression("source") — MapLibre match по полю "source"', () => {
|
||
const { _buildColorExpression, GPS_SOURCE_COLORS } = loadGpsTracksModule();
|
||
const expr = _buildColorExpression('source');
|
||
|
||
assert.ok(Array.isArray(expr), 'выражение должно быть массивом');
|
||
assert.equal(expr[0], 'match', 'первый элемент должен быть "match"');
|
||
assert.deepEqual(expr[1], ['get', 'source'], 'второй элемент должен быть ["get", "source"]');
|
||
|
||
// Каждый источник присутствует в выражении с правильным цветом
|
||
for (const [src, color] of Object.entries(GPS_SOURCE_COLORS)) {
|
||
const idx = expr.indexOf(src);
|
||
assert.notEqual(idx, -1, `источник "${src}" не найден в match-выражении`);
|
||
assert.equal(expr[idx + 1], color, `цвет для источника "${src}" не совпадает`);
|
||
}
|
||
|
||
// Последний элемент — fallback цвет
|
||
assert.equal(expr[expr.length - 1], '#808080', 'последний элемент должен быть fallback #808080');
|
||
});
|
||
|
||
test('Colors: _buildColorExpression("activity") — MapLibre match по полю "activity"', () => {
|
||
const { _buildColorExpression, GPS_ACTIVITY_COLORS } = loadGpsTracksModule();
|
||
const expr = _buildColorExpression('activity');
|
||
|
||
assert.ok(Array.isArray(expr), 'выражение должно быть массивом');
|
||
assert.equal(expr[0], 'match', 'первый элемент должен быть "match"');
|
||
assert.deepEqual(expr[1], ['get', 'activity'], 'второй элемент должен быть ["get", "activity"]');
|
||
|
||
// Каждый тип активности присутствует в выражении с правильным цветом
|
||
for (const [act, color] of Object.entries(GPS_ACTIVITY_COLORS)) {
|
||
const idx = expr.indexOf(act);
|
||
assert.notEqual(idx, -1, `активность "${act}" не найдена в match-выражении`);
|
||
assert.equal(expr[idx + 1], color, `цвет для активности "${act}" не совпадает`);
|
||
}
|
||
|
||
// Последний элемент — fallback цвет
|
||
assert.equal(expr[expr.length - 1], '#808080', 'последний элемент должен быть fallback #808080');
|
||
});
|
||
|
||
test('Colors: _buildColorExpression — незнакомый режим даёт выражение по источнику', () => {
|
||
const { _buildColorExpression } = loadGpsTracksModule();
|
||
// Любое значение, отличное от 'activity', даёт режим 'source'
|
||
const expr = _buildColorExpression('unknown');
|
||
assert.deepEqual(expr[1], ['get', 'source']);
|
||
});
|