"""Unit тесты для MVT тайлов GPS-треков (ET-008). U-50: тайл z=10 с треками U-51: упрощение на z=7 U-52: min-length фильтр """ import json import pytest from shapely.geometry import LineString from shapely import wkb from src.api.gps_tracks.mvt import build_gps_mvt, _simplify_coords, _wkb_to_coords def _make_mock_row( track_id=1, activity_type="enduro", source_id="osm", length_m=8000.0, name="Test Track", coords=None, min_lon=37.60, min_lat=55.74, max_lon=37.65, max_lat=55.78, ): """Создаёт mock sqlite3.Row как словарь.""" if coords is None: coords = [ (min_lon, min_lat), ((min_lon + max_lon) / 2, (min_lat + max_lat) / 2), (max_lon, max_lat), ] geom_wkb = wkb.dumps(LineString(coords)) # Имитируем sqlite3.Row через dict с поддержкой подписки class MockRow(dict): def __getitem__(self, key): return super().__getitem__(key) return MockRow({ "id": track_id, "activity_type": activity_type, "sources_json": json.dumps([source_id]), "external_urls_json": json.dumps([]), "length_m": length_m, "name": name, "geom": geom_wkb, }) # ─── U-50: тайл z=10 с треками ─────────────────────────────────────────────── def test_u50_tile_z10_with_tracks(): """U-50: build_gps_mvt возвращает непустой тайл при наличии треков.""" rows = [ _make_mock_row(1, "enduro", "osm", length_m=8000), _make_mock_row(2, "moto", "osm", length_m=5000, min_lon=37.61, min_lat=55.75, max_lon=37.62, max_lat=55.76), ] # Тайл z=10, x=620, y=320 — область Москвы result = build_gps_mvt(rows, z=10, x=620, y=320) assert isinstance(result, bytes) assert len(result) > 0 def test_u50_empty_rows_returns_empty_bytes(): """U-50: пустой список строк возвращает b"".""" result = build_gps_mvt([], z=10, x=620, y=320) assert result == b"" def test_u50_invalid_geom_row_skipped(): """U-50: строка с невалидной геометрией пропускается.""" class BadRow(dict): pass bad_row = BadRow({ "id": 99, "activity_type": "other", "sources_json": '["osm"]', "external_urls_json": "[]", "length_m": 5000, "name": "bad", "geom": b"\x00\x01\x02", # невалидный WKB }) good_row = _make_mock_row(1, length_m=5000) result = build_gps_mvt([bad_row, good_row], z=10, x=620, y=320) # Не падает, плохая строка пропускается assert isinstance(result, bytes) # ─── U-51: упрощение на z=7 ────────────────────────────────────────────────── def test_u51_simplification_z7_reduces_points(): """U-51: геометрия упрощается на малых зумах.""" # Создаём трек из 20 точек coords = [(37.60 + i * 0.001, 55.74 + i * 0.0005) for i in range(20)] simplified = _simplify_coords(coords, z=7) # При z=7 tolerance=0.008, ожидаем меньше точек assert len(simplified) < len(coords) assert len(simplified) >= 2 def test_u51_no_simplification_z12(): """U-51: на z=12 упрощение не применяется.""" coords = [(37.60 + i * 0.001, 55.74 + i * 0.0005) for i in range(10)] result = _simplify_coords(coords, z=12) assert result == coords def test_u51_simplification_z10_moderate(): """U-51: на z=10 умеренное упрощение.""" coords = [(37.60 + i * 0.0001, 55.74 + i * 0.0001) for i in range(30)] simplified_z10 = _simplify_coords(coords, z=10) simplified_z7 = _simplify_coords(coords, z=7) # z=7 должен сильнее упрощать, чем z=10 assert len(simplified_z10) >= len(simplified_z7) # ─── U-52: min-length фильтр ───────────────────────────────────────────────── def test_u52_min_length_filter_z7(): """U-52: на z<=7 треки короче 2000м отфильтровываются.""" short_track = _make_mock_row(1, length_m=1500) # меньше 2000м long_track = _make_mock_row(2, length_m=5000) # больше 2000м result_with_short = build_gps_mvt([short_track, long_track], z=7, x=77, y=40) result_without_short = build_gps_mvt([long_track], z=7, x=77, y=40) # Результаты должны совпадать (короткий трек отфильтрован) assert result_with_short == result_without_short def test_u52_no_min_length_filter_z10(): """U-52: на z=10 нет min-length фильтра — все треки проходят.""" short_track = _make_mock_row(1, length_m=100) long_track = _make_mock_row(2, length_m=5000) result_both = build_gps_mvt([short_track, long_track], z=10, x=620, y=320) result_long_only = build_gps_mvt([long_track], z=10, x=620, y=320) # При z=10 оба трека должны включаться (если геометрия пересекается с тайлом) # result_both может быть больше result_long_only если короткий трек в тайле assert isinstance(result_both, bytes) assert isinstance(result_long_only, bytes) def test_u52_min_length_boundary(): """U-52: трек ровно 2000м на z=7 проходит фильтр.""" track_2000 = _make_mock_row(1, length_m=2000) track_1999 = _make_mock_row(2, length_m=1999) result_2000 = build_gps_mvt([track_2000], z=7, x=77, y=40) result_1999 = build_gps_mvt([track_1999], z=7, x=77, y=40) # track_1999 должен быть отфильтрован (строго меньше 2000) # track_2000 проходит (>= 2000 не выполняется для строгого фильтра < 2000) # По коду: if min_length_m > 0 and length_m < min_length_m → skip # 1999 < 2000 → skip, 2000 < 2000 → False → not skipped assert result_2000 != result_1999 or result_1999 == b""