'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, ttrails', () => { 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('ttrails'), 'отсутствует источник ttrails'); }); 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']); });