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:
2026-10-06 11:03:28 -04:00
parent 6fd83446ec
commit 3426264ffb
48 changed files with 8094 additions and 54 deletions
+207
View File
@@ -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