inire/inire/tests/test_fuzz.py

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}")