""" Integration tests для ET-001: блокировка шлагбаумов и исключение тротуаров. Тесты делятся на две группы: 1. Статические проверки lua-профиля (TC-004, частично AC-1/AC-2). Запускаются всегда — анализируют исходник `infra/osrm/enduro.lua`. 2. Интеграционные проверки через работающий OSRM (TC-001, TC-002, TC-003, TC-005). Требуют поднятого OSRM с пересобранным графом. Если OSRM недоступен — соответствующие тесты помечаются `skip`, чтобы CI без инфраструктуры не падал. Адрес OSRM берётся из переменной окружения OSRM_URL (default: http://172.22.0.1:5559). Координаты тестовых точек задаются переменными окружения с осмысленными default'ами для региона ЦФО — `architect` может уточнить их позже. """ from __future__ import annotations import os import re import subprocess from pathlib import Path from shutil import which import httpx import pytest REPO_ROOT = Path(__file__).resolve().parents[2] LUA_PROFILE_PATH = REPO_ROOT / "infra" / "osrm" / "enduro.lua" OSRM_URL = os.environ.get("OSRM_URL", "http://172.22.0.1:5559") OSRM_TIMEOUT = float(os.environ.get("OSRM_TIMEOUT", "10")) # Тестовые координаты (lon, lat). Architect может переопределить через env. # Default'ы — заведомо валидные точки в ЦФО (Москва и окрестности). BARRIER_FROM = ( float(os.environ.get("TEST_BARRIER_FROM_LON", "37.6173")), float(os.environ.get("TEST_BARRIER_FROM_LAT", "55.7558")), ) BARRIER_TO = ( float(os.environ.get("TEST_BARRIER_TO_LON", "37.6500")), float(os.environ.get("TEST_BARRIER_TO_LAT", "55.7600")), ) # Узел заведомо известного шлагбаума (lon, lat). Если не задан — тест проверяет # только сам факт получения 200/404 без падений. BARRIER_NODE = ( float(os.environ.get("TEST_BARRIER_NODE_LON", "0")) or None, float(os.environ.get("TEST_BARRIER_NODE_LAT", "0")) or None, ) CITY_FROM = ( float(os.environ.get("TEST_CITY_FROM_LON", "37.6173")), float(os.environ.get("TEST_CITY_FROM_LAT", "55.7558")), ) CITY_TO = ( float(os.environ.get("TEST_CITY_TO_LON", "37.6300")), float(os.environ.get("TEST_CITY_TO_LAT", "55.7600")), ) CATTLE_GRID_FROM = ( float(os.environ.get("TEST_CG_FROM_LON", "37.6173")), float(os.environ.get("TEST_CG_FROM_LAT", "55.7558")), ) CATTLE_GRID_TO = ( float(os.environ.get("TEST_CG_TO_LON", "37.7000")), float(os.environ.get("TEST_CG_TO_LAT", "55.8000")), ) REGRESSION_FROM = ( float(os.environ.get("TEST_REG_FROM_LON", "37.6173")), float(os.environ.get("TEST_REG_FROM_LAT", "55.7558")), ) REGRESSION_TO = ( float(os.environ.get("TEST_REG_TO_LON", "37.8000")), float(os.environ.get("TEST_REG_TO_LAT", "55.9000")), ) # Если шлагбаум попал в маршрут — координата ноды должна оказаться достаточно # близко к одной из точек геометрии. Порог в градусах (~5 м на широте 55°). COORD_NEAR_DEG = 5e-5 def _osrm_available() -> bool: """Проверяет что OSRM отвечает на /health (или хотя бы на корень).""" try: with httpx.Client(timeout=2.0) as client: resp = client.get(f"{OSRM_URL}/health") if resp.status_code < 500: return True except Exception: pass try: with httpx.Client(timeout=2.0) as client: # Любой ответ от роутера сойдёт — нам нужно убедиться что порт жив. resp = client.get(f"{OSRM_URL}/route/v1/driving/0,0;1,1") return resp.status_code < 500 except Exception: return False osrm_required = pytest.mark.skipif( not _osrm_available(), reason=f"OSRM не доступен на {OSRM_URL} — интеграционный тест пропущен", ) def _route(from_lonlat: tuple[float, float], to_lonlat: tuple[float, float]) -> httpx.Response: """Делает запрос к OSRM и возвращает ответ.""" coords = f"{from_lonlat[0]},{from_lonlat[1]};{to_lonlat[0]},{to_lonlat[1]}" url = ( f"{OSRM_URL}/route/v1/driving/{coords}" f"?overview=full&geometries=geojson&alternatives=false&radiuses=5000;5000" ) with httpx.Client(timeout=OSRM_TIMEOUT) as client: return client.get(url) # ────────────────────────────────────────────────────────────────────────────── # Статические проверки lua-профиля (без OSRM) # ────────────────────────────────────────────────────────────────────────────── def _read_profile() -> str: assert LUA_PROFILE_PATH.is_file(), f"Lua-профиль не найден: {LUA_PROFILE_PATH}" return LUA_PROFILE_PATH.read_text(encoding="utf-8") def test_lua_syntax(): """TC-004: lua-профиль синтаксически корректен. Если в системе есть `luac` — используем его. Иначе ограничиваемся структурными проверками: файл читается, содержит ключевые функции и сбалансированные `function`/`end`. """ profile = _read_profile() # Структурные инварианты — должны быть всегда. assert "function process_node" in profile, "нет process_node" assert "function process_way" in profile, "нет process_way" assert "function process_turn" in profile, "нет process_turn" assert "function setup" in profile, "нет setup" assert "return {" in profile, "нет финального экспорта" luac = which("luac") or which("luac5.3") or which("luac5.1") if luac: result = subprocess.run( [luac, "-p", str(LUA_PROFILE_PATH)], capture_output=True, text=True, ) assert result.returncode == 0, ( f"luac -p вернул {result.returncode}: {result.stderr}" ) def test_blocked_barriers_match_trz(): """AC-1: профиль блокирует ровно перечисленный в ТЗ набор barrier-типов.""" profile = _read_profile() expected = { "gate", "bollard", "lift_gate", "chain", "cycle_barrier", "motorcycle_barrier", "border_control", "block", } # Парсим таблицу blocked_barriers match = re.search(r"local\s+blocked_barriers\s*=\s*\{([^}]*)\}", profile, re.S) assert match, "не нашли таблицу blocked_barriers" found = set(re.findall(r"(\w+)\s*=\s*true", match.group(1))) assert found == expected, ( f"blocked_barriers содержит {found}, ожидали {expected}" ) # cattle_grid и ford не должны быть в блоке assert "cattle_grid" not in found, "cattle_grid не должен блокироваться" assert "ford" not in found, "ford не должен блокироваться" # mode.inaccessible должен использоваться в process_node node_match = re.search( r"function\s+process_node.*?\nend", profile, re.S, ) assert node_match, "не нашли тело process_node" body = node_match.group(0) assert "mode.inaccessible" in body, ( "process_node должен использовать mode.inaccessible (ADR-001)" ) assert "forward_mode" in body and "backward_mode" in body, ( "process_node должен выставлять и forward_mode, и backward_mode" ) def test_excluded_highways_match_trz(): """AC-2: footway/pedestrian/steps/corridor исключены из графа.""" profile = _read_profile() expected = {"footway", "pedestrian", "steps", "corridor"} match = re.search(r"local\s+excluded_highways\s*=\s*\{([^}]*)\}", profile, re.S) assert match, "не нашли таблицу excluded_highways" found = set(re.findall(r"(\w+)\s*=\s*true", match.group(1))) assert found == expected, ( f"excluded_highways содержит {found}, ожидали {expected}" ) # И ни одного из них не должно остаться в highway_rate. rate_match = re.search(r"local\s+highway_rate\s*=\s*\{([^}]*)\}", profile, re.S) assert rate_match, "не нашли таблицу highway_rate" rate_body = rate_match.group(1) for hw in expected: # Проверяем именно ключ таблицы — пробелы между ключом и `=` допустимы. assert not re.search(rf"\b{hw}\s*=", rate_body), ( f"{hw} остался в highway_rate — должен быть удалён (AC-2)" ) # ────────────────────────────────────────────────────────────────────────────── # Интеграционные тесты через OSRM # ────────────────────────────────────────────────────────────────────────────── @osrm_required def test_route_avoids_barrier(): """TC-001: маршрут через точку с известным шлагбаумом обходит её. Если переменные TEST_BARRIER_NODE_* не заданы — тест ограничивается проверкой того, что OSRM либо возвращает валидный маршрут, либо честно отвечает NoRoute (404), без internal-ошибок. """ resp = _route(BARRIER_FROM, BARRIER_TO) assert resp.status_code < 500, f"OSRM 5xx: {resp.status_code} {resp.text[:200]}" data = resp.json() assert data.get("code") in {"Ok", "NoRoute", "NoSegment"}, ( f"неожиданный OSRM-код: {data.get('code')}" ) if data.get("code") != "Ok": # Заблокированный участок может приводить к NoRoute — это ожидаемое # поведение (см. ADR-001, раздел «Последствия»). return routes = data.get("routes") or [] assert routes, "OSRM=Ok, но routes пуст" coords = routes[0]["geometry"]["coordinates"] assert len(coords) >= 2, "геометрия маршрута слишком короткая" node_lon, node_lat = BARRIER_NODE if node_lon is None or node_lat is None: # Координата ноды-шлагбаума не задана — детальная проверка невозможна, # но сам факт корректного ответа — уже AC-1 на уровне smoke. return for lon, lat in coords: assert not ( abs(lon - node_lon) < COORD_NEAR_DEG and abs(lat - node_lat) < COORD_NEAR_DEG ), ( f"маршрут проходит через заблокированный шлагбаум " f"({node_lon}, {node_lat})" ) @osrm_required def test_route_no_footway(): """TC-002: маршрут в городе не использует footway/pedestrian/steps. Проверяем через OSRM-annotations: если в ответе есть classes/nodes с пешеходным типом — тест падает. Без annotations ограничиваемся проверкой того, что маршрут построен и не пустой. """ coords = f"{CITY_FROM[0]},{CITY_FROM[1]};{CITY_TO[0]},{CITY_TO[1]}" url = ( f"{OSRM_URL}/route/v1/driving/{coords}" f"?overview=full&geometries=geojson&alternatives=false" f"&annotations=true&steps=true&radiuses=5000;5000" ) with httpx.Client(timeout=OSRM_TIMEOUT) as client: resp = client.get(url) assert resp.status_code < 500, f"OSRM 5xx: {resp.status_code}" data = resp.json() if data.get("code") != "Ok": # В городе может не найтись маршрута если все варианты — тротуары. # Это и есть желаемое поведение (AC-2): пешеходные дороги выкинуты. assert data.get("code") in {"NoRoute", "NoSegment"}, ( f"неожиданный код: {data.get('code')}" ) return routes = data.get("routes") or [] assert routes, "OSRM=Ok, но routes пуст" route = routes[0] assert route.get("distance", 0) > 0, "distance должен быть > 0" # Если OSRM вернул steps — заглянем в name/ref/maneuver на наличие # явно пешеходных подсказок (грубый, но дешёвый sanity check). legs = route.get("legs", []) for leg in legs: for step in leg.get("steps", []): name = (step.get("name") or "").lower() assert "тротуар" not in name, "step с тротуаром в name" assert "footway" not in name, "step с footway в name" @osrm_required def test_route_allows_cattle_grid(): """TC-003: cattle_grid не блокирует маршрут (мотоцикл проезжает).""" resp = _route(CATTLE_GRID_FROM, CATTLE_GRID_TO) assert resp.status_code < 500, f"OSRM 5xx: {resp.status_code}" data = resp.json() # Главное — что cattle_grid не превращает маршрут в NoRoute. # Допускаем NoSegment если тестовая точка не привязалась к графу. assert data.get("code") in {"Ok", "NoSegment"}, ( f"cattle_grid не должен ломать маршрут, но получили {data.get('code')}" ) if data.get("code") == "Ok": routes = data.get("routes") or [] assert routes, "OSRM=Ok, но routes пуст" assert routes[0].get("distance", 0) > 0 @osrm_required def test_existing_route_works(): """TC-005 (регрессия): обычный маршрут без шлагбаумов/тротуаров строится.""" resp = _route(REGRESSION_FROM, REGRESSION_TO) assert resp.status_code == 200, f"OSRM статус: {resp.status_code}" data = resp.json() assert data.get("code") == "Ok", f"OSRM код: {data.get('code')}" routes = data.get("routes") or [] assert routes, "routes пуст" route = routes[0] assert route.get("distance", 0) > 0, "distance должен быть > 0" geom = route.get("geometry", {}) assert geom.get("type") == "LineString" assert len(geom.get("coordinates", [])) >= 2, "геометрия пуста"