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>
387 lines
15 KiB
Python
387 lines
15 KiB
Python
"""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
|