93 lines
3.3 KiB
Python
93 lines
3.3 KiB
Python
from typing import Any
|
|
|
|
import pytest
|
|
from hypothesis import given, settings, strategies as st
|
|
from shapely.geometry import Point, Polygon
|
|
|
|
from inire.geometry.collision import RoutingWorld
|
|
from inire.geometry.primitives import Port
|
|
from inire.model import RoutingOptions, RoutingProblem, SearchOptions
|
|
from inire.router._astar_types import AStarContext, SearchRunConfig
|
|
from inire.router._search import route_astar
|
|
from inire.router.cost import CostEvaluator
|
|
from inire.router.danger_map import DangerMap
|
|
|
|
|
|
@st.composite
|
|
def random_obstacle(draw: Any) -> Polygon:
|
|
x = draw(st.floats(min_value=0, max_value=20))
|
|
y = draw(st.floats(min_value=0, max_value=20))
|
|
w = draw(st.floats(min_value=1, max_value=5))
|
|
h = draw(st.floats(min_value=1, max_value=5))
|
|
return Polygon([(x, y), (x + w, y), (x + w, y + h), (x, y + h)])
|
|
|
|
|
|
@st.composite
|
|
def random_port(draw: Any) -> Port:
|
|
x = draw(st.floats(min_value=0, max_value=20))
|
|
y = draw(st.floats(min_value=0, max_value=20))
|
|
orientation = draw(st.sampled_from([0, 90, 180, 270]))
|
|
return Port(x, y, orientation)
|
|
|
|
|
|
def _port_has_required_clearance(port: Port, obstacles: list[Polygon], clearance: float, net_width: float) -> bool:
|
|
point = Point(float(port.x), float(port.y))
|
|
required_gap = (net_width / 2.0) + clearance
|
|
return all(point.distance(obstacle) >= required_gap for obstacle in obstacles)
|
|
|
|
|
|
def _build_context(
|
|
evaluator: CostEvaluator,
|
|
*,
|
|
bounds: tuple[float, float, float, float],
|
|
**search_overrides: object,
|
|
) -> AStarContext:
|
|
return AStarContext(
|
|
evaluator,
|
|
RoutingProblem(bounds=bounds),
|
|
RoutingOptions(search=SearchOptions(**search_overrides)),
|
|
)
|
|
|
|
|
|
def _route(context: AStarContext, start: Port, target: Port):
|
|
return route_astar(
|
|
start,
|
|
target,
|
|
net_width=2.0,
|
|
context=context,
|
|
config=SearchRunConfig.from_options(context.options),
|
|
)
|
|
|
|
|
|
@settings(max_examples=3, deadline=None)
|
|
@given(obstacles=st.lists(random_obstacle(), min_size=0, max_size=3), start=random_port(), target=random_port())
|
|
def test_fuzz_astar_no_crash(obstacles: list[Polygon], start: Port, target: Port) -> None:
|
|
net_width = 2.0
|
|
clearance = 2.0
|
|
engine = RoutingWorld(clearance=2.0)
|
|
for obs in obstacles:
|
|
engine.add_static_obstacle(obs)
|
|
|
|
danger_map = DangerMap(bounds=(0, 0, 30, 30))
|
|
danger_map.precompute(obstacles)
|
|
|
|
evaluator = CostEvaluator(engine, danger_map)
|
|
context = _build_context(evaluator, bounds=(0, 0, 30, 30), node_limit=5000)
|
|
|
|
# Check if start/target are inside obstacles (safety zone check)
|
|
# The router should handle this gracefully (either route or return None)
|
|
try:
|
|
path = _route(context, start, target)
|
|
|
|
# This is a crash-smoke test rather than a full correctness proof.
|
|
# If a full path is returned, it should at least terminate at the requested target.
|
|
endpoints_are_clear = (
|
|
_port_has_required_clearance(start, obstacles, clearance, net_width)
|
|
and _port_has_required_clearance(target, obstacles, clearance, net_width)
|
|
)
|
|
if path and endpoints_are_clear:
|
|
assert path[-1].end_port == target
|
|
|
|
except Exception as e:
|
|
# Unexpected exceptions are failures
|
|
pytest.fail(f"Router crashed with {type(e).__name__}: {e}")
|