Files
enduro-trails/tests/web/gps_tracks.test.js
claude-bot 3577ff32ac
Some checks failed
CI / lint (push) Failing after 4s
CI / test (push) Failing after 5s
CI / build (push) Has been skipped
CI / lint (pull_request) Failing after 4s
CI / test (pull_request) Failing after 5s
CI / build (pull_request) Has been skipped
feat(ET-009): activate EnduroRussia + Wikiloc GPS sources
Конфиг-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>
2026-06-01 19:38:55 +00:00

307 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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']);
});