Files
enduro-trails/tests/api/test_gps_mvt_zoom_tiers.py
claude-bot bbed0e1082
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
feat(gps-tracks): lower public-tracks minzoom to z5 (ET-012)
Калибровка существующих 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>
2026-06-04 06:29:41 +00:00

258 lines
9.0 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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