#!/usr/bin/env python3 """Fast geometry regression tests for robot_planner (no torch/cv2 needed). Run with ``python3 test_robot_planner.py``. These pin the pure-numpy/scipy behaviour: contour tracing, polygon simplification, homography round-trips, and stroke planning/coverage, including the failure path when the roller cannot fit. """ import numpy as np import robot_planner as rp def rectangle(height, width, top, left, bottom, right): mask = np.zeros((height, width), dtype=bool) mask[top:bottom, left:right] = True return mask def test_trace_follows_concavity(): # L-shape: a convex hull would have 4 corners and bridge the notch. mask = rectangle(60, 60, 5, 5, 55, 20) mask |= rectangle(60, 60, 40, 5, 55, 55) traced = [(x, y) for y, x in rp.trace_boundary(mask)] simplified = rp.simplify_closed(traced, tolerance=2.0) assert 5 <= len(simplified) <= 8, f"expected ~6 corners for an L-shape, got {len(simplified)}" assert len(traced) > 40 def test_rectangle_simplifies_to_four_corners(): traced = [(x, y) for y, x in rp.trace_boundary(rectangle(40, 70, 5, 6, 33, 61))] simplified = rp.simplify_closed(traced, tolerance=1.5) assert len(simplified) == 4, simplified def test_homography_round_trip(): corners = np.array([[10, 12], [410, 8], [420, 308], [5, 300]], float) matrix = rp.homography(corners, [(0, 0), (400, 0), (400, 300), (0, 300)]) mapped = rp.apply_homography(matrix, corners) assert np.allclose(mapped, [[0, 0], [400, 0], [400, 300], [0, 300]], atol=1e-6) back = rp.apply_homography(np.linalg.inv(matrix), mapped) assert np.allclose(back, corners, atol=1e-6) def test_plan_covers_open_wall(): paintable = rectangle(400, 600, 20, 20, 380, 580) keepout = np.zeros_like(paintable) plan, debug = rp.build_plan(paintable, keepout, stroke_width=80, overlap=0.2, margin=20, orientation="horizontal", corners=None, mm_per_unit=2.5, boundary_tolerance=6.0) assert plan["warnings"] == [], plan["warnings"] assert plan["wall"]["corners"] == 4 assert plan["plan"]["segments"] > 0 assert plan["coverage"]["planned_fraction"] >= 0.8, plan["coverage"] assert plan["plan"]["path_length_mm"] > plan["plan"]["path_length_units"] assert debug["allowed"].any() def test_plan_reports_infeasible_roller(): paintable = rectangle(200, 200, 0, 0, 200, 200) keepout = np.zeros_like(paintable) plan, _ = rp.build_plan(paintable, keepout, stroke_width=900, overlap=0.2, margin=100, orientation="horizontal", corners=None, mm_per_unit=None, boundary_tolerance=4.0) assert plan["plan"]["segments"] == 0 assert plan["warnings"] and "centre-line" in plan["warnings"][0] def test_keepout_blocks_strokes(): paintable = rectangle(300, 300, 10, 10, 290, 290) keepout = rectangle(300, 300, 0, 140, 300, 160) # vertical stripe: splits every pass plan, _ = rp.build_plan(paintable, keepout, stroke_width=40, overlap=0.2, margin=10, orientation="horizontal", corners=None, mm_per_unit=None, boundary_tolerance=4.0) assert plan["plan"]["segments"] > plan["plan"]["passes"], "keep-out stripe should split passes" assert 0.0 < plan["coverage"]["planned_fraction"] < 1.0 def test_trim_tool_reaches_edges_and_fixtures(): paintable = rectangle(400, 600, 20, 20, 380, 580) keepout = rectangle(400, 600, 0, 290, 400, 310) # fixture stripe the roller keeps clear of roller_only, _ = rp.build_plan(paintable, keepout, stroke_width=90, overlap=0.2, margin=25, orientation="horizontal", corners=None, mm_per_unit=None, boundary_tolerance=4.0) combined, _ = rp.build_plan(paintable, keepout, stroke_width=90, overlap=0.2, margin=25, orientation="horizontal", corners=None, mm_per_unit=None, boundary_tolerance=4.0, trim_width=20, trim_overlap=0.3) assert roller_only["tools"] == ["roller"] assert combined["tools"] == ["roller", "trim"] assert combined["plan"]["trim"]["segments"] > 0 assert combined["coverage"]["planned_fraction"] > roller_only["coverage"]["planned_fraction"] assert combined["coverage"]["remaining_fraction"] < roller_only["coverage"]["remaining_fraction"] assert combined["coverage"]["remaining_fraction"] < 0.05 def test_waypoints_carry_paint_flags(): paintable = rectangle(200, 200, 10, 10, 190, 190) plan, _ = rp.build_plan(paintable, np.zeros_like(paintable), stroke_width=40, overlap=0.2, margin=10, orientation="horizontal", corners=None, mm_per_unit=None, boundary_tolerance=4.0) waypoints = plan["plan"]["waypoints"] segments = plan["plan"]["segments"] assert waypoints[0]["paint"] is True assert sum(1 for w in waypoints if not w["paint"]) == max(0, segments - 1) assert all(set(("x", "y", "paint", "tool")) <= set(w) for w in waypoints) def test_progress_state_round_trip_and_replan(): import tempfile from pathlib import Path painted = rectangle(120, 160, 10, 10, 90, 150) with tempfile.TemporaryDirectory() as folder: path = Path(folder) / "state.npz" assert rp.load_state(path) is None rp.save_state(path, painted, (160, 120)) loaded, size = rp.load_state(path) assert size == (160, 120) and np.array_equal(loaded, painted) # A fully painted target is recognised and yields no new strokes. plan, _ = rp.build_plan(painted, np.zeros_like(painted), stroke_width=30, overlap=0.2, margin=5, orientation="horizontal", corners=None, mm_per_unit=None, boundary_tolerance=3.0, already_painted=painted) assert plan["plan"]["segments"] == 0 assert plan["coverage"]["already_painted_fraction"] == 1.0 assert any("already fully painted" in w for w in plan["warnings"]) def test_progress_state_rejects_size_mismatch(): paintable = rectangle(120, 160, 10, 10, 90, 150) try: rp.build_plan(paintable, np.zeros_like(paintable), stroke_width=30, overlap=0.2, margin=5, orientation="horizontal", corners=None, mm_per_unit=None, boundary_tolerance=3.0, already_painted=rectangle(100, 100, 0, 0, 100, 100)) except ValueError as exc: assert "does not match plane" in str(exc) else: raise AssertionError("expected a size-mismatch ValueError") def test_measured_plane_size_sets_true_aspect(): # A foreshortened wall in the image: without the measured size the plane takes the # image's longest edges; with it, the plane has the wall's true proportions. paintable = rectangle(400, 600, 0, 0, 400, 600) corners = [(100, 80), (500, 120), (500, 300), (100, 340)] _, guessed = rp.build_plan(paintable, np.zeros_like(paintable), stroke_width=20, overlap=0.2, margin=5, orientation="horizontal", corners=corners, mm_per_unit=None, boundary_tolerance=4.0) plan, measured = rp.build_plan(paintable, np.zeros_like(paintable), stroke_width=20, overlap=0.2, margin=5, orientation="horizontal", corners=corners, mm_per_unit=10.0, boundary_tolerance=4.0, plane_size=(366, 229)) assert tuple(guessed["size"]) == (402, 260) and tuple(measured["size"]) == (366, 229) assert plan["plane_size"]["width"] == 366 and plan["plane_size"]["mm_per_unit"] == 10.0 def main(): tests = [value for name, value in sorted(globals().items()) if name.startswith("test_")] for test in tests: test() print(f"ok {test.__name__}") print(f"{len(tests)} tests passed") if __name__ == "__main__": main()