Last active
March 10, 2025 03:58
-
-
Save paretech/a2c00bd63ee0d2c9740faefef145b8c9 to your computer and use it in GitHub Desktop.
Grid Explorer
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
"""Convenience module for working with grids""" | |
import dataclasses | |
import unittest | |
@dataclasses.dataclass | |
class GridPoint: | |
x: int | |
y: int | |
def __iter__(self): | |
yield self.x | |
yield self.y | |
def __str__(self) -> str: | |
return f"({self.x}, {self.y})" | |
@property | |
def row(self): | |
return self.y | |
@property | |
def column(self): | |
return self.x | |
class GridArea: | |
def __init__( | |
self, | |
p1: tuple[int, int], | |
p2: tuple[int, int], | |
cells_per_col: int = 1, | |
min_move: int = 1, | |
max_move: int = None, | |
): | |
"""Define and iterate over grid | |
Grid defined in terms of unit of cells. The smallest grid (p1 == p2) has | |
area of 1. | |
The number of cells that can be visited in a single iteration is | |
determined by requirements specified as initialization arguments. | |
Args: | |
p1: Tuple of positive integers representing the upper left hand | |
corner of grid area. | |
p2: Tuple of positive integers representing the lower right hand | |
corner of grid area. | |
cells_per_col: Positive integer representing how many cells are in a | |
column. Defaults to 1. | |
min_move: Positive integer representing the minimum number of cells | |
that can be consumed in a single iteration. The total grid area must | |
be evenly divisible by this value. Defaults to 1. | |
max_move: Positive integer representing the maximum number of cells | |
that can be consumed in a single iteration. Defaults to `remaining` | |
area. | |
Raises: | |
ValueError: Raised if starting x position is not evenly divisible by | |
specified `cells`. | |
ValueError: Raised if width is not evenly divisible by specified | |
`cells`. | |
ValueError: Raised if area is not divisible by `min_move`. | |
""" | |
# Track how many cells of grid area have been visited | |
self._visited = 0 | |
# Track how many steps have been taken so far | |
self._steps = 0 | |
# Track if `move` method was called | |
self._has_moved = None | |
self._p1, self._p2 = GridPoint(*p1), GridPoint(*p2) | |
self._cells_per_column = cells_per_col | |
self._min_move = min_move | |
self._max_move = max_move or self.remaining | |
if not ((self._p1.x <= self._p2.x) and (self._p1.y <= self._p2.y)): | |
raise ValueError("p2 must be greater than or equal to p1") | |
if (self._p1.x % self._cells_per_column) != 0: | |
raise ValueError( | |
f"The starting x position (p1.x) must be evenly divisible by {self._cells_per_column}" | |
) | |
if (self.width % self._cells_per_column) != 0: | |
raise ValueError( | |
f"`width` ({self.width}) must be evenly divisible by `cells_per_col` ({self._cells_per_column})" | |
) | |
if (self.area % self.min_move) != 0: | |
raise ValueError( | |
f"Area ({self.area}) must be evenly divisible by min_move ({self.min_move})" | |
) | |
def __iter__(self) -> "GridArea": | |
self.reset() | |
return self | |
def __next__(self) -> "GridArea": | |
"""Iterate over a grid area until all cells have been explored""" | |
if self.remaining <= 0: | |
raise StopIteration | |
# First iteration does NOT trigger auto-move (`None` check prevents it). | |
# Subsequent iterations trigger auto-move unless `move()` is called. | |
if self._has_moved is False: | |
self._move_default() | |
# Reset for next iteration | |
self._has_moved = False | |
return self | |
def __repr__(self): | |
return ( | |
f"{self.__class__.__name__}(" | |
f"p1={self._p1}, p2={self._p2}, cells_per_col={self._cells_per_column}, " | |
f"min_move={self._min_move}, max_move={self._max_move})" | |
) | |
@property | |
def status(self) -> str: | |
"""Return a status string summarizing state""" | |
return ( | |
f"[Step {self._steps}] Visited: {self.visited}/{self.area} " | |
f"(Remaining: {self.remaining}, Max Move: {self.max_move})\n" | |
f" ├── Next: ({self.next_row}, {self.next_column})\n" | |
f" └── Last: ({self.last_row}, {self.last_col})" | |
) | |
@property | |
def width(self) -> int: | |
"""Return width of area as cell count""" | |
return self._p2.x - self._p1.x + 1 | |
@property | |
def height(self) -> int: | |
"""Return height of area as cell count""" | |
return self._p2.y - self._p1.y + 1 | |
@property | |
def size(self) -> tuple[int, int]: | |
"""Return (width, height)""" | |
return self.width, self.height | |
@property | |
def area(self) -> int: | |
"""Return the area as cell count""" | |
return self.width * self.height | |
@property | |
def rows(self) -> int: | |
"""Return number of rows in area""" | |
return self.height | |
@property | |
def columns(self) -> int: | |
"""Return number of columns in area as function of `cells`""" | |
return self.width // self._cells_per_column | |
@property | |
def visited(self) -> int: | |
"""Return number of grid cells already visited""" | |
return self._visited | |
@property | |
def remaining(self) -> int: | |
"""Return number of grid cells not yet visited""" | |
return self.area - self.visited | |
@property | |
def next_row(self) -> int: | |
"""Return row index of the next cell to visit""" | |
return self._calc_row(self.visited) | |
@property | |
def next_column(self) -> int: | |
"""Return column index of the next cell to visit""" | |
return self._calc_col(self.visited) | |
@property | |
def last_row(self) -> int: | |
"""Return row index of the last available cell to visit | |
Last cell available is limited by "max_step" | |
""" | |
return self._calc_row(self.visited + self.max_move - 1) | |
@property | |
def last_col(self) -> int: | |
"""Return row index of the last available cell to visit | |
Last cell available is limited by "max_step" | |
""" | |
return self._calc_col(self.visited + self.max_move - 1) | |
@property | |
def max_move(self) -> int: | |
"""Return the largest valid move available | |
Value is guranteed to be an even multiple of min_move. | |
""" | |
return self._nearest_valid_move(min(self._max_move, self.remaining)) | |
@property | |
def min_move(self) -> int: | |
return self._min_move | |
def reset(self) -> None: | |
"""Reset visited and steps counter""" | |
self._visited = 0 | |
self._steps = 0 | |
self._has_moved = None | |
def move(self, count: int) -> None: | |
"""Advance the visited cells counter. | |
Args: | |
count: Number of cells to move. Must be a multiple of `min_move` and | |
cannot exceed `max_move`. | |
Raises: | |
RuntimeError: If move is attempted but no cells remaining. | |
ValueError: If `count` is not a multiple of `min_move` or exceeds `max_move`. | |
""" | |
if self.remaining <= 0: | |
raise RuntimeError("Cannot move: no cells remaining.") | |
if (count % self._min_move) != 0: | |
raise ValueError(f"Count must be evenly divisible by {self._min_move}") | |
if count > self.max_move: | |
raise ValueError(f"Count must be less than or equal to {self.max_move}") | |
self._visited += count | |
self._steps += 1 | |
# If `_has_moved` is False, then `__next__` was just called and we are | |
# in iterator protocol. It is important to set `_has_moved` to True so | |
# that the default move is not triggered. | |
if self._has_moved is False: | |
self._has_moved = True | |
def _move_default(self): | |
self.move(self._min_move) | |
def _nearest_valid_move(self, count: int) -> int: | |
return (count // self._min_move) * self._min_move | |
def _calc_row(self, cell_index: int) -> int: | |
"""Calculate next row index given cell index""" | |
if self.remaining <= 0: | |
return self._p2.row | |
return (cell_index // self.width) + self._p1.row | |
def _calc_col(self, cell_index: int) -> int: | |
"""Calculate next column index given cell index""" | |
if self.remaining <= 0: | |
return self._p2.column | |
return (cell_index % self.columns) + (self._p1.column % self._cells_per_column) | |
class TestGridPoint(unittest.TestCase): | |
def test_grid_point_init(self): | |
"""Test GridPoint initialization.""" | |
p = GridPoint(3, 4) | |
self.assertEqual(p.x, 3) | |
self.assertEqual(p.y, 4) | |
def test_grid_point_str(self): | |
"""Test GridPoint string representation.""" | |
p = GridPoint(3, 4) | |
self.assertEqual(str(p), "(3, 4)") | |
def test_grid_point_iter(self): | |
"""Test unpacking GridPoint using iteration.""" | |
p = GridPoint(3, 4) | |
self.assertEqual(tuple(p), (3, 4)) | |
class TestGridArea(unittest.TestCase): | |
def test_grid_area_init(self): | |
"""Test initialization of GridArea with valid inputs.""" | |
grid = GridArea((0, 0), (4, 4), cells_per_col=1, min_move=1) | |
self.assertEqual(grid.width, 5) | |
self.assertEqual(grid.height, 5) | |
self.assertEqual(grid.area, 25) | |
def test_grid_area_invalid_p1_p2(self): | |
"""Test invalid GridArea initialization when p1 is not top-left of p2.""" | |
with self.assertRaises(ValueError): | |
GridArea((5, 5), (2, 2), cells_per_col=1, min_move=1) | |
def test_grid_area_invalid_cells_per_column(self): | |
"""Test validation of starting x position divisibility by cells_per_col.""" | |
with self.assertRaises(ValueError): | |
GridArea((1, 0), (4, 4), cells_per_col=2, min_move=1) | |
def test_grid_area_invalid_width_divisibility(self): | |
"""Test validation of width divisibility by cells_per_col.""" | |
with self.assertRaises(ValueError): | |
GridArea((0, 0), (6, 4), cells_per_col=2, min_move=1) | |
def test_grid_area_invalid_area_divisibility(self): | |
"""Test validation of area divisibility by min_step.""" | |
with self.assertRaises(ValueError): | |
GridArea((0, 0), (4, 4), cells_per_col=1, min_move=3) | |
def test_grid_area_auto_iteration(self): | |
"""Test that GridArea iterates correctly over all cells.""" | |
grid = GridArea((0, 0), (2, 2), cells_per_col=1, min_move=1) | |
visited_steps = [] | |
for area in grid: | |
visited_steps.append(area.visited) | |
self.assertEqual(visited_steps[-1], grid.area) | |
def test_grid_area_manual_movement(self): | |
"""Test manual movement through the grid using move().""" | |
area = GridArea((0, 0), (2, 2), cells_per_col=1, min_move=1) | |
while area.remaining > 0: | |
area.move(area.min_move) | |
self.assertEqual(area.visited, area.area) | |
def test_grid_area_move_valid(self): | |
"""Test valid moves within grid constraints.""" | |
area = GridArea((0, 0), (4, 4), cells_per_col=1, min_move=1) | |
area.move(5) | |
self.assertEqual(area.visited, 5) | |
def test_grid_area_move_invalid_step(self): | |
"""Test move with an invalid step size (not divisible by min_step).""" | |
area = GridArea((0, 0), (5, 5), cells_per_col=1, min_move=2) | |
with self.assertRaises(ValueError): | |
area.move(3) # Not a multiple of min_step=2 | |
def test_grid_area_move_exceeding_max(self): | |
"""Test move that exceeds max_move constraint.""" | |
area = GridArea((0, 0), (4, 4), cells_per_col=1, min_move=1, max_move=5) | |
with self.assertRaises(ValueError): | |
area.move(6) # Exceeds max_step=5 | |
def test_grid_area_remaining(self): | |
"""Test remaining cell calculation.""" | |
area = GridArea((0, 0), (4, 4), cells_per_col=1, min_move=1) | |
self.assertEqual(area.remaining, area.area) | |
area.move(10) | |
self.assertEqual(area.remaining, area.area - 10) | |
def test_grid_area_last_cell_calculation(self): | |
"""Test calculation of last available cell for current step.""" | |
area = GridArea((0, 0), (4, 4), cells_per_col=1, min_move=1, max_move=3) | |
area.move(3) | |
self.assertEqual(area.last_row, area._calc_row(5)) | |
self.assertEqual(area.last_col, area._calc_col(5)) | |
def test_grid_area_status(self): | |
"""Test status message generation.""" | |
area = GridArea((0, 0), (4, 4), cells_per_col=1, min_move=1, max_move=5) | |
expected_status = "[Step 0] Visited: 0/25 (Remaining: 25, Max Move: 5)\n ├── Next: (0, 0)\n └── Last: (0, 4)" | |
self.assertEqual(area.status, expected_status) | |
class TestAdHoc(unittest.TestCase): | |
def test_grid_explore(self): | |
# Max step here is limitation of the grid | |
grid = GridArea((0, 0), (4, 4), max_move=4) | |
# Max step here is limitation of the explorer | |
max_step = 2 | |
# print(f"Exploring {grid}") | |
for step, area in enumerate(grid): | |
# print(f"Step {step}: {area.status}") | |
step_size = min(area.max_move, max_step) | |
# print(f"\tVisited: {step_size} cells") | |
area.move(step_size) | |
def test_min_grid(self): | |
"""Smallest grid area""" | |
# Max step here is limitation of the grid | |
grid = GridArea((0, 0), (0, 0), max_move=4) | |
# Max step here is limitation of the explorer | |
client_max_move = 2 | |
# print(f"Exploring {grid}") | |
for step, area in enumerate(grid): | |
# print(f"Step {step}: {area.status}") | |
step_size = min(area.max_move, client_max_move) | |
# print(f"\tVisited: {step_size} cells") | |
area.move(step_size) | |
def test_unlimited_move(self): | |
"""Large grid with no move constraints""" | |
grid = GridArea((0, 0), (1024, 768)) | |
# print(f"Exploring {grid}") | |
for step, area in enumerate(grid): | |
# print(f"Step {step}: {area.status}") | |
step_size = area.max_move | |
# print(f"\tVisited: {step_size} cells") | |
area.move(step_size) | |
def test_client_limited(self): | |
"""Large grid with no move constraints""" | |
grid = GridArea((0, 0), (1024, 768)) | |
# Max step here is limitation of the explorer | |
client_max_move = 512 | |
for area in grid: | |
step_size = min(area.max_move, client_max_move) | |
area.move(step_size) | |
def test_dev(self): | |
grid = GridArea( | |
(799 - 5, 599 - 5), | |
(799, 599), | |
cells_per_col=2, | |
min_move=4, | |
max_move=256, | |
) | |
# Max step here is limitation of the explorer | |
client_max_move = 4 | |
# print( | |
# f"Exploring {grid} ({grid.width}x{grid.height} grid = {grid.rows} rows, {grid.columns} columns)" | |
# ) | |
for area in grid: | |
# print(f"{area.status}") | |
step_size = min(area.max_move, client_max_move) | |
# print(f"\tVisited: {step_size} cells") | |
area.move(step_size) | |
# print(f"{area.status}") | |
self.assertEqual(area._steps, 9) | |
self.assertEqual(area.remaining, 0) | |
if __name__ == "__main__": | |
unittest.main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment