feat(gps-tracks): lower public-tracks minzoom to z5 (ET-012)
All checks were successful
CI / lint (push) Successful in 5s
CI / test (push) Successful in 9s
CI / build (push) Successful in 2s
CI / lint (pull_request) Successful in 4s
CI / test (pull_request) Successful in 10s
CI / build (pull_request) Successful in 3s

Калибровка существующих 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:
2026-06-04 06:29:41 +00:00
parent c7d472023f
commit bbed0e1082
10 changed files with 1049 additions and 13 deletions

View File

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

View File

@@ -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'"

View File

@@ -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 м
elif z >= 8:
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

View File

@@ -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';

View File

@@ -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">
Фильтры…

View 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 # без упрощения

View 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

View 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

View File

View 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"