fix(gps-tracks): rename health fields and fix layer insert priority (F-04, F-05)
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

F-04: rename gps_health() response fields per tester feedback:
  - total_tracks → tracks_total
  - by_activity  → tracks_by_activity
  - recent_pipeline_runs (list) → last_pipeline_run (object | null)
  Change LIMIT from 10 to 1; fetch single row instead of a list.

F-05: rewrite _findGpsInsertPosition with explicit priority order:
  1. gpx-layer-* (ET-006 GPX file layers) — highest priority
  2. route-* (ET-002 routing layers)
  Remove old combined find() that lacked clear priority semantics.

Add tests/web/gps_tracks.test.js (22 JS unit tests via node:test):
  - _findGpsInsertPosition priority logic (9 cases)
  - Filter state management — default state assertions (5 cases)
  - Color palette mapping and _buildColorExpression (8 cases)

Add tests/web/test_gps_tracks.py — Python pytest runner (8 static
  checks + node --test invocation).

Refs: ET-008

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-01 14:18:06 +00:00
parent 3a6017cc82
commit ba356ae317
6 changed files with 450 additions and 17 deletions

View File

@@ -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),

View File

@@ -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;
}
// ─── Управление видимостью ────────────────────────────────────────

View File

@@ -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 ─────────────────────────────────────────────────────

0
tests/web/__init__.py Normal file
View File

View File

@@ -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']);
});

View File

@@ -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}"
)