fix(gps-tracks): rename health fields and fix layer insert priority (F-04, F-05)
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:
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
// ─── Управление видимостью ────────────────────────────────────────
|
||||
|
||||
@@ -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
0
tests/web/__init__.py
Normal file
288
tests/web/gps_tracks.test.js
Normal file
288
tests/web/gps_tracks.test.js
Normal 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']);
|
||||
});
|
||||
133
tests/web/test_gps_tracks.py
Normal file
133
tests/web/test_gps_tracks.py
Normal 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}"
|
||||
)
|
||||
Reference in New Issue
Block a user