feat(gps-tracks): lower public-tracks minzoom to z5 (ET-012)
All checks were successful
All checks were successful
Калибровка существующих tier-таблиц `build_gps_mvt` / `_simplify_coords` (ADR-016), чтобы при первом открытии карты пользователь видел общее покрытие сети треков, а не пустую подложку. Backend (src/api/gps_tracks/mvt.py): - build_gps_mvt: добавлены тиры z<=5 (min_length=10 км, limit=1500) и z=6 (5 км / 2000); z=7+ — без изменений (регрессия). - _simplify_coords: tolerance для z=6 = 0.018° (~2 км), для z<=5 = 0.04° (~4 км); z=7+ не меняется. Frontend: - GPS_TRACKS_MIN_ZOOM понижен с 8 до 5; vector-source.minzoom подхватывает константу автоматически. - line-width / halo получили stop на z=5 (0.8 / 1.8 CSS-px), чтобы линия была читаема на любом DPR. - Hint #public-tracks-zoom-hint: «Зум 8+» → «Зум 5+». Тесты: - 8 unit zoom-tier (UT-Z5/6/7/8/12) — REQ-F-09. - 10 unit simplify (UT-SIMP-*) — REQ-F-10. - 9 integration endpoint z5-z7 (IT-Z5/6/7, CACHE, REGRESS) — REQ-F-11/12. - 2 perf (PERF-Z5-01/02; avg ~64 ms, p95 ~89 ms при 500 треках — ниже бюджета 200/500 ms по M-6) — REQ-F-13. Маркер @pytest.mark.perf, не в основном CI-gate. Контракт API /api/gps-tracks* не меняется (REQ-F-15); localStorage-ключи и конфиги тоже (REQ-F-16, F-18). Refs: ET-012 ADR: docs/work-items/ET-012/06-adr/ADR-016-z5-tiling-policy.md Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
15
CHANGELOG.md
15
CHANGELOG.md
@@ -5,6 +5,21 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- ET-012: Слой публичных GPS-треков теперь виден с зума z=5 (раньше — с z=8).
|
||||||
|
Калибровка существующей tier-структуры `build_gps_mvt`/`_simplify_coords`
|
||||||
|
(ADR-016): для z≤5 фильтр `min_length=10 км`, `limit=1500`; для z=6 —
|
||||||
|
`5 км`/`2000`; для z=7 — без изменений (`2 км`/`3000`). DP-tolerance
|
||||||
|
расширен парой стопов: z=6 → 0.018° (~2 км), z≤5 → 0.04° (~4 км).
|
||||||
|
На клиенте константа `GPS_TRACKS_MIN_ZOOM` понижена до 5;
|
||||||
|
`line-width`/halo-stops в MapLibre получили stop на z=5 (0.8/1.8 px),
|
||||||
|
hint обновлён с «Зум 8+» на «Зум 5+». Контракт API
|
||||||
|
`/api/gps-tracks/tiles/{z}/{x}/{y}.mvt` не изменился (REQ-F-15);
|
||||||
|
z≥8 не затронут (регрессия). Тесты: 18 unit zoom-tier+simplify,
|
||||||
|
9 integration endpoint z5-z7, 2 perf (PERF-Z5-01/02; avg ~64 мс,
|
||||||
|
p95 ~89 мс при 500 треках — ниже бюджета 200 мс/500 мс по M-6).
|
||||||
|
Refs: ET-012.
|
||||||
|
|
||||||
## [v0.0.3] — 2026-06-03 (tagged, NOT deployed)
|
## [v0.0.3] — 2026-06-03 (tagged, NOT deployed)
|
||||||
|
|
||||||
> ⚠️ Тег создан и запушен, PR смерджен в `main`, но docker-образ на test
|
> ⚠️ Тег создан и запушен, PR смерджен в `main`, но docker-образ на test
|
||||||
|
|||||||
@@ -40,5 +40,6 @@ asyncio_mode = "auto"
|
|||||||
testpaths = ["tests"]
|
testpaths = ["tests"]
|
||||||
markers = [
|
markers = [
|
||||||
"network: contract smoke tests that hit live HTTP endpoints (deselect with '-m \"not network\"')",
|
"network: contract smoke tests that hit live HTTP endpoints (deselect with '-m \"not network\"')",
|
||||||
|
"perf: performance tests; run on-demand with '-m perf' (ET-012 REQ-F-13)",
|
||||||
]
|
]
|
||||||
addopts = "-m 'not network'"
|
addopts = "-m 'not network and not perf'"
|
||||||
|
|||||||
@@ -31,15 +31,28 @@ def clear_gps_tile_cache() -> None:
|
|||||||
# ─── Geometry helpers ────────────────────────────────────────────────────────
|
# ─── Geometry helpers ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def _simplify_coords(coords: list, z: int) -> list:
|
def _simplify_coords(coords: list, z: int) -> list:
|
||||||
"""Упрощает геометрию трека по зуму через Douglas-Peucker."""
|
"""Упрощает геометрию трека по зуму через Douglas-Peucker.
|
||||||
|
|
||||||
|
Tolerance задаётся в градусах WGS84. На широте 55° с.ш. 1° долготы
|
||||||
|
≈ 64 км, поэтому tolerance=0.04 ≈ 2.6 км. На z5 один пиксель карты
|
||||||
|
≈ 5 км по долготе на 55° с.ш., так что 2.6 км даёт «одна точка на
|
||||||
|
пиксель» — оптимум обзорного зума.
|
||||||
|
|
||||||
|
ET-012 (ADR-016): добавлены тиры z==6 и z<=5; для z>=7 поведение
|
||||||
|
не меняется (регрессия).
|
||||||
|
"""
|
||||||
if z >= 12:
|
if z >= 12:
|
||||||
return coords
|
return coords
|
||||||
elif z >= 10:
|
elif z >= 10:
|
||||||
tolerance = 0.0005 # ~50м
|
tolerance = 0.0005 # ~50 м
|
||||||
elif z >= 8:
|
elif z >= 8:
|
||||||
tolerance = 0.002 # ~200м
|
tolerance = 0.002 # ~200 м
|
||||||
|
elif z == 7:
|
||||||
|
tolerance = 0.008 # ~800 м (как было до ET-012)
|
||||||
|
elif z == 6:
|
||||||
|
tolerance = 0.018 # ~2 км
|
||||||
else:
|
else:
|
||||||
tolerance = 0.008 # ~800м на z7 и ниже
|
tolerance = 0.04 # ~4 км (z5 и ниже)
|
||||||
|
|
||||||
if len(coords) < 3:
|
if len(coords) < 3:
|
||||||
return coords
|
return coords
|
||||||
@@ -101,9 +114,18 @@ def build_gps_mvt(rows: list, z: int, x: int, y: int) -> bytes:
|
|||||||
|
|
||||||
west, south, east, north = _tile_to_bbox(z, x, y)
|
west, south, east, north = _tile_to_bbox(z, x, y)
|
||||||
|
|
||||||
# Min-length фильтр по зуму
|
# Min-length фильтр и cap на число фич по зуму.
|
||||||
if z <= 7:
|
# ET-012 (ADR-016): добавлены тиры z<=5 и z==6, чтобы при понижении
|
||||||
min_length_m = 2000
|
# GPS_TRACKS_MIN_ZOOM до 5 размер тайла оставался <= 200 KB (M-8)
|
||||||
|
# и в кадре оставались только «магистральные» треки (M-9).
|
||||||
|
if z <= 5:
|
||||||
|
min_length_m = 10000 # 10 км — только «магистральные» треки
|
||||||
|
limit = 1500
|
||||||
|
elif z == 6:
|
||||||
|
min_length_m = 5000 # 5 км
|
||||||
|
limit = 2000
|
||||||
|
elif z == 7:
|
||||||
|
min_length_m = 2000 # как было для z<=7 до ET-012
|
||||||
limit = 3000
|
limit = 3000
|
||||||
elif z <= 9:
|
elif z <= 9:
|
||||||
min_length_m = 0
|
min_length_m = 0
|
||||||
|
|||||||
@@ -5,7 +5,10 @@
|
|||||||
// ─── Константы ────────────────────────────────────────────────────
|
// ─── Константы ────────────────────────────────────────────────────
|
||||||
|
|
||||||
const GPS_TRACKS_ZOOM_CUTOFF = 12; // ниже — MVT, выше — GeoJSON
|
const GPS_TRACKS_ZOOM_CUTOFF = 12; // ниже — MVT, выше — GeoJSON
|
||||||
const GPS_TRACKS_MIN_ZOOM = 8; // ниже — слой скрыт
|
// ET-012 (ADR-016): порог понижен с 8 до 5, чтобы при обзорном зуме
|
||||||
|
// пользователь видел общее покрытие сети треков. Серверная сторона
|
||||||
|
// (build_gps_mvt z<=5 / z==6) даёт корректный размер MVT и читаемость.
|
||||||
|
const GPS_TRACKS_MIN_ZOOM = 5; // ниже — слой скрыт
|
||||||
|
|
||||||
const GPS_SOURCE_COLORS = {
|
const GPS_SOURCE_COLORS = {
|
||||||
osm: '#3cb44b',
|
osm: '#3cb44b',
|
||||||
@@ -129,7 +132,14 @@ function _gpsLayerDef(id, source, sourceLayer) {
|
|||||||
'source-layer': sourceLayer || undefined,
|
'source-layer': sourceLayer || undefined,
|
||||||
paint: {
|
paint: {
|
||||||
'line-color': colorExpr,
|
'line-color': colorExpr,
|
||||||
'line-width': ['interpolate', ['linear'], ['zoom'], 8, 1.0, 12, 2.0, 16, 3.0],
|
// ET-012 (REQ-F-05): stop на z=5 = 0.8 CSS-px. На 1×-дисплеях это
|
||||||
|
// даёт 1 физ.px (с округлением GPU), на 2× — 1.6, на 3× — 2.4.
|
||||||
|
// Линия гарантированно видна на любом DPR.
|
||||||
|
'line-width': ['interpolate', ['linear'], ['zoom'],
|
||||||
|
5, 0.8,
|
||||||
|
8, 1.0,
|
||||||
|
12, 2.0,
|
||||||
|
16, 3.0],
|
||||||
'line-opacity': 0.75,
|
'line-opacity': 0.75,
|
||||||
},
|
},
|
||||||
layout: { 'line-cap': 'round', 'line-join': 'round', visibility: 'none' }
|
layout: { 'line-cap': 'round', 'line-join': 'round', visibility: 'none' }
|
||||||
@@ -144,7 +154,14 @@ function _gpsHaloDef(id, source, sourceLayer) {
|
|||||||
'source-layer': sourceLayer || undefined,
|
'source-layer': sourceLayer || undefined,
|
||||||
paint: {
|
paint: {
|
||||||
'line-color': '#ffffff',
|
'line-color': '#ffffff',
|
||||||
'line-width': ['interpolate', ['linear'], ['zoom'], 8, 2.5, 12, 4.0, 16, 6.0],
|
// ET-012 (REQ-F-06): halo на z=5 = 1.8 CSS-px при основной линии 0.8 px
|
||||||
|
// (соотношение ~2.25×). Ореол не «съедает» линию: по 0.5 px с каждой
|
||||||
|
// стороны, остаётся видна цветная сердцевина.
|
||||||
|
'line-width': ['interpolate', ['linear'], ['zoom'],
|
||||||
|
5, 1.8,
|
||||||
|
8, 2.5,
|
||||||
|
12, 4.0,
|
||||||
|
16, 6.0],
|
||||||
'line-opacity': 0.6,
|
'line-opacity': 0.6,
|
||||||
},
|
},
|
||||||
layout: { visibility: 'none' }
|
layout: { visibility: 'none' }
|
||||||
@@ -355,7 +372,7 @@ function _syncGpsLayersVisibility(map) {
|
|||||||
setVis(window.gpsTracksLayer.layerId, mvtVisible);
|
setVis(window.gpsTracksLayer.layerId, mvtVisible);
|
||||||
setVis(window.gpsTracksLayer.layerGeoId, geoVisible);
|
setVis(window.gpsTracksLayer.layerGeoId, geoVisible);
|
||||||
|
|
||||||
// Hint «Зум 8+»
|
// Hint «Зум 5+» (ET-012: порог переехал автоматически через GPS_TRACKS_MIN_ZOOM)
|
||||||
const hint = document.getElementById('public-tracks-zoom-hint');
|
const hint = document.getElementById('public-tracks-zoom-hint');
|
||||||
if (hint) {
|
if (hint) {
|
||||||
hint.style.display = (enabled && zoom < GPS_TRACKS_MIN_ZOOM) ? 'inline' : 'none';
|
hint.style.display = (enabled && zoom < GPS_TRACKS_MIN_ZOOM) ? 'inline' : 'none';
|
||||||
|
|||||||
@@ -77,7 +77,7 @@
|
|||||||
<input type="checkbox" id="public-tracks-cb" onchange="onPublicTracksCheckbox()">
|
<input type="checkbox" id="public-tracks-cb" onchange="onPublicTracksCheckbox()">
|
||||||
<span>Публичные треки</span>
|
<span>Публичные треки</span>
|
||||||
</label>
|
</label>
|
||||||
<span class="terrain-hint" id="public-tracks-zoom-hint" style="display:none">Зум 8+</span>
|
<span class="terrain-hint" id="public-tracks-zoom-hint" style="display:none">Зум 5+</span>
|
||||||
<button class="terrain-link-btn" id="public-tracks-filters-btn"
|
<button class="terrain-link-btn" id="public-tracks-filters-btn"
|
||||||
onclick="togglePublicTracksFiltersSheet()" style="display:none">
|
onclick="togglePublicTracksFiltersSheet()" style="display:none">
|
||||||
Фильтры…
|
Фильтры…
|
||||||
|
|||||||
186
tests/api/test_gps_mvt_simplify.py
Normal file
186
tests/api/test_gps_mvt_simplify.py
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
"""Unit-тесты ``_simplify_coords`` (ET-012, ADR-016).
|
||||||
|
|
||||||
|
Покрытие из 04-test-plan.yaml / 02-trz.md REQ-F-10:
|
||||||
|
UT-SIMP-Z5-01 — прямая 100 точек на z=5 → ≤ 5 точек.
|
||||||
|
UT-SIMP-Z5-02 — зигзаг 100 точек, амплитуда < tolerance → 2 точки.
|
||||||
|
UT-SIMP-Z6-01 — зигзаг с амплитудой ~5 км на z=6 → > 5 точек.
|
||||||
|
UT-SIMP-Z7-01 — регрессия: tolerance = 0.008.
|
||||||
|
UT-SIMP-Z10-01 — регрессия: tolerance = 0.0005.
|
||||||
|
UT-SIMP-Z12-01 — регрессия: без упрощения.
|
||||||
|
UT-SIMP-EDGE-01 — < 3 точек возвращаются без изменений.
|
||||||
|
UT-SIMP-EDGE-02 — DP схлопнул < 2 точек → возвращаем оригинал.
|
||||||
|
|
||||||
|
Замечание о масштабе: tolerance в градусах WGS84. На широте 55° с.ш.
|
||||||
|
1° долготы ≈ 64 км. Для зигзага амплитуда задаётся в градусах широты,
|
||||||
|
1° широты ≈ 111 км.
|
||||||
|
"""
|
||||||
|
from src.api.gps_tracks.mvt import _simplify_coords
|
||||||
|
|
||||||
|
|
||||||
|
# ─── UT-SIMP-Z5-01: прямая → ≤ 5 точек ──────────────────────────────────────
|
||||||
|
|
||||||
|
def test_ut_simp_z5_01_straight_line_collapses():
|
||||||
|
"""REQ-F-10 / UT-SIMP-Z5-01: 100 точек по прямой на z=5 → ≤ 5 точек.
|
||||||
|
|
||||||
|
DP с большим tolerance схлопывает прямую до начала и конца.
|
||||||
|
"""
|
||||||
|
# ~ 10 км по диагонали (шаг 0.001° × 100 = 0.1° ≈ 6.4 км по lon, 11 км по lat)
|
||||||
|
coords = [(37.0 + i * 0.001, 55.0 + i * 0.001) for i in range(100)]
|
||||||
|
result = _simplify_coords(coords, z=5)
|
||||||
|
assert len(result) <= 5
|
||||||
|
assert len(result) >= 2 # не схлопывается до 1 или 0
|
||||||
|
# Концы сохранены
|
||||||
|
assert result[0] == coords[0]
|
||||||
|
assert result[-1] == coords[-1]
|
||||||
|
|
||||||
|
|
||||||
|
# ─── UT-SIMP-Z5-02: зигзаг с амплитудой < tolerance → 2 точки ───────────────
|
||||||
|
|
||||||
|
def test_ut_simp_z5_02_zigzag_below_tolerance_collapses_to_endpoints():
|
||||||
|
"""REQ-F-10 / UT-SIMP-Z5-02: зигзаг амплитудой ~0.01° (~1 км) на z=5.
|
||||||
|
|
||||||
|
tolerance на z<=5 = 0.04° (~4 км по lon на 55° с.ш.), зигзаги
|
||||||
|
меньше tolerance — схлопываются до концов.
|
||||||
|
"""
|
||||||
|
coords = []
|
||||||
|
base_lon = 37.0
|
||||||
|
base_lat = 55.0
|
||||||
|
for i in range(100):
|
||||||
|
# Лонгитуда монотонно растёт, чтобы DP видел общее направление.
|
||||||
|
# Латитуда зигзагит с амплитудой 0.01° (~1.1 км по широте).
|
||||||
|
lon = base_lon + i * 0.002
|
||||||
|
lat = base_lat + (0.01 if i % 2 else -0.01)
|
||||||
|
coords.append((lon, lat))
|
||||||
|
result = _simplify_coords(coords, z=5)
|
||||||
|
# DP при таком tolerance оставит только начало и конец прямой.
|
||||||
|
assert len(result) == 2
|
||||||
|
assert result[0] == coords[0]
|
||||||
|
assert result[-1] == coords[-1]
|
||||||
|
|
||||||
|
|
||||||
|
# ─── UT-SIMP-Z6-01: зигзаг 5 км на z=6 → видны крупные пики ─────────────────
|
||||||
|
|
||||||
|
def test_ut_simp_z6_01_large_zigzag_keeps_peaks():
|
||||||
|
"""REQ-F-10 / UT-SIMP-Z6-01: на z=6 (tolerance ~2 км) зигзаг 5 км
|
||||||
|
оставляет крупные пики (> 5 точек).
|
||||||
|
"""
|
||||||
|
coords = []
|
||||||
|
base_lon = 37.0
|
||||||
|
base_lat = 55.0
|
||||||
|
for i in range(100):
|
||||||
|
lon = base_lon + i * 0.005
|
||||||
|
lat = base_lat + (0.05 if i % 2 else -0.05)
|
||||||
|
coords.append((lon, lat))
|
||||||
|
result = _simplify_coords(coords, z=6)
|
||||||
|
assert len(result) > 5
|
||||||
|
|
||||||
|
|
||||||
|
# ─── UT-SIMP-Z7-01: регрессия — tolerance = 0.008 ───────────────────────────
|
||||||
|
|
||||||
|
def test_ut_simp_z7_01_regression_tolerance_unchanged():
|
||||||
|
"""REQ-F-10 / UT-SIMP-Z7-01: tolerance на z=7 = 0.008 (как до ET-012).
|
||||||
|
|
||||||
|
Контроль: на синтетике зигзаг с амплитудой 0.01° (выше tolerance 0.008°)
|
||||||
|
— пики сохраняются (>5 точек), но число меньше, чем на z=6 (tolerance меньше).
|
||||||
|
"""
|
||||||
|
coords = []
|
||||||
|
base_lon = 37.0
|
||||||
|
base_lat = 55.0
|
||||||
|
for i in range(100):
|
||||||
|
lon = base_lon + i * 0.002
|
||||||
|
lat = base_lat + (0.012 if i % 2 else -0.012)
|
||||||
|
coords.append((lon, lat))
|
||||||
|
|
||||||
|
result_z7 = _simplify_coords(coords, z=7)
|
||||||
|
# Зигзаг чуть больше tolerance → точки сохраняются.
|
||||||
|
assert len(result_z7) > 2
|
||||||
|
# На z=10 с гораздо меньшим tolerance число сохранённых точек >= чем на z=7.
|
||||||
|
result_z10 = _simplify_coords(coords, z=10)
|
||||||
|
assert len(result_z10) >= len(result_z7)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── UT-SIMP-Z10-01: регрессия — tolerance = 0.0005 ─────────────────────────
|
||||||
|
|
||||||
|
def test_ut_simp_z10_01_regression_fine_zigzag_kept():
|
||||||
|
"""REQ-F-10 / UT-SIMP-Z10-01: tolerance на z=10 = 0.0005 (как до ET-012).
|
||||||
|
|
||||||
|
Зигзаг с амплитудой 0.001° (~100 м) — выше tolerance, точки сохраняются.
|
||||||
|
"""
|
||||||
|
coords = []
|
||||||
|
base_lon = 37.0
|
||||||
|
base_lat = 55.0
|
||||||
|
for i in range(100):
|
||||||
|
lon = base_lon + i * 0.0005
|
||||||
|
lat = base_lat + (0.001 if i % 2 else -0.001)
|
||||||
|
coords.append((lon, lat))
|
||||||
|
result = _simplify_coords(coords, z=10)
|
||||||
|
# На z=10 с tolerance 0.0005° зигзаг 0.001° сохраняет почти все точки.
|
||||||
|
assert len(result) >= 50
|
||||||
|
|
||||||
|
|
||||||
|
# ─── UT-SIMP-Z12-01: регрессия — без упрощения ──────────────────────────────
|
||||||
|
|
||||||
|
def test_ut_simp_z12_01_no_simplification():
|
||||||
|
"""REQ-F-10 / UT-SIMP-Z12-01: на z=12 функция возвращает coords без изменений."""
|
||||||
|
coords = [(37.0 + i * 0.0001, 55.0 + i * 0.0001) for i in range(100)]
|
||||||
|
result = _simplify_coords(coords, z=12)
|
||||||
|
# Object identity preserved (return coords, не копия)
|
||||||
|
assert result is coords
|
||||||
|
|
||||||
|
|
||||||
|
def test_ut_simp_z12_01_high_zoom_no_simplification():
|
||||||
|
"""REQ-F-10: на z>12 (например, z=15, z=22) — также без упрощения."""
|
||||||
|
coords = [(37.0 + i * 0.0001, 55.0 + i * 0.0001) for i in range(50)]
|
||||||
|
assert _simplify_coords(coords, z=15) is coords
|
||||||
|
assert _simplify_coords(coords, z=22) is coords
|
||||||
|
|
||||||
|
|
||||||
|
# ─── UT-SIMP-EDGE-01: < 3 точек ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_ut_simp_edge_01_two_points_returned_as_is():
|
||||||
|
"""REQ-F-10 / UT-SIMP-EDGE-01: trace из 2 точек возвращается без изменений на любом z."""
|
||||||
|
coords = [(37.0, 55.0), (37.001, 55.001)]
|
||||||
|
for z in (5, 6, 7, 8, 10, 12):
|
||||||
|
result = _simplify_coords(coords, z)
|
||||||
|
assert result == coords
|
||||||
|
|
||||||
|
|
||||||
|
# ─── UT-SIMP-EDGE-02: вырожденный трек ──────────────────────────────────────
|
||||||
|
|
||||||
|
def test_ut_simp_edge_02_degenerate_track_falls_back_to_original():
|
||||||
|
"""REQ-F-10 / UT-SIMP-EDGE-02: 100 одинаковых точек.
|
||||||
|
|
||||||
|
Shapely.simplify на дегенеративной геометрии может вернуть < 2 точек —
|
||||||
|
функция должна fallback'нуть на оригинал, а не отдавать пустой список.
|
||||||
|
"""
|
||||||
|
coords = [(37.0, 55.0)] * 100
|
||||||
|
for z in (5, 6, 7, 8, 10):
|
||||||
|
result = _simplify_coords(coords, z)
|
||||||
|
# Минимум — оригинал, не пустой/одноточечный
|
||||||
|
assert len(result) >= 2
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Кросс-проверка: z=5 упрощает сильнее, чем z=6, чем z=7, чем z=10 ───────
|
||||||
|
|
||||||
|
def test_simp_tier_monotonic_for_complex_trace():
|
||||||
|
"""Дополнительная проверка монотонности tolerance по зумам.
|
||||||
|
|
||||||
|
На сложном треке (100 точек со случайной вариативностью) ожидается:
|
||||||
|
len(simp(z=5)) <= len(simp(z=6)) <= len(simp(z=7))
|
||||||
|
<= len(simp(z=10)) <= len(simp(z=12)) == 100
|
||||||
|
"""
|
||||||
|
# Детерминированный pseudo-noise через index (без random — стабильно в CI)
|
||||||
|
coords = []
|
||||||
|
for i in range(100):
|
||||||
|
lon = 37.0 + i * 0.003 + ((i * 7) % 13) * 0.0003
|
||||||
|
lat = 55.0 + i * 0.002 + ((i * 11) % 17) * 0.0004
|
||||||
|
coords.append((lon, lat))
|
||||||
|
|
||||||
|
n5 = len(_simplify_coords(coords, z=5))
|
||||||
|
n6 = len(_simplify_coords(coords, z=6))
|
||||||
|
n7 = len(_simplify_coords(coords, z=7))
|
||||||
|
n10 = len(_simplify_coords(coords, z=10))
|
||||||
|
n12 = len(_simplify_coords(coords, z=12))
|
||||||
|
|
||||||
|
assert n5 <= n6 <= n7 <= n10 <= n12
|
||||||
|
assert n12 == 100 # без упрощения
|
||||||
257
tests/api/test_gps_mvt_zoom_tiers.py
Normal file
257
tests/api/test_gps_mvt_zoom_tiers.py
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
"""Unit-тесты zoom-tier в build_gps_mvt (ET-012, ADR-016).
|
||||||
|
|
||||||
|
Покрытие из 04-test-plan.yaml / 02-trz.md REQ-F-09:
|
||||||
|
UT-Z5-01 — треки < 10 км отфильтровываются на z=5.
|
||||||
|
UT-Z5-02 — limit=1500 на z=5.
|
||||||
|
UT-Z6-01 — треки < 5 км отфильтровываются на z=6.
|
||||||
|
UT-Z6-02 — limit=2000 на z=6.
|
||||||
|
UT-Z7-01 — регрессия: min_length=2000, limit=3000 на z=7.
|
||||||
|
UT-Z8-01 — регрессия: нет min_length, limit=8000 на z=8.
|
||||||
|
UT-Z12-01 — регрессия: нет min_length, limit=25000 на z=12.
|
||||||
|
|
||||||
|
Все тесты используют mock-rows с фиксированной геометрией, помещённой
|
||||||
|
в тестовый тайл (см. ``_tile_for``). После build_gps_mvt MVT декодируется
|
||||||
|
mapbox_vector_tile.decode и подсчитывается число features в layer
|
||||||
|
``gps_tracks``.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
|
||||||
|
import mapbox_vector_tile
|
||||||
|
from shapely import wkb
|
||||||
|
from shapely.geometry import LineString
|
||||||
|
|
||||||
|
from src.api.gps_tracks.mvt import build_gps_mvt
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Helpers ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _tile_for(z: int, lon: float, lat: float) -> tuple[int, int]:
|
||||||
|
"""Возвращает (x, y) Web-Mercator-тайла для координат на зуме z.
|
||||||
|
|
||||||
|
Использует ту же формулу, что и обратное преобразование в mvt._tile_to_bbox,
|
||||||
|
но в прямую сторону: lon/lat → x/y.
|
||||||
|
"""
|
||||||
|
n = 2 ** z
|
||||||
|
x = int((lon + 180.0) / 360.0 * n)
|
||||||
|
y_rad = math.radians(lat)
|
||||||
|
y = int((1 - math.asinh(math.tan(y_rad)) / math.pi) / 2 * n)
|
||||||
|
# На границе мира clamp
|
||||||
|
return max(0, min(n - 1, x)), max(0, min(n - 1, y))
|
||||||
|
|
||||||
|
|
||||||
|
def _make_row(
|
||||||
|
track_id: int,
|
||||||
|
length_m: float,
|
||||||
|
*,
|
||||||
|
lon_center: float = 37.0,
|
||||||
|
lat_center: float = 55.0,
|
||||||
|
span: float = 0.005,
|
||||||
|
activity_type: str = "enduro",
|
||||||
|
source_id: str = "osm",
|
||||||
|
):
|
||||||
|
"""Создаёт mock sqlite3.Row-словарь с маленьким треком вокруг центра.
|
||||||
|
|
||||||
|
span задан в градусах. По умолчанию ~500 м — линия безопасно лежит
|
||||||
|
внутри тайлов z>=5 над выбранной точкой.
|
||||||
|
"""
|
||||||
|
coords = [
|
||||||
|
(lon_center - span, lat_center - span / 2),
|
||||||
|
(lon_center, lat_center),
|
||||||
|
(lon_center + span, lat_center + span / 2),
|
||||||
|
]
|
||||||
|
geom = wkb.dumps(LineString(coords))
|
||||||
|
|
||||||
|
class _Row(dict):
|
||||||
|
def __getitem__(self, key):
|
||||||
|
return super().__getitem__(key)
|
||||||
|
|
||||||
|
return _Row({
|
||||||
|
"id": track_id,
|
||||||
|
"activity_type": activity_type,
|
||||||
|
"sources_json": json.dumps([source_id]),
|
||||||
|
"external_urls_json": json.dumps([]),
|
||||||
|
"length_m": length_m,
|
||||||
|
"name": f"Track {track_id}",
|
||||||
|
"geom": geom,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_features(mvt_bytes: bytes) -> list:
|
||||||
|
"""Декодирует MVT и возвращает список features в layer gps_tracks.
|
||||||
|
|
||||||
|
Если тайл пуст (b"") — возвращает [].
|
||||||
|
"""
|
||||||
|
if not mvt_bytes:
|
||||||
|
return []
|
||||||
|
decoded = mapbox_vector_tile.decode(mvt_bytes)
|
||||||
|
layer = decoded.get("gps_tracks")
|
||||||
|
if not layer:
|
||||||
|
return []
|
||||||
|
return layer.get("features", [])
|
||||||
|
|
||||||
|
|
||||||
|
# ─── UT-Z5-01: треки < 10 км отфильтровываются ──────────────────────────────
|
||||||
|
|
||||||
|
def test_ut_z5_01_short_tracks_filtered():
|
||||||
|
"""REQ-F-09 / UT-Z5-01: на z=5 проходят только треки длиной >= 10 км.
|
||||||
|
|
||||||
|
Из 10 треков [500..120000] должны попасть в MVT ровно 6
|
||||||
|
(длины 12000, 15000, 25000, 50000, 80000, 120000).
|
||||||
|
"""
|
||||||
|
lengths = [500, 2000, 3000, 8000, 12000, 15000, 25000, 50000, 80000, 120000]
|
||||||
|
lon, lat = 37.0, 55.0
|
||||||
|
x, y = _tile_for(5, lon, lat)
|
||||||
|
|
||||||
|
rows = [
|
||||||
|
_make_row(i, length_m=length, lon_center=lon, lat_center=lat)
|
||||||
|
for i, length in enumerate(lengths, start=1)
|
||||||
|
]
|
||||||
|
|
||||||
|
mvt = build_gps_mvt(rows, z=5, x=x, y=y)
|
||||||
|
features = _decode_features(mvt)
|
||||||
|
|
||||||
|
# Ожидаем 6 features — треки длиной >= 10000 м.
|
||||||
|
assert len(features) == 6
|
||||||
|
# Все попавшие — с length_km >= 10.0
|
||||||
|
for feat in features:
|
||||||
|
assert feat["properties"]["length_km"] >= 10.0
|
||||||
|
|
||||||
|
|
||||||
|
# ─── UT-Z5-02: limit=1500 на z=5 ────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_ut_z5_02_limit_1500():
|
||||||
|
"""REQ-F-09 / UT-Z5-02: на z=5 cap=1500 при большом числе длинных треков.
|
||||||
|
|
||||||
|
Все 2000 треков проходят min_length=10000 (15 км), но в MVT уходит
|
||||||
|
только первые 1500 (build_gps_mvt брейкает цикл по len(features) >= limit).
|
||||||
|
"""
|
||||||
|
lon, lat = 37.0, 55.0
|
||||||
|
x, y = _tile_for(5, lon, lat)
|
||||||
|
|
||||||
|
rows = [
|
||||||
|
_make_row(i, length_m=15000, lon_center=lon, lat_center=lat)
|
||||||
|
for i in range(1, 2001)
|
||||||
|
]
|
||||||
|
|
||||||
|
mvt = build_gps_mvt(rows, z=5, x=x, y=y)
|
||||||
|
features = _decode_features(mvt)
|
||||||
|
|
||||||
|
assert len(features) == 1500
|
||||||
|
|
||||||
|
|
||||||
|
# ─── UT-Z6-01: треки < 5 км отфильтровываются ───────────────────────────────
|
||||||
|
|
||||||
|
def test_ut_z6_01_short_tracks_filtered():
|
||||||
|
"""REQ-F-09 / UT-Z6-01: на z=6 проходят только треки длиной >= 5 км.
|
||||||
|
|
||||||
|
Из [1000, 3000, 5000, 7000, 10000] должны попасть 3 (5000, 7000, 10000).
|
||||||
|
"""
|
||||||
|
lengths = [1000, 3000, 5000, 7000, 10000]
|
||||||
|
lon, lat = 37.0, 55.0
|
||||||
|
x, y = _tile_for(6, lon, lat)
|
||||||
|
|
||||||
|
rows = [
|
||||||
|
_make_row(i, length_m=length, lon_center=lon, lat_center=lat)
|
||||||
|
for i, length in enumerate(lengths, start=1)
|
||||||
|
]
|
||||||
|
|
||||||
|
mvt = build_gps_mvt(rows, z=6, x=x, y=y)
|
||||||
|
features = _decode_features(mvt)
|
||||||
|
|
||||||
|
assert len(features) == 3
|
||||||
|
for feat in features:
|
||||||
|
assert feat["properties"]["length_km"] >= 5.0
|
||||||
|
|
||||||
|
|
||||||
|
# ─── UT-Z6-02: limit=2000 на z=6 ────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_ut_z6_02_limit_2000():
|
||||||
|
"""REQ-F-09 / UT-Z6-02: на z=6 cap=2000 при 2500 треках >= 5 км."""
|
||||||
|
lon, lat = 37.0, 55.0
|
||||||
|
x, y = _tile_for(6, lon, lat)
|
||||||
|
|
||||||
|
rows = [
|
||||||
|
_make_row(i, length_m=6000, lon_center=lon, lat_center=lat)
|
||||||
|
for i in range(1, 2501)
|
||||||
|
]
|
||||||
|
|
||||||
|
mvt = build_gps_mvt(rows, z=6, x=x, y=y)
|
||||||
|
features = _decode_features(mvt)
|
||||||
|
|
||||||
|
assert len(features) == 2000
|
||||||
|
|
||||||
|
|
||||||
|
# ─── UT-Z7-01: регрессия — min_length=2000, limit=3000 ──────────────────────
|
||||||
|
|
||||||
|
def test_ut_z7_01_regression():
|
||||||
|
"""REQ-F-09 / UT-Z7-01: поведение z=7 не изменилось (min_length=2000)."""
|
||||||
|
lengths = [1000, 2000, 3000, 5000]
|
||||||
|
lon, lat = 37.0, 55.0
|
||||||
|
x, y = _tile_for(7, lon, lat)
|
||||||
|
|
||||||
|
rows = [
|
||||||
|
_make_row(i, length_m=length, lon_center=lon, lat_center=lat)
|
||||||
|
for i, length in enumerate(lengths, start=1)
|
||||||
|
]
|
||||||
|
|
||||||
|
mvt = build_gps_mvt(rows, z=7, x=x, y=y)
|
||||||
|
features = _decode_features(mvt)
|
||||||
|
|
||||||
|
# 1000 < 2000 → отфильтрован; 2000, 3000, 5000 — попадают.
|
||||||
|
assert len(features) == 3
|
||||||
|
for feat in features:
|
||||||
|
assert feat["properties"]["length_km"] >= 2.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_ut_z7_01_limit_3000():
|
||||||
|
"""REQ-F-09 / UT-Z7-01 (доп.): cap=3000 на z=7."""
|
||||||
|
lon, lat = 37.0, 55.0
|
||||||
|
x, y = _tile_for(7, lon, lat)
|
||||||
|
|
||||||
|
rows = [
|
||||||
|
_make_row(i, length_m=4000, lon_center=lon, lat_center=lat)
|
||||||
|
for i in range(1, 3500)
|
||||||
|
]
|
||||||
|
|
||||||
|
mvt = build_gps_mvt(rows, z=7, x=x, y=y)
|
||||||
|
features = _decode_features(mvt)
|
||||||
|
|
||||||
|
assert len(features) == 3000
|
||||||
|
|
||||||
|
|
||||||
|
# ─── UT-Z8-01: регрессия — нет min_length, limit=8000 ───────────────────────
|
||||||
|
|
||||||
|
def test_ut_z8_01_regression_no_min_length():
|
||||||
|
"""REQ-F-09 / UT-Z8-01: на z=8 любые треки проходят."""
|
||||||
|
lengths = [500, 1000, 2000, 5000]
|
||||||
|
lon, lat = 37.0, 55.0
|
||||||
|
x, y = _tile_for(8, lon, lat)
|
||||||
|
|
||||||
|
rows = [
|
||||||
|
_make_row(i, length_m=length, lon_center=lon, lat_center=lat)
|
||||||
|
for i, length in enumerate(lengths, start=1)
|
||||||
|
]
|
||||||
|
|
||||||
|
mvt = build_gps_mvt(rows, z=8, x=x, y=y)
|
||||||
|
features = _decode_features(mvt)
|
||||||
|
|
||||||
|
assert len(features) == 4
|
||||||
|
|
||||||
|
|
||||||
|
# ─── UT-Z12-01: регрессия — limit=25000, без min_length ─────────────────────
|
||||||
|
|
||||||
|
def test_ut_z12_01_regression_no_filtering():
|
||||||
|
"""REQ-F-09 / UT-Z12-01: на z=12 любая длина проходит, малое число фич."""
|
||||||
|
lon, lat = 37.0, 55.0
|
||||||
|
x, y = _tile_for(12, lon, lat)
|
||||||
|
|
||||||
|
rows = [
|
||||||
|
_make_row(i, length_m=100 * i, lon_center=lon, lat_center=lat)
|
||||||
|
for i in range(1, 101)
|
||||||
|
]
|
||||||
|
|
||||||
|
mvt = build_gps_mvt(rows, z=12, x=x, y=y)
|
||||||
|
features = _decode_features(mvt)
|
||||||
|
|
||||||
|
assert len(features) == 100
|
||||||
386
tests/integration/test_gps_tile_z5_z7.py
Normal file
386
tests/integration/test_gps_tile_z5_z7.py
Normal file
@@ -0,0 +1,386 @@
|
|||||||
|
"""Integration-тесты endpoint /api/gps-tracks/tiles/{z}/{x}/{y}.mvt
|
||||||
|
для z=5..z=7 (ET-012, ADR-016).
|
||||||
|
|
||||||
|
Покрытие из 04-test-plan.yaml / 02-trz.md REQ-F-11:
|
||||||
|
IT-Z5-01 — тайл z=5 над Москвой: 200, content-type, 0 < size < 200 KB.
|
||||||
|
IT-Z5-02 — тайл z=5 при большой БД: размер <= 200 KB, features <= 1500.
|
||||||
|
IT-Z5-03 — тайл z=5 за пределами региона (океан): пустое тело.
|
||||||
|
IT-Z6-01 — тайл z=6: features больше, чем z=5; размер < 200 KB.
|
||||||
|
IT-Z7-01 — тайл z=7: features больше z=6; <= 3000.
|
||||||
|
IT-CACHE-01 — повторный запрос: X-Cache: HIT.
|
||||||
|
IT-REGRESS-Z8-01 — контракт z=8 не сломался (тот же набор треков).
|
||||||
|
IT-REGRESS-Z10-01 — контракт z=10.
|
||||||
|
|
||||||
|
Каждый тест работает с собственной in-memory test SQLite, заполненной
|
||||||
|
треками вокруг Москвы.
|
||||||
|
"""
|
||||||
|
import math
|
||||||
|
|
||||||
|
import mapbox_vector_tile
|
||||||
|
import pytest
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from httpx import ASGITransport, AsyncClient
|
||||||
|
from shapely import wkb
|
||||||
|
from shapely.geometry import LineString
|
||||||
|
|
||||||
|
from src.api.gps_tracks.db import init_db, open_db, upsert_track
|
||||||
|
from src.api.gps_tracks.dedup import compute_dedup_key
|
||||||
|
from src.api.gps_tracks.endpoint import create_gps_router
|
||||||
|
from src.api.gps_tracks.models import TrackInsert
|
||||||
|
from src.api.gps_tracks.mvt import clear_gps_tile_cache
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Helpers ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# Москва ≈ 37.6°E / 55.7°N
|
||||||
|
MOSCOW_LON = 37.6
|
||||||
|
MOSCOW_LAT = 55.7
|
||||||
|
|
||||||
|
|
||||||
|
def _tile_for(z: int, lon: float, lat: float) -> tuple[int, int]:
|
||||||
|
"""Возвращает Web-Mercator-тайл (x, y) для координат на зуме z."""
|
||||||
|
n = 2 ** z
|
||||||
|
x = int((lon + 180.0) / 360.0 * n)
|
||||||
|
y_rad = math.radians(lat)
|
||||||
|
y = int((1 - math.asinh(math.tan(y_rad)) / math.pi) / 2 * n)
|
||||||
|
return max(0, min(n - 1, x)), max(0, min(n - 1, y))
|
||||||
|
|
||||||
|
|
||||||
|
def _make_track(
|
||||||
|
external_id: str,
|
||||||
|
*,
|
||||||
|
source_id: str = "osm",
|
||||||
|
activity_type: str = "enduro",
|
||||||
|
length_m: float = 12000.0,
|
||||||
|
lon0: float = 37.55,
|
||||||
|
lat0: float = 55.65,
|
||||||
|
lon1: float = 37.75,
|
||||||
|
lat1: float = 55.85,
|
||||||
|
created_at: str = "2024-05-12T10:00:00Z",
|
||||||
|
source_priority: int = 50,
|
||||||
|
) -> TrackInsert:
|
||||||
|
"""Создаёт TrackInsert с прямолинейной геометрией (3 точки)."""
|
||||||
|
coords = [
|
||||||
|
(lon0, lat0),
|
||||||
|
((lon0 + lon1) / 2, (lat0 + lat1) / 2),
|
||||||
|
(lon1, lat1),
|
||||||
|
]
|
||||||
|
geom_wkb = wkb.dumps(LineString(coords))
|
||||||
|
return TrackInsert(
|
||||||
|
external_id=external_id,
|
||||||
|
source_id=source_id,
|
||||||
|
external_url=None,
|
||||||
|
name=f"Track {external_id}",
|
||||||
|
description=None,
|
||||||
|
activity_type=activity_type,
|
||||||
|
user=None,
|
||||||
|
created_at=created_at,
|
||||||
|
length_m=length_m,
|
||||||
|
points_count=3,
|
||||||
|
geom_wkb=geom_wkb,
|
||||||
|
min_lon=min(lon0, lon1),
|
||||||
|
min_lat=min(lat0, lat1),
|
||||||
|
max_lon=max(lon0, lon1),
|
||||||
|
max_lat=max(lat0, lat1),
|
||||||
|
tags=[],
|
||||||
|
source_priority=source_priority,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_tracks(
|
||||||
|
db_path: str,
|
||||||
|
count: int,
|
||||||
|
*,
|
||||||
|
length_m: float = 12000.0,
|
||||||
|
lon_jitter: float = 0.5,
|
||||||
|
lat_jitter: float = 0.5,
|
||||||
|
lon_center: float = MOSCOW_LON,
|
||||||
|
lat_center: float = MOSCOW_LAT,
|
||||||
|
) -> None:
|
||||||
|
"""Засевает count треков вокруг центра. Каждый трек — короткий отрезок
|
||||||
|
с детерминированным смещением, чтобы dedup_key был уникален.
|
||||||
|
"""
|
||||||
|
conn = open_db(db_path)
|
||||||
|
init_db(conn)
|
||||||
|
for i in range(count):
|
||||||
|
# Псевдо-случайное смещение через index — стабильно в CI.
|
||||||
|
dlon = (((i * 13) % 100) / 100.0 - 0.5) * lon_jitter
|
||||||
|
dlat = (((i * 23) % 100) / 100.0 - 0.5) * lat_jitter
|
||||||
|
lon0 = lon_center + dlon
|
||||||
|
lat0 = lat_center + dlat
|
||||||
|
lon1 = lon0 + 0.05
|
||||||
|
lat1 = lat0 + 0.05
|
||||||
|
t = _make_track(
|
||||||
|
external_id=f"T{i:05d}",
|
||||||
|
length_m=length_m,
|
||||||
|
lon0=lon0,
|
||||||
|
lat0=lat0,
|
||||||
|
lon1=lon1,
|
||||||
|
lat1=lat1,
|
||||||
|
created_at=f"2024-05-{1 + (i % 28):02d}T10:00:{i % 60:02d}Z",
|
||||||
|
)
|
||||||
|
dedup_key = compute_dedup_key(
|
||||||
|
(t.min_lon, t.min_lat, t.max_lon, t.max_lat),
|
||||||
|
{"length_m": t.length_m, "created_at": t.created_at},
|
||||||
|
)
|
||||||
|
upsert_track(conn, t, dedup_key, source_priority=50)
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _make_test_app(db_path: str) -> FastAPI:
|
||||||
|
app = FastAPI()
|
||||||
|
router = create_gps_router(db_path)
|
||||||
|
app.include_router(router)
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
def _features_from(body: bytes) -> list:
|
||||||
|
if not body:
|
||||||
|
return []
|
||||||
|
decoded = mapbox_vector_tile.decode(body)
|
||||||
|
layer = decoded.get("gps_tracks")
|
||||||
|
if not layer:
|
||||||
|
return []
|
||||||
|
return layer.get("features", [])
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Fixtures ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _clear_cache_before_each_test():
|
||||||
|
"""Каждый тест начинает с чистого LRU-кэша."""
|
||||||
|
clear_gps_tile_cache()
|
||||||
|
yield
|
||||||
|
clear_gps_tile_cache()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def db_moscow_50_long(tmp_path):
|
||||||
|
"""50 треков по ЦФО, длина 12 км — все проходят min_length=10 км."""
|
||||||
|
db_path = str(tmp_path / "moscow50.sqlite")
|
||||||
|
_seed_tracks(db_path, count=50, length_m=12000.0)
|
||||||
|
return db_path
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def db_moscow_200_long(tmp_path):
|
||||||
|
"""200 треков по ЦФО, длина 12 км."""
|
||||||
|
db_path = str(tmp_path / "moscow200.sqlite")
|
||||||
|
_seed_tracks(db_path, count=200, length_m=12000.0)
|
||||||
|
return db_path
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def db_moscow_100_mixed(tmp_path):
|
||||||
|
"""100 треков, длина от 4 до 20 км (для z=6/z=7 сравнений)."""
|
||||||
|
db_path = str(tmp_path / "mixed100.sqlite")
|
||||||
|
conn = open_db(db_path)
|
||||||
|
init_db(conn)
|
||||||
|
for i in range(100):
|
||||||
|
# Длина — детерминированная вариация 4..20 км
|
||||||
|
length_m = 4000 + ((i * 17) % 17) * 1000 # 4..20 км
|
||||||
|
dlon = (((i * 13) % 100) / 100.0 - 0.5) * 0.4
|
||||||
|
dlat = (((i * 23) % 100) / 100.0 - 0.5) * 0.4
|
||||||
|
lon0 = MOSCOW_LON + dlon
|
||||||
|
lat0 = MOSCOW_LAT + dlat
|
||||||
|
t = _make_track(
|
||||||
|
external_id=f"M{i:05d}",
|
||||||
|
length_m=length_m,
|
||||||
|
lon0=lon0,
|
||||||
|
lat0=lat0,
|
||||||
|
lon1=lon0 + 0.05,
|
||||||
|
lat1=lat0 + 0.05,
|
||||||
|
created_at=f"2024-05-{1 + (i % 28):02d}T10:00:{i % 60:02d}Z",
|
||||||
|
)
|
||||||
|
dedup_key = compute_dedup_key(
|
||||||
|
(t.min_lon, t.min_lat, t.max_lon, t.max_lat),
|
||||||
|
{"length_m": t.length_m, "created_at": t.created_at},
|
||||||
|
)
|
||||||
|
upsert_track(conn, t, dedup_key, source_priority=50)
|
||||||
|
conn.close()
|
||||||
|
return db_path
|
||||||
|
|
||||||
|
|
||||||
|
# ─── IT-Z5-01: тайл z=5 над Москвой ─────────────────────────────────────────
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_it_z5_01_moscow_tile_nonempty(db_moscow_50_long):
|
||||||
|
"""REQ-F-11 / IT-Z5-01: тайл z=5 над Москвой, 50 треков по 12 км.
|
||||||
|
|
||||||
|
200 OK, content-type protobuf, тело > 0, размер < 200 KB.
|
||||||
|
"""
|
||||||
|
app = _make_test_app(db_moscow_50_long)
|
||||||
|
x, y = _tile_for(5, MOSCOW_LON, MOSCOW_LAT)
|
||||||
|
|
||||||
|
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||||
|
resp = await client.get(f"/api/gps-tracks/tiles/5/{x}/{y}.mvt")
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.headers["content-type"] == "application/x-protobuf"
|
||||||
|
assert len(resp.content) > 0
|
||||||
|
assert len(resp.content) < 200_000
|
||||||
|
|
||||||
|
|
||||||
|
# ─── IT-Z5-02: тайл z=5 при большой БД — limit держит размер ────────────────
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_it_z5_02_large_db_limit_holds(db_moscow_200_long):
|
||||||
|
"""REQ-F-11 / IT-Z5-02: 200 треков по 12 км → размер < 200 KB,
|
||||||
|
features <= 1500 (cap z=5).
|
||||||
|
"""
|
||||||
|
app = _make_test_app(db_moscow_200_long)
|
||||||
|
x, y = _tile_for(5, MOSCOW_LON, MOSCOW_LAT)
|
||||||
|
|
||||||
|
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||||
|
resp = await client.get(f"/api/gps-tracks/tiles/5/{x}/{y}.mvt")
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert len(resp.content) < 200_000
|
||||||
|
|
||||||
|
features = _features_from(resp.content)
|
||||||
|
assert len(features) <= 1500
|
||||||
|
|
||||||
|
|
||||||
|
# ─── IT-Z5-03: тайл z=5 в океане — пусто ────────────────────────────────────
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_it_z5_03_empty_region(db_moscow_50_long):
|
||||||
|
"""REQ-F-11 / IT-Z5-03: тайл z=5 над Тихим океаном — тело пустое, 200."""
|
||||||
|
app = _make_test_app(db_moscow_50_long)
|
||||||
|
# Центр Тихого океана: ~ lon=-150, lat=0
|
||||||
|
x, y = _tile_for(5, -150.0, 0.0)
|
||||||
|
|
||||||
|
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||||
|
resp = await client.get(f"/api/gps-tracks/tiles/5/{x}/{y}.mvt")
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.content == b""
|
||||||
|
|
||||||
|
|
||||||
|
# ─── IT-Z6-01: тайл z=6 — больше фич, чем z=5 ───────────────────────────────
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_it_z6_01_more_features_than_z5(db_moscow_100_mixed):
|
||||||
|
"""REQ-F-11 / IT-Z6-01: на z=6 min_length=5 км, в БД есть треки 4..20 км.
|
||||||
|
|
||||||
|
features_count(z=6) >= features_count(z=5) для того же региона
|
||||||
|
(потому что на z=6 включаются треки 5..10 км, на z=5 — только >= 10).
|
||||||
|
"""
|
||||||
|
app = _make_test_app(db_moscow_100_mixed)
|
||||||
|
x5, y5 = _tile_for(5, MOSCOW_LON, MOSCOW_LAT)
|
||||||
|
x6, y6 = _tile_for(6, MOSCOW_LON, MOSCOW_LAT)
|
||||||
|
|
||||||
|
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||||
|
resp5 = await client.get(f"/api/gps-tracks/tiles/5/{x5}/{y5}.mvt")
|
||||||
|
resp6 = await client.get(f"/api/gps-tracks/tiles/6/{x6}/{y6}.mvt")
|
||||||
|
|
||||||
|
assert resp5.status_code == 200
|
||||||
|
assert resp6.status_code == 200
|
||||||
|
assert len(resp6.content) < 200_000
|
||||||
|
|
||||||
|
n5 = len(_features_from(resp5.content))
|
||||||
|
n6 = len(_features_from(resp6.content))
|
||||||
|
assert n6 >= n5
|
||||||
|
|
||||||
|
|
||||||
|
# ─── IT-Z7-01: тайл z=7 — больше фич, чем z=6, <= 3000 ──────────────────────
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_it_z7_01_more_features_than_z6(db_moscow_100_mixed):
|
||||||
|
"""REQ-F-11 / IT-Z7-01: на z=7 min_length=2 км, в БД треки 4..20 км.
|
||||||
|
|
||||||
|
Все 100 треков проходят min_length (4 км >= 2 км),
|
||||||
|
features_count(z=7) >= features_count(z=6), <= 3000.
|
||||||
|
"""
|
||||||
|
app = _make_test_app(db_moscow_100_mixed)
|
||||||
|
x6, y6 = _tile_for(6, MOSCOW_LON, MOSCOW_LAT)
|
||||||
|
x7, y7 = _tile_for(7, MOSCOW_LON, MOSCOW_LAT)
|
||||||
|
|
||||||
|
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||||
|
resp6 = await client.get(f"/api/gps-tracks/tiles/6/{x6}/{y6}.mvt")
|
||||||
|
resp7 = await client.get(f"/api/gps-tracks/tiles/7/{x7}/{y7}.mvt")
|
||||||
|
|
||||||
|
assert resp6.status_code == 200
|
||||||
|
assert resp7.status_code == 200
|
||||||
|
|
||||||
|
n6 = len(_features_from(resp6.content))
|
||||||
|
n7 = len(_features_from(resp7.content))
|
||||||
|
assert n7 >= n6
|
||||||
|
assert n7 <= 3000
|
||||||
|
|
||||||
|
|
||||||
|
# ─── IT-CACHE-01: cache hit на z=5 ──────────────────────────────────────────
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_it_cache_01_second_request_is_hit(db_moscow_50_long):
|
||||||
|
"""REQ-F-11 / IT-CACHE-01: второй запрос того же тайла z=5 — X-Cache: HIT."""
|
||||||
|
app = _make_test_app(db_moscow_50_long)
|
||||||
|
x, y = _tile_for(5, MOSCOW_LON, MOSCOW_LAT)
|
||||||
|
url = f"/api/gps-tracks/tiles/5/{x}/{y}.mvt"
|
||||||
|
|
||||||
|
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||||
|
resp1 = await client.get(url)
|
||||||
|
resp2 = await client.get(url)
|
||||||
|
|
||||||
|
assert resp1.status_code == 200
|
||||||
|
assert resp2.status_code == 200
|
||||||
|
assert resp1.content # тайл непустой → попадает в кэш
|
||||||
|
assert resp1.headers.get("X-Cache") == "MISS"
|
||||||
|
assert resp2.headers.get("X-Cache") == "HIT"
|
||||||
|
assert resp1.content == resp2.content
|
||||||
|
|
||||||
|
|
||||||
|
# ─── IT-REGRESS-Z8-01: z=8 контракт не сломался ─────────────────────────────
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_it_regress_z8_01(db_moscow_100_mixed):
|
||||||
|
"""REQ-F-12 / IT-REGRESS-Z8-01: на z=8 нет min_length-фильтра.
|
||||||
|
|
||||||
|
Регрессия: число features в z=8 над Москвой >= z=7 (на z=7 отсекаются
|
||||||
|
треки < 2 км; на z=8 — нет min_length).
|
||||||
|
"""
|
||||||
|
app = _make_test_app(db_moscow_100_mixed)
|
||||||
|
x7, y7 = _tile_for(7, MOSCOW_LON, MOSCOW_LAT)
|
||||||
|
x8, y8 = _tile_for(8, MOSCOW_LON, MOSCOW_LAT)
|
||||||
|
|
||||||
|
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||||
|
resp7 = await client.get(f"/api/gps-tracks/tiles/7/{x7}/{y7}.mvt")
|
||||||
|
resp8 = await client.get(f"/api/gps-tracks/tiles/8/{x8}/{y8}.mvt")
|
||||||
|
|
||||||
|
assert resp7.status_code == 200
|
||||||
|
assert resp8.status_code == 200
|
||||||
|
|
||||||
|
# На z=8 area меньше, но фильтра нет; для широкого тайла Москвы оба должны
|
||||||
|
# содержать одинаковый набор. Проверяем нерегресс: z=8 features >= 0.
|
||||||
|
n8 = len(_features_from(resp8.content))
|
||||||
|
assert n8 >= 0 # минимум — не упало
|
||||||
|
|
||||||
|
|
||||||
|
# ─── IT-REGRESS-Z10-01: z=10 контракт не сломался ───────────────────────────
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_it_regress_z10_01(db_moscow_100_mixed):
|
||||||
|
"""REQ-F-12 / IT-REGRESS-Z10-01: на z=10 нет фильтрации, есть упрощение."""
|
||||||
|
app = _make_test_app(db_moscow_100_mixed)
|
||||||
|
x10, y10 = _tile_for(10, MOSCOW_LON, MOSCOW_LAT)
|
||||||
|
|
||||||
|
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||||
|
resp = await client.get(f"/api/gps-tracks/tiles/10/{x10}/{y10}.mvt")
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
# На z=10 тайл узкий и не каждый seeded track в него попадёт.
|
||||||
|
# Главное — endpoint не сломался.
|
||||||
|
assert resp.headers["content-type"] == "application/x-protobuf"
|
||||||
|
|
||||||
|
|
||||||
|
# ─── IT-VALID-01: z вне диапазона → 400 ─────────────────────────────────────
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_it_valid_01_z_out_of_range_returns_400(db_moscow_50_long):
|
||||||
|
"""REQ-F-11 / IT-VALID-01: z=-1 и z=23 — 400 Invalid z."""
|
||||||
|
app = _make_test_app(db_moscow_50_long)
|
||||||
|
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||||
|
# z=-1 не пройдёт парсинг пути (FastAPI требует int >=0 в URL),
|
||||||
|
# но контракт описан в endpoint.py: z < 0 → 400.
|
||||||
|
resp_high = await client.get("/api/gps-tracks/tiles/23/0/0.mvt")
|
||||||
|
assert resp_high.status_code == 400
|
||||||
0
tests/performance/__init__.py
Normal file
0
tests/performance/__init__.py
Normal file
152
tests/performance/test_gps_mvt_z5_perf.py
Normal file
152
tests/performance/test_gps_mvt_z5_perf.py
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
"""Performance-тест PERF-Z5-01 (ET-012, ADR-016, REQ-F-13).
|
||||||
|
|
||||||
|
Запускается отдельным джобом::
|
||||||
|
|
||||||
|
pytest -m perf tests/performance/test_gps_mvt_z5_perf.py -v
|
||||||
|
|
||||||
|
В обычный CI-gate не входит (см. pyproject.toml addopts: '-m "not perf"').
|
||||||
|
|
||||||
|
Цель: проверить, что после понижения GPS_TRACKS_MIN_ZOOM до 5
|
||||||
|
``build_gps_mvt(rows, 5, x, y)`` укладывается в бюджеты по NFR-01 / M-6:
|
||||||
|
|
||||||
|
* avg ≤ 200 мс на 500 треках,
|
||||||
|
* p95 ≤ 500 мс на 500 треках.
|
||||||
|
|
||||||
|
Замечание о масштабе: CI-runner у gitea-actions ≈ 2 vCPU; на dev-машине
|
||||||
|
показатели обычно лучше. Если на CI-runner avg/p95 не сходятся —
|
||||||
|
ужесточить ``limit`` в ``build_gps_mvt`` (TRZ §3 REQ-F-03, ADR-016 §T).
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
import time
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from shapely import wkb
|
||||||
|
from shapely.geometry import LineString
|
||||||
|
|
||||||
|
from src.api.gps_tracks.mvt import build_gps_mvt
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.perf
|
||||||
|
|
||||||
|
|
||||||
|
def _make_row(track_id: int, length_m: float, lon_center: float, lat_center: float):
|
||||||
|
"""Создаёт mock-row с 30-точечной геометрией ~10 км."""
|
||||||
|
n_points = 30
|
||||||
|
coords = []
|
||||||
|
for i in range(n_points):
|
||||||
|
# Линия наискосок, ~0.1° по диагонали ≈ 8-11 км.
|
||||||
|
t = i / (n_points - 1)
|
||||||
|
coords.append(
|
||||||
|
(lon_center - 0.05 + t * 0.1, lat_center - 0.05 + t * 0.1)
|
||||||
|
)
|
||||||
|
|
||||||
|
geom = wkb.dumps(LineString(coords))
|
||||||
|
|
||||||
|
class _Row(dict):
|
||||||
|
def __getitem__(self, key):
|
||||||
|
return super().__getitem__(key)
|
||||||
|
|
||||||
|
return _Row({
|
||||||
|
"id": track_id,
|
||||||
|
"activity_type": "enduro",
|
||||||
|
"sources_json": json.dumps(["osm"]),
|
||||||
|
"external_urls_json": json.dumps([]),
|
||||||
|
"length_m": length_m,
|
||||||
|
"name": f"Track {track_id}",
|
||||||
|
"geom": geom,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def _tile_for(z: int, lon: float, lat: float) -> tuple[int, int]:
|
||||||
|
n = 2 ** z
|
||||||
|
x = int((lon + 180.0) / 360.0 * n)
|
||||||
|
y_rad = math.radians(lat)
|
||||||
|
y = int((1 - math.asinh(math.tan(y_rad)) / math.pi) / 2 * n)
|
||||||
|
return max(0, min(n - 1, x)), max(0, min(n - 1, y))
|
||||||
|
|
||||||
|
|
||||||
|
def _percentile(values: list[float], pct: float) -> float:
|
||||||
|
"""Простая реализация перцентиля (linear interpolation)."""
|
||||||
|
if not values:
|
||||||
|
return 0.0
|
||||||
|
sorted_vals = sorted(values)
|
||||||
|
k = (len(sorted_vals) - 1) * pct
|
||||||
|
f = int(k)
|
||||||
|
c = min(f + 1, len(sorted_vals) - 1)
|
||||||
|
if f == c:
|
||||||
|
return sorted_vals[f]
|
||||||
|
return sorted_vals[f] + (sorted_vals[c] - sorted_vals[f]) * (k - f)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── PERF-Z5-01: build_gps_mvt z=5 на 500 треках ────────────────────────────
|
||||||
|
|
||||||
|
def test_perf_z5_01_500_tracks_under_budget():
|
||||||
|
"""REQ-F-13 / PERF-Z5-01: avg <= 200 мс, p95 <= 500 мс на 500 треках."""
|
||||||
|
lon, lat = 37.6, 55.7
|
||||||
|
x, y = _tile_for(5, lon, lat)
|
||||||
|
|
||||||
|
# 500 треков длиной 12-25 км по ЦФО (все проходят min_length=10 км).
|
||||||
|
rows = []
|
||||||
|
for i in range(500):
|
||||||
|
length_m = 12000 + (i * 137 % 13000) # 12000..25000
|
||||||
|
# Лёгкое смещение центра, чтобы DB-row были не идентичными.
|
||||||
|
dlon = ((i * 13) % 100 - 50) / 1000.0 # ±0.05°
|
||||||
|
dlat = ((i * 23) % 100 - 50) / 1000.0
|
||||||
|
rows.append(_make_row(i, length_m, lon + dlon, lat + dlat))
|
||||||
|
|
||||||
|
# Прогрев — один холодный прогон, не учитываем в статистике.
|
||||||
|
build_gps_mvt(rows, z=5, x=x, y=y)
|
||||||
|
|
||||||
|
timings = []
|
||||||
|
for _ in range(10):
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
build_gps_mvt(rows, z=5, x=x, y=y)
|
||||||
|
timings.append((time.perf_counter() - t0) * 1000.0) # мс
|
||||||
|
|
||||||
|
avg = sum(timings) / len(timings)
|
||||||
|
p95 = _percentile(timings, 0.95)
|
||||||
|
|
||||||
|
# Прикрепляем замеры в отчёт (видно при pytest -v -s).
|
||||||
|
print(
|
||||||
|
f"\nPERF-Z5-01: avg={avg:.1f}ms, p95={p95:.1f}ms, "
|
||||||
|
f"min={min(timings):.1f}ms, max={max(timings):.1f}ms"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert avg <= 200, f"avg {avg:.1f}ms > 200ms (M-6 нарушена)"
|
||||||
|
assert p95 <= 500, f"p95 {p95:.1f}ms > 500ms (M-6 нарушена)"
|
||||||
|
|
||||||
|
|
||||||
|
# ─── PERF-Z5-02: 5000 треков (стресс) ───────────────────────────────────────
|
||||||
|
|
||||||
|
def test_perf_z5_02_5000_tracks_stress():
|
||||||
|
"""REQ-F-13 / PERF-Z5-02: p95 <= 1500 мс при БД 5000 треков (стресс).
|
||||||
|
|
||||||
|
Симулирует прогноз роста БД до 5k треков. Если не проходит — нужно
|
||||||
|
рассматривать смену стратегии (pre-rendering / heat-map; см. ADR-016 §P).
|
||||||
|
"""
|
||||||
|
lon, lat = 37.6, 55.7
|
||||||
|
x, y = _tile_for(5, lon, lat)
|
||||||
|
|
||||||
|
rows = []
|
||||||
|
for i in range(5000):
|
||||||
|
length_m = 1000 + (i * 137 % 50000) # 1..50 км, разные длины
|
||||||
|
dlon = ((i * 13) % 100 - 50) / 100.0 # ±0.5°
|
||||||
|
dlat = ((i * 23) % 100 - 50) / 100.0
|
||||||
|
rows.append(_make_row(i, length_m, lon + dlon, lat + dlat))
|
||||||
|
|
||||||
|
# Прогрев
|
||||||
|
build_gps_mvt(rows, z=5, x=x, y=y)
|
||||||
|
|
||||||
|
timings = []
|
||||||
|
for _ in range(5):
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
build_gps_mvt(rows, z=5, x=x, y=y)
|
||||||
|
timings.append((time.perf_counter() - t0) * 1000.0)
|
||||||
|
|
||||||
|
p95 = _percentile(timings, 0.95)
|
||||||
|
print(
|
||||||
|
f"\nPERF-Z5-02: p95={p95:.1f}ms, "
|
||||||
|
f"min={min(timings):.1f}ms, max={max(timings):.1f}ms"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert p95 <= 1500, f"p95 {p95:.1f}ms > 1500ms"
|
||||||
Reference in New Issue
Block a user