phase: 02_level_content_and_validation
Phase `02_level_content_and_validation` — final verification **green**. - **Gate:** `bash .agents/validate.sh` → ruff ✅ · pyright 0 errors ✅ · `undercooked-levels validate` OK (2 levels) ✅ · **668 passed** (incl. 10 Playwright e2e) ✅ · coverage **100%** ✅ · repo hygiene ✅ - **Criteria:** `validate` exit 0, one line per level; `list` = `01_first_kitchen`, `02_double_shift` in campaign order; live `:8000` `/healthz` 200, `/api/levels` (+detail) carries both levels with numeric `target_score`/`time_limit` and no grid/station leakage, `/api/config` correct, unknown id → 404, startup log `content loaded: 2 level(s), 4 recipe(s)`; `validate --dir tests/fixtures/levels/_bad/` → exit 1 with named `RECIPE_STATION_MISSING` / `SERVE_REACHABLE` / `TARGET_IMPLAUSIBLE`; A8 lock test proves a YAML-only level reaches loader+API+CLI. - **Defect fixed (pre-existing path, phase-02-triggered):** `undercooked` with unusable content printed the report then a raw traceback and exited `1`. `src/undercooked/__main__.py` now catches `ContentError` → one closing line, exit `2` (like a bad setting); 2 new tests in `tests/unit/test_main.py` (incl. non-content failures still propagate). - **Docs pass (full, project-wide):** README (content gate + exit `2`, config section), AGENTS.md §3, `docs/LEVELS.md` (startup refusal + exact CLI exit codes), `docs/DEPLOYMENT.md` (crash-loop diagnosis bullet); DESIGN/TESTING re-read, consistent. - **Container re-verified (no phase-01 regression):** `podman build -f Containerfile` succeeds; the image ships `levels/` and serves `/healthz` 200, `/api/levels` (2 levels), `/` 200 as non-root. - **Deviation to note:** the phase overview names `tests/fixtures/levels/bad/`; the actual broken-fixture dir is `tests/fixtures/levels/_bad/` (the `_` prefix is what keeps it out of normal discovery). Overview files are read-only to me — README/LEVELS.md document the real path. - No git index/history commands run; all changes left unstaged for the harness. ntfy notification sent.
This commit is contained in:
@@ -0,0 +1,207 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user