"""Unit tests for _run_f2c() orchestration, with fields2cover replaced by FakeF2C.
FakeF2C returns canned swaths, so these tests cover what the planner does around
fields2cover: boundary projection, angle wrapping, headland fallback, snake
ordering, obstacle clipping and projection back to lat/lon. They do not cover
fields2cover's own swath generation.
"""
# pylint: disable=protected-access
import math
import unittest
from unittest import mock
from devkit_f2c_planner.f2c_test_helpers import LAT0, LON0, FakeF2C, planner, silence_planner_log, to_ll, to_xy
FIELD_XY = [(0, 0), (100, 0), (100, 50), (0, 50)]
TOOL_WIDTH = 2.0
ROWS_XY = [[(0.0, y), (100.0, y)] for y in (5.0, 15.0, 25.0, 35.0)]
TOLERANCE_M = 1e-3
[docs]
class RunF2CTest(unittest.TestCase):
[docs]
def setUp(self):
silence_planner_log(self)
[docs]
def run_planner(self, fake: FakeF2C, obstacles_xy: list | None = None, angle_deg: float = 0.0, **kwargs) -> list:
"""Run _run_f2c() against the fake; returns rows in local xy metres."""
with mock.patch.object(planner, 'f2c', fake):
rows_ll = planner._run_f2c(
to_ll(FIELD_XY), [to_ll(ring) for ring in obstacles_xy or []], TOOL_WIDTH, angle_deg, **kwargs)
return [to_xy(row) for row in rows_ll]
[docs]
def assert_rows_almost_equal(self, actual: list, expected: list) -> None:
self.assertEqual(len(actual), len(expected))
for actual_row, expected_row in zip(actual, expected, strict=True):
self.assertEqual(len(actual_row), len(expected_row))
for (ax, ay), (ex, ey) in zip(actual_row, expected_row, strict=True):
self.assertAlmostEqual(ax, ex, delta=TOLERANCE_M)
self.assertAlmostEqual(ay, ey, delta=TOLERANCE_M)
# ── projection ───────────────────────────────────────────────────
[docs]
def test_boundary_is_projected_with_the_first_corner_as_origin(self):
fake = FakeF2C(ROWS_XY)
self.run_planner(fake)
outer_ring = fake.swath_calls[0][2].rings[0]
self.assert_rows_almost_equal([outer_ring], [FIELD_XY])
[docs]
def test_result_is_projected_back_to_latlon_at_the_first_corner(self):
fake = FakeF2C([[(0.0, 0.0), (100.0, 0.0)]])
with mock.patch.object(planner, 'f2c', fake):
(row,) = planner._run_f2c(to_ll(FIELD_XY), [], TOOL_WIDTH, 0.0)
(lat_start, lon_start), (lat_end, lon_end) = row
self.assertEqual((lat_start, lon_start), (LAT0, LON0))
self.assertEqual(lat_end, LAT0)
self.assertGreater(lon_end, LON0)
# ── angle ────────────────────────────────────────────────────────
[docs]
def test_angle_is_wrapped_to_a_half_turn_and_passed_in_radians(self):
for angle_deg, expected_deg in [(0, 0), (10, 10), (190, 10), (-10, 170), (180, 0), (370, 10)]:
with self.subTest(angle_deg=angle_deg):
fake = FakeF2C(ROWS_XY)
self.run_planner(fake, angle_deg=angle_deg)
self.assertAlmostEqual(fake.swath_calls[0][0], math.radians(expected_deg))
# ── snake ordering ───────────────────────────────────────────────
[docs]
def test_snake_order_reverses_every_second_swath(self):
rows = self.run_planner(FakeF2C(ROWS_XY), snake_order=True)
self.assert_rows_almost_equal(rows, [
[(0.0, 5.0), (100.0, 5.0)],
[(100.0, 15.0), (0.0, 15.0)],
[(0.0, 25.0), (100.0, 25.0)],
[(100.0, 35.0), (0.0, 35.0)],
])
[docs]
def test_without_snake_order_swaths_keep_the_direction_f2c_gave_them(self):
rows = self.run_planner(FakeF2C(ROWS_XY), snake_order=False)
self.assert_rows_almost_equal(rows, ROWS_XY)
[docs]
def test_swaths_with_fewer_than_two_points_are_ignored_and_do_not_shift_the_snake_order(self):
rows = self.run_planner(FakeF2C([[(0.0, 1.0)], *ROWS_XY[:2]]), snake_order=True)
self.assert_rows_almost_equal(rows, [[(0.0, 5.0), (100.0, 5.0)], [(100.0, 15.0), (0.0, 15.0)]])
# ── obstacles ────────────────────────────────────────────────────
[docs]
def test_obstacle_clips_swaths_even_though_f2c_ignored_it(self):
obstacle = [(40, 20), (60, 20), (60, 30), (40, 30)] # crosses the y = 25 swath only
rows = self.run_planner(FakeF2C(ROWS_XY), [obstacle], snake_order=False)
self.assert_rows_almost_equal(rows, [
ROWS_XY[0], ROWS_XY[1], [(0.0, 25.0), (40.0, 25.0)], [(60.0, 25.0), (100.0, 25.0)], ROWS_XY[3],
])
[docs]
def test_clipped_fragments_follow_the_snake_direction(self):
obstacle = [(40, 10), (60, 10), (60, 20), (40, 20)] # crosses the reversed y = 15 swath
rows = self.run_planner(FakeF2C(ROWS_XY[:2]), [obstacle], snake_order=True)
self.assert_rows_almost_equal(rows, [
ROWS_XY[0], [(100.0, 15.0), (60.0, 15.0)], [(40.0, 15.0), (0.0, 15.0)],
])
[docs]
def test_obstacle_padding_widens_the_gap(self):
obstacle = [(40, 20), (60, 20), (60, 30), (40, 30)]
rows = self.run_planner(FakeF2C(ROWS_XY[2:3]), [obstacle], snake_order=False, obstacle_pad_m=5.0)
self.assert_rows_almost_equal(rows, [[(0.0, 25.0), (35.0, 25.0)], [(65.0, 25.0), (100.0, 25.0)]])
[docs]
def test_obstacles_are_also_given_to_f2c_as_hole_rings(self):
fake = FakeF2C(ROWS_XY)
self.run_planner(fake, [[(40, 20), (60, 20), (60, 30), (40, 30)], [(10, 10), (20, 10), (20, 20)]])
self.assertEqual(len(fake.swath_calls[0][2].rings), 3) # boundary + two obstacles
[docs]
def test_invalid_obstacle_is_not_given_to_f2c(self):
fake = FakeF2C(ROWS_XY)
self.run_planner(fake, [[(0, 0), (5, 0), (10, 0)]])
self.assertEqual(len(fake.swath_calls[0][2].rings), 1)
# ── headland ─────────────────────────────────────────────────────
[docs]
def test_no_headland_uses_the_full_boundary_without_calling_the_generator(self):
fake = FakeF2C(ROWS_XY)
self.run_planner(fake, headland_width_m=0.0)
self.assertEqual(fake.headland_widths, [])
self.assertIsNot(fake.swath_calls[0][2], fake.inset_cell)
[docs]
def test_headland_swaths_are_generated_on_the_inset_cell(self):
fake = FakeF2C(ROWS_XY)
self.run_planner(fake, headland_width_m=3.0)
self.assertEqual(fake.headland_widths, [3.0])
self.assertIs(fake.swath_calls[0][2], fake.inset_cell)
[docs]
def test_headland_that_leaves_no_cells_falls_back_to_the_full_boundary(self):
fake = FakeF2C(ROWS_XY, headland_cells=[])
rows = self.run_planner(fake, headland_width_m=3.0, snake_order=False)
self.assertEqual(fake.headland_widths, [3.0])
self.assertEqual(len(fake.swath_calls[0][2].rings), 1)
self.assertEqual(len(rows), len(ROWS_XY))
[docs]
def test_headland_generator_failure_falls_back_to_the_full_boundary(self):
fake = FakeF2C(ROWS_XY, headland_error=RuntimeError('degenerate field'))
rows = self.run_planner(fake, headland_width_m=3.0, snake_order=False)
self.assertEqual(len(fake.swath_calls[0][2].rings), 1)
self.assertEqual(len(rows), len(ROWS_XY))
if __name__ == '__main__':
unittest.main()