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>
258 lines
9.0 KiB
Python
258 lines
9.0 KiB
Python
"""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
|