"""Unit tests for `undercooked.sim.grid` (phase 02 task 01). Geometry, walkability, BFS around walls, and every `GridError` the ASCII parser must refuse: ragged rows, unknown legend chars, empty/oversized maps. """ from __future__ import annotations import pytest from undercooked.sim.grid import MAX_GRID, Grid, GridError from undercooked.sim.model import TileKind, Vec2 pytestmark = pytest.mark.unit LEGEND = { ".": TileKind.FLOOR, "#": TileKind.WALL, "C": TileKind.COUNTER, "o": TileKind.VOID, } def make_grid(rows: list[str]) -> Grid: return Grid.from_rows(rows, LEGEND) # --- Grid.from_rows -------------------------------------------------------------- def test_from_rows_lays_tiles_out_row_major() -> None: grid = make_grid(["..#", "#.o", "C#."]) assert (grid.width, grid.height) == (3, 3) assert grid.tiles == ( TileKind.FLOOR, TileKind.FLOOR, TileKind.WALL, TileKind.WALL, TileKind.FLOOR, TileKind.VOID, TileKind.COUNTER, TileKind.WALL, TileKind.FLOOR, ) assert dict(grid.station_at) == {} def test_from_rows_rejects_no_rows() -> None: with pytest.raises(GridError, match="no rows"): make_grid([]) def test_from_rows_rejects_ragged_rows() -> None: with pytest.raises(GridError, match="ragged grid: row 1"): make_grid(["...", ".."]) def test_from_rows_rejects_unknown_legend_char() -> None: with pytest.raises(GridError, match=r"unknown legend char '@' at \(2,0\)"): make_grid(["..@"]) def test_from_rows_rejects_empty_row() -> None: with pytest.raises(GridError, match=r"1\.\.64"): make_grid([""]) def test_from_rows_rejects_oversized_map() -> None: with pytest.raises(GridError, match=r"1\.\.64"): make_grid(["." * (MAX_GRID + 1)]) @pytest.mark.parametrize( ("width", "height", "tiles"), [ (0, 1, ()), (1, 0, ()), (MAX_GRID + 1, 1, (TileKind.FLOOR,) * (MAX_GRID + 1)), (2, 2, (TileKind.FLOOR,) * 3), # length mismatch ], ) def test_direct_construction_validates_dimensions( width: int, height: int, tiles: tuple[TileKind, ...] ) -> None: with pytest.raises(GridError): Grid(width=width, height=height, tiles=tiles) # --- geometry --------------------------------------------------------------------- @pytest.fixture def grid() -> Grid: return make_grid(["..#", "#.o", "C#."]) @pytest.mark.parametrize( ("x", "y", "expected"), [(0, 0, True), (2, 2, True), (-1, 0, False), (0, -1, False), (3, 0, False), (0, 3, False)], ) def test_in_bounds(grid: Grid, x: int, y: int, expected: bool) -> None: assert grid.in_bounds(x, y) is expected def test_tile_at_in_and_out_of_bounds(grid: Grid) -> None: assert grid.tile_at(0, 0) is TileKind.FLOOR assert grid.tile_at(2, 0) is TileKind.WALL assert grid.tile_at(1, 1) is TileKind.FLOOR assert grid.tile_at(-1, 0) is None assert grid.tile_at(3, 2) is None def test_is_walkable(grid: Grid) -> None: assert grid.is_walkable(0, 0) is True assert grid.is_walkable(1, 2) is False # wall assert grid.is_walkable(2, 1) is False # void assert grid.is_walkable(0, 2) is False # counter assert grid.is_walkable(9, 9) is False # off-map def test_walkable_cells_is_row_major(grid: Grid) -> None: assert grid.walkable_cells() == (0, 1, 4, 8) def test_blocking_mask_covers_every_cell(grid: Grid) -> None: assert grid.blocking_mask() == ( False, False, True, True, False, True, True, True, False, ) # --- neighbours --------------------------------------------------------------------- def test_neighbours_four_way_order_is_north_west_east_south() -> None: grid = make_grid(["...", "...", "..."]) assert grid.neighbours(4) == (1, 3, 5, 7) def test_neighbours_eight_way_is_complete_around_a_center() -> None: grid = make_grid(["...", "...", "..."]) assert grid.neighbours(4, four_way=False) == (1, 0, 2, 3, 5, 6, 8, 7) def test_neighbours_are_clipped_to_the_map() -> None: grid = make_grid(["..", ".."]) assert grid.neighbours(0) == (1, 2) assert set(grid.neighbours(0, four_way=False)) == {1, 2, 3} def test_neighbours_reject_out_of_range_cells() -> None: grid = make_grid([".."]) with pytest.raises(GridError, match="out of range"): grid.neighbours(2) # --- bfs_reachable --------------------------------------------------------------------- def test_bfs_finds_the_whole_open_region() -> None: grid = make_grid(["...", "..."]) assert grid.bfs_reachable(0) == frozenset(range(6)) def test_bfs_walks_around_a_wall_column() -> None: grid = make_grid([".##.", "....", ".##."]) # every floor cell is connected through the middle row assert grid.bfs_reachable(0) == frozenset({0, 3, 4, 5, 6, 7, 8, 11}) def test_bfs_cannot_cross_a_full_wall() -> None: grid = make_grid(["...###", "...###"]) assert grid.bfs_reachable(0) == frozenset({0, 1, 2, 6, 7, 8}) def test_bfs_honours_extra_blocked_cells() -> None: grid = make_grid(["..."]) assert grid.bfs_reachable(0) == frozenset({0, 1, 2}) assert grid.bfs_reachable(0, extra_blocked=frozenset({1})) == frozenset({0}) assert grid.bfs_reachable(0, extra_blocked=frozenset({0})) == frozenset() def test_bfs_from_a_non_walkable_start_reaches_nothing() -> None: grid = make_grid([".#"]) assert grid.bfs_reachable(1) == frozenset() def test_bfs_rejects_an_out_of_range_start() -> None: grid = make_grid([".."]) with pytest.raises(GridError, match="out of range"): grid.bfs_reachable(2) # --- manhattan -------------------------------------------------------------------------- def test_manhattan_distance() -> None: assert Grid.manhattan(Vec2(1.0, 1.0), Vec2(4.0, 5.0)) == 7.0 assert Grid.manhattan(Vec2(-2.5, 0.25), Vec2(0.5, 0.25)) == 3.0 assert Grid.manhattan(Vec2(2.0, 2.0), Vec2(2.0, 2.0)) == 0.0