diff --git a/src/api/gps_tracks/endpoint.py b/src/api/gps_tracks/endpoint.py index 508cc5a..6c78288 100644 --- a/src/api/gps_tracks/endpoint.py +++ b/src/api/gps_tracks/endpoint.py @@ -224,10 +224,11 @@ def create_gps_router(db_path: str) -> APIRouter: status, tracks_new, tracks_updated FROM pipeline_runs ORDER BY started_at DESC - LIMIT 10 + LIMIT 1 """ ) - recent_runs = [dict(row) for row in cur.fetchall()] + last_run_row = cur.fetchone() + last_run = dict(last_run_row) if last_run_row else None cur.execute("SELECT sources_json FROM tracks") tracks_by_source: dict = {} @@ -252,9 +253,9 @@ def create_gps_router(db_path: str) -> APIRouter: return { "status": "ok", "db_path": db_path, - "total_tracks": total_tracks, - "by_activity": by_activity, - "recent_pipeline_runs": recent_runs, + "tracks_total": total_tracks, + "tracks_by_activity": by_activity, + "last_pipeline_run": last_run, "db_size_mb": db_size_mb, "tracks_by_source": tracks_by_source, "tile_cache_size": len(_gps_tile_cache), diff --git a/src/web/gps_tracks.js b/src/web/gps_tracks.js index 4987db5..8b8af0d 100644 --- a/src/web/gps_tracks.js +++ b/src/web/gps_tracks.js @@ -189,14 +189,25 @@ function _ensureGpsLayers(map) { } function _findGpsInsertPosition(map) { + /** + * Returns the id of the first layer that GPS tracks should be inserted + * below, using priority order: + * 1. gpx-layer-* — ET-006 GPX file layers (highest priority) + * 2. route-* — ET-002 routing layers + * Returns undefined if neither is present (GPS tracks go on top). + */ const style = map.getStyle && map.getStyle(); if (!style || !style.layers) return undefined; - const routeLayer = style.layers.find(l => - l.id === 'route-line' || - l.id.startsWith('route-') || - l.id.startsWith('gpx-layer-') - ); - return routeLayer ? routeLayer.id : undefined; + + // Priority 1: gpx-layer-* (ET-006 GPX file layers) + const gpxLayer = style.layers.find(l => l.id.startsWith('gpx-layer-')); + if (gpxLayer) return gpxLayer.id; + + // Priority 2: route-* (ET-002 routing layers) + const routeLayer = style.layers.find(l => l.id.startsWith('route-')); + if (routeLayer) return routeLayer.id; + + return undefined; } // ─── Управление видимостью ──────────────────────────────────────── diff --git a/tests/api/test_gps_tracks_endpoint.py b/tests/api/test_gps_tracks_endpoint.py index fc433bc..a102872 100644 --- a/tests/api/test_gps_tracks_endpoint.py +++ b/tests/api/test_gps_tracks_endpoint.py @@ -313,10 +313,10 @@ async def test_i40_health_endpoint(db_with_tracks): assert resp.status_code == 200 data = resp.json() assert data["status"] == "ok" - assert "total_tracks" in data - assert data["total_tracks"] > 0 - assert "by_activity" in data - assert "recent_pipeline_runs" in data + assert "tracks_total" in data + assert data["tracks_total"] > 0 + assert "tracks_by_activity" in data + assert "last_pipeline_run" in data @pytest.mark.asyncio @@ -333,8 +333,8 @@ async def test_i40_health_empty_db(tmp_path): assert resp.status_code == 200 data = resp.json() - assert data["total_tracks"] == 0 - assert data["recent_pipeline_runs"] == [] + assert data["tracks_total"] == 0 + assert data["last_pipeline_run"] is None # ─── Cache clear endpoint ───────────────────────────────────────────────────── diff --git a/tests/web/__init__.py b/tests/web/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/web/gps_tracks.test.js b/tests/web/gps_tracks.test.js new file mode 100644 index 0000000..ed94131 --- /dev/null +++ b/tests/web/gps_tracks.test.js @@ -0,0 +1,288 @@ +'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']); +}); diff --git a/tests/web/test_gps_tracks.py b/tests/web/test_gps_tracks.py new file mode 100644 index 0000000..03f63a2 --- /dev/null +++ b/tests/web/test_gps_tracks.py @@ -0,0 +1,133 @@ +"""ET-008 — тесты модуля публичных GPS-треков (gps_tracks.js + endpoint). + +Изменение ET-008 — модуль `src/web/gps_tracks.js` + FastAPI endpoint +`/api/gps-tracks`. В CI исполняется только `pytest tests/`, поэтому файл +покрывает фронтендовую часть двумя способами: + +1. Статические проверки структуры gps_tracks.js — выполняются всегда. +2. Поведенческие JS unit-тесты — через встроенный тест-раннер Node + (`node --test`). Если `node` отсутствует — часть помечается `skip`. + +API-тесты endpoint живут в tests/api/test_gps_tracks_endpoint.py. + +Запуск JS-тестов напрямую: + node --test tests/web/gps_tracks.test.js +""" + +from __future__ import annotations + +import subprocess +from pathlib import Path +from shutil import which + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[2] +GPS_TRACKS_JS = REPO_ROOT / "src" / "web" / "gps_tracks.js" +JS_TEST = REPO_ROOT / "tests" / "web" / "gps_tracks.test.js" + + +def _read(path: Path) -> str: + assert path.is_file(), f"не найден {path}" + return path.read_text(encoding="utf-8") + + +# ────────────────────────────────────────────────────────────────────────────── +# Статические проверки gps_tracks.js +# ────────────────────────────────────────────────────────────────────────────── + +def test_gps_tracks_module_exists(): + """Модуль src/web/gps_tracks.js присутствует в репозитории.""" + assert GPS_TRACKS_JS.is_file(), "не найден src/web/gps_tracks.js" + + +def test_gps_tracks_find_insert_position_defined(): + """_findGpsInsertPosition() объявлена в модуле.""" + js = _read(GPS_TRACKS_JS) + assert "function _findGpsInsertPosition(" in js, ( + "_findGpsInsertPosition не объявлена в gps_tracks.js" + ) + + +def test_gps_tracks_find_insert_position_priority_gpx_first(): + """F-05: поиск gpx-layer-* идёт до route-* (приоритет 1 > приоритет 2).""" + js = _read(GPS_TRACKS_JS) + fn_start = js.index("function _findGpsInsertPosition(") + fn_end = js.index("\n}", fn_start) + fn_body = js[fn_start:fn_end] + + gpx_pos = fn_body.find("gpx-layer-") + route_pos = fn_body.find("route-") + assert gpx_pos != -1, "gpx-layer- не найден в _findGpsInsertPosition" + assert route_pos != -1, "route- не найден в _findGpsInsertPosition" + assert gpx_pos < route_pos, ( + "gpx-layer-* должен проверяться ДО route-* (приоритет 1 > приоритет 2)" + ) + + +def test_gps_tracks_find_insert_position_no_exact_route_line(): + """F-05: старый точный match 'route-line' удалён, используется startsWith.""" + js = _read(GPS_TRACKS_JS) + fn_start = js.index("function _findGpsInsertPosition(") + fn_end = js.index("\n}", fn_start) + fn_body = js[fn_start:fn_end] + assert "l.id === 'route-line'" not in fn_body, ( + "старый точный матч route-line не должен присутствовать (F-05)" + ) + + +def test_gps_tracks_state_object_defined(): + """window.gpsTracksLayer инициализируется в модуле.""" + js = _read(GPS_TRACKS_JS) + assert "window.gpsTracksLayer = {" in js, ( + "gps_tracks.js не инициализирует window.gpsTracksLayer" + ) + + +def test_gps_tracks_source_colors_defined(): + """GPS_SOURCE_COLORS объявлен для всех основных источников.""" + js = _read(GPS_TRACKS_JS) + for src in ("osm", "enduro_russia", "ttrails", "offmaps", "nakarte"): + assert src in js, f"GPS_SOURCE_COLORS не содержит ключ {src}" + + +def test_gps_tracks_activity_colors_defined(): + """GPS_ACTIVITY_COLORS объявлен для всех 7 типов активности.""" + js = _read(GPS_TRACKS_JS) + for act in ("enduro", "moto", "offroad", "bicycle", "hike", "ski", "other"): + assert act in js, f"GPS_ACTIVITY_COLORS не содержит ключ {act}" + + +def test_gps_tracks_build_color_expression_defined(): + """_buildColorExpression() объявлена в модуле.""" + js = _read(GPS_TRACKS_JS) + assert "function _buildColorExpression(" in js, ( + "_buildColorExpression не объявлена в gps_tracks.js" + ) + + +# ────────────────────────────────────────────────────────────────────────────── +# Поведенческие JS unit-тесты через Node +# ────────────────────────────────────────────────────────────────────────────── + +node_required = pytest.mark.skipif( + which("node") is None, + reason="node не установлен — поведенческие JS unit-тесты пропущены", +) + + +@node_required +def test_js_unit_tests_pass(): + """F-05 + filters + colors: behavioral JS-тесты gps_tracks.js через `node --test`.""" + assert JS_TEST.is_file(), f"не найден JS-тест {JS_TEST}" + node = which("node") + result = subprocess.run( + [node, "--test", str(JS_TEST)], + capture_output=True, + text=True, + cwd=str(REPO_ROOT), + ) + assert result.returncode == 0, ( + f"JS unit-тесты GPS-треков упали (код {result.returncode}):\n" + f"{result.stdout}\n{result.stderr}" + )