feat(ET-001): implement barrier blocking and footway exclusion in OSRM profile
- enduro.lua: блокировка нод barrier=gate|bollard|lift_gate|chain|cycle_barrier| motorcycle_barrier|border_control|block через mode.inaccessible (ADR-001). cattle_grid и ford остаются проезжими. - enduro.lua: highway=footway|pedestrian|steps|corridor полностью исключены из графа (early return в process_way). Эти типы удалены из highway_rate и highway_speeds, чтобы профиль был самодостаточным. - scripts/rebuild-osrm.sh: пересборка графа (extract → partition → customize) и рестарт контейнера osrm-routed. - tests/integration/test_routing_barriers.py: 7 тестов (TC-001..TC-005 + статический анализ blocked_barriers/excluded_highways). Интеграционные тесты скипаются если OSRM не доступен. Refs: ET-001
This commit is contained in:
@@ -3,19 +3,21 @@
|
|||||||
-- Чтобы грунтовки были ДЕШЕВЛЕ асфальта → у грунтовок forward_rate БОЛЬШЕ
|
-- Чтобы грунтовки были ДЕШЕВЛЕ асфальта → у грунтовок forward_rate БОЛЬШЕ
|
||||||
-- (высокий rate → маленький weight на тот же distance)
|
-- (высокий rate → маленький weight на тот же distance)
|
||||||
-- У асфальта forward_rate МЕНЬШЕ → большой weight → OSRM избегает
|
-- У асфальта forward_rate МЕНЬШЕ → большой weight → OSRM избегает
|
||||||
-- Обновлён: 2026-05-06 (F-07, F-08, новые rate для асфальта)
|
-- Обновлён: 2026-05-15 (ET-001: блокировка шлагбаумов через mode.inaccessible,
|
||||||
|
-- исключение footway/pedestrian/steps/corridor из графа)
|
||||||
|
|
||||||
api_version = 4
|
api_version = 4
|
||||||
|
|
||||||
-- forward_rate: чем ВЫШЕ — тем ПРЕДПОЧТИТЕЛЬНЕЕ маршрут
|
-- forward_rate: чем ВЫШЕ — тем ПРЕДПОЧТИТЕЛЬНЕЕ маршрут
|
||||||
-- грунтовки = высокий rate (предпочтительны)
|
-- грунтовки = высокий rate (предпочтительны)
|
||||||
-- асфальт = низкий rate (избегаем, но штраф 3-5x вместо 17-200x)
|
-- асфальт = низкий rate (избегаем, но штраф 3-5x вместо 17-200x)
|
||||||
|
-- Примечание: footway/pedestrian/steps/corridor отсутствуют — они полностью исключены
|
||||||
|
-- из графа в process_way (ET-001, F-08).
|
||||||
local highway_rate = {
|
local highway_rate = {
|
||||||
track = 100.0, -- самый предпочтительный
|
track = 100.0, -- самый предпочтительный
|
||||||
bridleway = 90.0,
|
bridleway = 90.0,
|
||||||
path = 85.0,
|
path = 85.0,
|
||||||
cycleway = 70.0,
|
cycleway = 70.0,
|
||||||
footway = 55.0, -- за городом полезны
|
|
||||||
unclassified = 45.0,
|
unclassified = 45.0,
|
||||||
residential = 35.0,
|
residential = 35.0,
|
||||||
service = 28.0,
|
service = 28.0,
|
||||||
@@ -37,7 +39,6 @@ local highway_speeds = {
|
|||||||
bridleway = 20,
|
bridleway = 20,
|
||||||
path = 20,
|
path = 20,
|
||||||
cycleway = 25,
|
cycleway = 25,
|
||||||
footway = 15,
|
|
||||||
unclassified = 40,
|
unclassified = 40,
|
||||||
residential = 40,
|
residential = 40,
|
||||||
service = 30,
|
service = 30,
|
||||||
@@ -62,6 +63,27 @@ local tracktype_multiplier = {
|
|||||||
grade5 = 0.8,
|
grade5 = 0.8,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
-- ET-001 (F-07): полный запрет проезда через ноды-шлагбаумы.
|
||||||
|
-- cattle_grid и ford НЕ включены — мотоцикл их проходит.
|
||||||
|
local blocked_barriers = {
|
||||||
|
gate = true,
|
||||||
|
bollard = true,
|
||||||
|
lift_gate = true,
|
||||||
|
chain = true,
|
||||||
|
cycle_barrier = true,
|
||||||
|
motorcycle_barrier = true,
|
||||||
|
border_control = true,
|
||||||
|
block = true,
|
||||||
|
}
|
||||||
|
|
||||||
|
-- ET-001 (F-08): пешеходные/служебные типы дорог, полностью исключаемые из графа.
|
||||||
|
local excluded_highways = {
|
||||||
|
footway = true,
|
||||||
|
pedestrian = true,
|
||||||
|
steps = true,
|
||||||
|
corridor = true,
|
||||||
|
}
|
||||||
|
|
||||||
function setup()
|
function setup()
|
||||||
return {
|
return {
|
||||||
properties = {
|
properties = {
|
||||||
@@ -76,16 +98,15 @@ function setup()
|
|||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
-- F-07: шлагбаумы блокируем только если явно закрыты для публики
|
-- ET-001 (F-07): шлагбаумы блокируются жёстко через mode.inaccessible.
|
||||||
|
-- Тег access не учитывается — физическое наличие шлагбаума уже причина обхода.
|
||||||
function process_node(profile, node, result)
|
function process_node(profile, node, result)
|
||||||
local barrier = node:get_value_by_key("barrier")
|
local barrier = node:get_value_by_key("barrier")
|
||||||
if barrier == "gate" or barrier == "bollard" or barrier == "lift_gate" then
|
if barrier and blocked_barriers[barrier] then
|
||||||
local access = node:get_value_by_key("access")
|
result.barrier = true
|
||||||
-- Блокировать только явно закрытые для публики
|
result.forward_mode = mode.inaccessible
|
||||||
if access == "private" or access == "no" or access == "customers" or access == "permissive" then
|
result.backward_mode = mode.inaccessible
|
||||||
result.barrier = true
|
return
|
||||||
end
|
|
||||||
-- Без тега access или access=yes — пропускаем (публичный)
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -93,14 +114,14 @@ function process_way(profile, way, result)
|
|||||||
local highway = way:get_value_by_key("highway")
|
local highway = way:get_value_by_key("highway")
|
||||||
if not highway then return end
|
if not highway then return end
|
||||||
|
|
||||||
|
-- ET-001 (F-08): пешеходные/служебные типы — полностью убираем из графа.
|
||||||
|
if excluded_highways[highway] then return end
|
||||||
|
|
||||||
local rate = highway_rate[highway]
|
local rate = highway_rate[highway]
|
||||||
if not rate then return end
|
if not rate then return end
|
||||||
|
|
||||||
-- steps — всегда исключить
|
-- path/cycleway в городской застройке — исключить
|
||||||
if highway == "steps" then return end
|
if highway == "path" or highway == "cycleway" then
|
||||||
|
|
||||||
-- F-08: пешеходные/велодорожки в городской застройке — исключить
|
|
||||||
if highway == "footway" or highway == "path" or highway == "cycleway" then
|
|
||||||
local landuse = way:get_value_by_key("landuse")
|
local landuse = way:get_value_by_key("landuse")
|
||||||
local place = way:get_value_by_key("place")
|
local place = way:get_value_by_key("place")
|
||||||
-- Признаки городской застройки
|
-- Признаки городской застройки
|
||||||
|
|||||||
72
scripts/rebuild-osrm.sh
Executable file
72
scripts/rebuild-osrm.sh
Executable file
@@ -0,0 +1,72 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# rebuild-osrm.sh — пересборка OSRM-графа эндуро-профиля.
|
||||||
|
#
|
||||||
|
# Запускает полный цикл extract → partition → customize и рестартит роутер.
|
||||||
|
# Требует docker, osrm-backend образ и pbf-файл в каталоге OSRM_DIR.
|
||||||
|
#
|
||||||
|
# Использование:
|
||||||
|
# ./scripts/rebuild-osrm.sh
|
||||||
|
# OSRM_DIR=/path/to/osrm OSRM_PBF=area.osm.pbf ./scripts/rebuild-osrm.sh
|
||||||
|
#
|
||||||
|
# Переменные окружения (со значениями по умолчанию):
|
||||||
|
# OSRM_DIR — каталог с enduro.lua и .osm.pbf (default: /home/slin/enduro-trails/osrm)
|
||||||
|
# OSRM_PBF — имя pbf-файла (default: enduro.osm.pbf)
|
||||||
|
# OSRM_PROFILE — имя lua-профиля (default: enduro.lua)
|
||||||
|
# OSRM_IMAGE — docker-образ osrm-backend (default: ghcr.io/project-osrm/osrm-backend:latest)
|
||||||
|
# OSRM_CONTAINER — имя контейнера для рестарта (default: osrm-osrm-routed-1)
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
OSRM_DIR="${OSRM_DIR:-/home/slin/enduro-trails/osrm}"
|
||||||
|
OSRM_PBF="${OSRM_PBF:-enduro.osm.pbf}"
|
||||||
|
OSRM_PROFILE="${OSRM_PROFILE:-enduro.lua}"
|
||||||
|
OSRM_IMAGE="${OSRM_IMAGE:-ghcr.io/project-osrm/osrm-backend:latest}"
|
||||||
|
OSRM_CONTAINER="${OSRM_CONTAINER:-osrm-osrm-routed-1}"
|
||||||
|
|
||||||
|
if [[ ! -d "$OSRM_DIR" ]]; then
|
||||||
|
echo "ERROR: каталог $OSRM_DIR не существует" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -f "$OSRM_DIR/$OSRM_PBF" ]]; then
|
||||||
|
echo "ERROR: pbf-файл $OSRM_DIR/$OSRM_PBF не найден" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -f "$OSRM_DIR/$OSRM_PROFILE" ]]; then
|
||||||
|
echo "ERROR: lua-профиль $OSRM_DIR/$OSRM_PROFILE не найден" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
OSRM_BASE="${OSRM_PBF%.osm.pbf}"
|
||||||
|
OSRM_FILE="$OSRM_BASE.osrm"
|
||||||
|
|
||||||
|
echo "==> Пересборка OSRM-графа"
|
||||||
|
echo " каталог: $OSRM_DIR"
|
||||||
|
echo " pbf: $OSRM_PBF"
|
||||||
|
echo " профиль: $OSRM_PROFILE"
|
||||||
|
echo " образ: $OSRM_IMAGE"
|
||||||
|
echo " контейнер: $OSRM_CONTAINER"
|
||||||
|
|
||||||
|
run_osrm() {
|
||||||
|
docker run --rm -v "$OSRM_DIR:/data" "$OSRM_IMAGE" "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "==> [1/4] osrm-extract (это ~40 минут)"
|
||||||
|
run_osrm osrm-extract -p "/data/$OSRM_PROFILE" "/data/$OSRM_PBF"
|
||||||
|
|
||||||
|
echo "==> [2/4] osrm-partition"
|
||||||
|
run_osrm osrm-partition "/data/$OSRM_FILE"
|
||||||
|
|
||||||
|
echo "==> [3/4] osrm-customize"
|
||||||
|
run_osrm osrm-customize "/data/$OSRM_FILE"
|
||||||
|
|
||||||
|
echo "==> [4/4] Рестарт контейнера $OSRM_CONTAINER"
|
||||||
|
if docker ps -a --format '{{.Names}}' | grep -qx "$OSRM_CONTAINER"; then
|
||||||
|
docker restart "$OSRM_CONTAINER"
|
||||||
|
echo " контейнер перезапущен"
|
||||||
|
else
|
||||||
|
echo " WARNING: контейнер $OSRM_CONTAINER не найден — рестарт пропущен" >&2
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "==> Готово"
|
||||||
0
tests/integration/__init__.py
Normal file
0
tests/integration/__init__.py
Normal file
331
tests/integration/test_routing_barriers.py
Normal file
331
tests/integration/test_routing_barriers.py
Normal file
@@ -0,0 +1,331 @@
|
|||||||
|
"""
|
||||||
|
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, "геометрия пуста"
|
||||||
Reference in New Issue
Block a user