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]
|
||||
|
||||
### 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)
|
||||
|
||||
> ⚠️ Тег создан и запушен, PR смерджен в `main`, но docker-образ на test
|
||||
|
||||
@@ -40,5 +40,6 @@ asyncio_mode = "auto"
|
||||
testpaths = ["tests"]
|
||||
markers = [
|
||||
"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 ────────────────────────────────────────────────────────
|
||||
|
||||
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:
|
||||
return coords
|
||||
elif z >= 10:
|
||||
tolerance = 0.0005 # ~50м
|
||||
tolerance = 0.0005 # ~50 м
|
||||
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:
|
||||
tolerance = 0.008 # ~800м на z7 и ниже
|
||||
tolerance = 0.04 # ~4 км (z5 и ниже)
|
||||
|
||||
if len(coords) < 3:
|
||||
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)
|
||||
|
||||
# Min-length фильтр по зуму
|
||||
if z <= 7:
|
||||
min_length_m = 2000
|
||||
# Min-length фильтр и cap на число фич по зуму.
|
||||
# ET-012 (ADR-016): добавлены тиры z<=5 и z==6, чтобы при понижении
|
||||
# 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
|
||||
elif z <= 9:
|
||||
min_length_m = 0
|
||||
|
||||
@@ -5,7 +5,10 @@
|
||||
// ─── Константы ────────────────────────────────────────────────────
|
||||
|
||||
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 = {
|
||||
osm: '#3cb44b',
|
||||
@@ -129,7 +132,14 @@ function _gpsLayerDef(id, source, sourceLayer) {
|
||||
'source-layer': sourceLayer || undefined,
|
||||
paint: {
|
||||
'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,
|
||||
},
|
||||
layout: { 'line-cap': 'round', 'line-join': 'round', visibility: 'none' }
|
||||
@@ -144,7 +154,14 @@ function _gpsHaloDef(id, source, sourceLayer) {
|
||||
'source-layer': sourceLayer || undefined,
|
||||
paint: {
|
||||
'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,
|
||||
},
|
||||
layout: { visibility: 'none' }
|
||||
@@ -355,7 +372,7 @@ function _syncGpsLayersVisibility(map) {
|
||||
setVis(window.gpsTracksLayer.layerId, mvtVisible);
|
||||
setVis(window.gpsTracksLayer.layerGeoId, geoVisible);
|
||||
|
||||
// Hint «Зум 8+»
|
||||
// Hint «Зум 5+» (ET-012: порог переехал автоматически через GPS_TRACKS_MIN_ZOOM)
|
||||
const hint = document.getElementById('public-tracks-zoom-hint');
|
||||
if (hint) {
|
||||
hint.style.display = (enabled && zoom < GPS_TRACKS_MIN_ZOOM) ? 'inline' : 'none';
|
||||
|
||||
@@ -77,7 +77,7 @@
|
||||
<input type="checkbox" id="public-tracks-cb" onchange="onPublicTracksCheckbox()">
|
||||
<span>Публичные треки</span>
|
||||
</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"
|
||||
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