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
+431
View File
@@ -0,0 +1,431 @@
"""Unit tests for the pure simulation data model (phase 02 task 01, PLAN §5).
Covers the enum vocabulary, `Vec2` geometry, the frozen entities and their
value-semantics helpers, and the `state_hash` contract the replay tests
(A21) will rely on: scored changes change the hash, cosmetic ones never do.
"""
from __future__ import annotations
from dataclasses import replace
from typing import Any
import pytest
from undercooked.sim.grid import Grid
from undercooked.sim.model import (
OPPOSITE,
WALKABLE,
Event,
EventKind,
Facing,
Hazard,
HazardKind,
Item,
ItemForm,
ItemKind,
KitchenState,
Order,
Player,
PlayerState,
RoundResult,
Station,
StationKind,
StationState,
TileKind,
Vec2,
_canonical,
)
pytestmark = pytest.mark.unit
@pytest.fixture
def state() -> KitchenState:
grid = Grid(width=3, height=2, tiles=(TileKind.FLOOR,) * 6)
return KitchenState(level_id="01_test", seed=42, grid=grid, target_score=40, time_left=90.0)
# --- Facing -------------------------------------------------------------------
@pytest.mark.parametrize(
("facing", "delta"),
[
(Facing.UP, Vec2(0.0, -1.0)),
(Facing.RIGHT, Vec2(1.0, 0.0)),
(Facing.DOWN, Vec2(0.0, 1.0)),
(Facing.LEFT, Vec2(-1.0, 0.0)),
],
)
def test_facing_delta_is_a_unit_step(facing: Facing, delta: Vec2) -> None:
assert facing.delta() == delta
def test_facing_wire_values() -> None:
assert [f.value for f in Facing] == ["up", "right", "down", "left"]
def test_opposite_pairs_and_is_involutive() -> None:
assert len(OPPOSITE) == 4
for facing in Facing:
assert OPPOSITE[OPPOSITE[facing]] is facing
# --- Vec2 ---------------------------------------------------------------------
def test_vec2_defaults_to_zero() -> None:
assert Vec2() == Vec2(0.0, 0.0)
def test_vec2_add_and_sub() -> None:
a, b = Vec2(2.0, 3.0), Vec2(0.5, -1.0)
assert a + b == Vec2(2.5, 2.0)
assert a - b == Vec2(1.5, 4.0)
def test_vec2_scale() -> None:
assert Vec2(1.5, -2.0).scale(2.0) == Vec2(3.0, -4.0)
@pytest.mark.parametrize(
("vec", "expected"),
[(Vec2(3.0, 4.0), 5.0), (Vec2(0.0, 0.0), 0.0), (Vec2(-1.0, 0.0), 1.0)],
)
def test_vec2_length(vec: Vec2, expected: float) -> None:
assert vec.length == pytest.approx(expected)
def test_vec2_normalized() -> None:
unit = Vec2(3.0, 4.0).normalized()
assert unit.x == pytest.approx(0.6)
assert unit.y == pytest.approx(0.8)
assert unit.length == pytest.approx(1.0)
def test_vec2_zero_normalizes_to_zero_without_division_error() -> None:
assert Vec2(0.0, 0.0).normalized() == Vec2(0.0, 0.0)
def test_vec2_quantized() -> None:
assert Vec2(1.23456, -2.98765).quantized() == Vec2(1.23, -2.99)
assert Vec2(1.23456, -2.98765).quantized(digits=1) == Vec2(1.2, -3.0)
@pytest.mark.parametrize(
("vec", "key"),
[
(Vec2(0.0, 0.0), (0, 0)),
(Vec2(1.5, 2.25), (96, 144)),
(Vec2(0.02, 0.01), (1, 1)),
(Vec2(-0.03, -0.007), (-2, 0)),
],
)
def test_vec2_key_is_64th_tile_resolution(vec: Vec2, key: tuple[int, int]) -> None:
assert key == vec.KEY
# --- TileKind / WALKABLE -------------------------------------------------------
def test_only_floor_is_walkable() -> None:
assert frozenset({TileKind.FLOOR}) == WALKABLE
assert {t.value for t in TileKind} == {"floor", "counter", "wall", "void"}
# --- Item ----------------------------------------------------------------------
def test_item_defaults() -> None:
item = Item(kind=ItemKind.TOMATO, form=ItemForm.RAW)
assert (item.quality, item.ttl, item.dish_id) == (1.0, 0.0, None)
def test_item_is_trash_only_when_burnt() -> None:
for form in ItemForm:
item = Item(kind=ItemKind.PATTY, form=form)
assert item.is_trash() is (form is ItemForm.BURNT)
def test_item_with_form_returns_a_new_instance() -> None:
raw = Item(kind=ItemKind.ONION, form=ItemForm.RAW, quality=0.5)
chopped = raw.with_form(ItemForm.CHOPPED)
assert chopped is not raw
assert chopped.form is ItemForm.CHOPPED
assert chopped.kind is ItemKind.ONION and chopped.quality == 0.5
assert raw.form is ItemForm.RAW # value semantics: the original never changes
# --- Station / Player / Order / Hazard ------------------------------------------
def test_station_defaults() -> None:
station = Station(id="stove_1", kind=StationKind.STOVE, x=3.0, y=2.0)
assert (station.contents, station.state) == ((), StationState.IDLE)
assert (station.progress, station.duration, station.cooldown) == (0.0, 0.0, 0.0)
assert station.worker_id is None and station.dirty is False
def test_station_kinds_and_states_match_the_plan_vocabulary() -> None:
assert {k.value for k in StationKind} == {
"spawn_shelf",
"chop_board",
"stove",
"prep",
"serve",
"sink",
"trash",
}
assert {s.value for s in StationState} == {
"idle",
"working",
"ready",
"busy",
"dirty",
"blocked",
}
def test_player_defaults() -> None:
player = Player(id="tok")
assert player.pos == Vec2(0.0, 0.0) and player.vel == Vec2(0.0, 0.0)
assert player.facing is Facing.DOWN and player.carrying is None
assert player.state is PlayerState.IDLE
assert (player.stun, player.score, player.dishes_served) == (0.0, 0, 0)
assert player.connected is True and player.seat == 0
def test_player_state_vocabulary() -> None:
assert {s.value for s in PlayerState} == {"idle", "moving", "using", "stunned", "disconnected"}
def test_order_defaults() -> None:
order = Order(id=17, recipe_id="soup")
assert (order.time_left, order.deadline, order.value) == (0.0, 0.0, 0)
def test_hazard_defaults() -> None:
hazard = Hazard(id="hz1", kind=HazardKind.FIRE)
assert hazard.pos == Vec2(0.0, 0.0)
assert hazard.life == 0.0 and hazard.path == () and hazard.period_s == 0.0
assert hazard.station_id is None and hazard.chance_per_s == 0.0
def test_hazard_and_result_vocabularies() -> None:
assert {k.value for k in HazardKind} == {"fire", "spill", "moving_obstacle", "rush"}
assert {r.value for r in RoundResult} == {"won", "lost", "aborted"}
def test_item_kind_and_form_vocabularies() -> None:
assert {k.value for k in ItemKind} == {
"tomato",
"onion",
"lettuce",
"dough",
"patty",
"rice",
"fish",
"plate",
}
assert {f.value for f in ItemForm} == {
"raw",
"chopped",
"cooking",
"cooked",
"burnt",
"plated",
"dirty",
}
# --- Event / EventKind -----------------------------------------------------------
@pytest.mark.parametrize(
("kind", "value"),
[
(EventKind.CHOPPED, "chopped"),
(EventKind.SERVED, "served"),
(EventKind.BURNT, "burnt"),
(EventKind.ORDER_MISSED, "order_missed"),
(EventKind.FIRE_STARTED, "fire_started"),
(EventKind.DENIED, "denied"),
(EventKind.SCORE, "score"),
(EventKind.STUNNED, "stunned"),
(EventKind.WASHED, "washed"),
(EventKind.TRASHED, "trashed"),
(EventKind.TICK_SLOW, "tick_slow"),
],
)
def test_event_kind_constants(kind: EventKind, value: str) -> None:
assert kind == value
def test_event_defaults() -> None:
event = Event(kind=EventKind.SERVED)
assert event.kind == "served"
assert (event.x, event.y) == (0.0, 0.0)
assert dict(event.payload) == {}
def test_event_carries_a_payload() -> None:
event = Event(kind=EventKind.SCORE, x=9.0, y=1.0, payload={"points": 40})
assert event.payload["points"] == 40
# --- KitchenState.state_hash -------------------------------------------------------
def test_state_hash_is_stable_hex(state: KitchenState) -> None:
first = state.state_hash()
assert first == state.state_hash()
assert len(first) == 64
assert all(c in "0123456789abcdef" for c in first)
@pytest.mark.parametrize(
"mutation",
[
lambda s: replace(s, score=s.score + 1),
lambda s: replace(s, target_score=s.target_score + 10),
lambda s: replace(s, tick=s.tick + 1),
lambda s: replace(s, time_left=s.time_left + 0.01),
lambda s: replace(s, streak=s.streak + 1),
lambda s: replace(s, result=RoundResult.WON),
lambda s: replace(s, seed=s.seed + 1),
lambda s: replace(s, level_id="02_other"),
lambda s: replace(s, rng_state=s.rng_state ^ 1),
lambda s: replace(s, counters={"served": 1}),
lambda s: replace(s, players=(Player(id="p1"),)),
lambda s: replace(s, stations=(Station(id="s1", kind=StationKind.STOVE),)),
lambda s: replace(s, orders=(Order(id=1, recipe_id="soup"),)),
lambda s: replace(s, hazards=(Hazard(id="h1", kind=HazardKind.FIRE),)),
lambda s: replace(
s, grid=Grid(width=3, height=2, tiles=(TileKind.FLOOR,) * 5 + (TileKind.WALL,))
),
],
ids=[
"score",
"target_score",
"tick",
"time_left",
"streak",
"result",
"seed",
"level_id",
"rng_state",
"counters",
"players",
"stations",
"orders",
"hazards",
"grid",
],
)
def test_state_hash_changes_for_scored_fields(
state: KitchenState, mutation: Any
) -> None:
assert mutation(state).state_hash() != state.state_hash()
def test_state_hash_ignores_counter_insertion_order() -> None:
grid = Grid(width=1, height=1, tiles=(TileKind.FLOOR,))
left = KitchenState("01_a", 1, grid, counters={"served": 2, "missed": 1})
right = KitchenState("01_a", 1, grid, counters={"missed": 1, "served": 2})
assert left.state_hash() == right.state_hash()
def test_state_hash_ignores_station_at_insertion_order() -> None:
tiles = (TileKind.FLOOR,) * 6
grid_a = Grid(width=3, height=2, tiles=tiles, station_at={0: "shelf", 5: "serve"})
grid_b = Grid(width=3, height=2, tiles=tiles, station_at={5: "serve", 0: "shelf"})
assert (
KitchenState("01_a", 1, grid_a).state_hash()
== KitchenState("01_a", 1, grid_b).state_hash()
)
def test_state_hash_ignores_float_repr_noise(state: KitchenState) -> None:
# cosmetic float noise (< 1e-6) is below the canonical quantization...
assert replace(state, time_left=90.0 + 1e-9).state_hash() == state.state_hash()
assert replace(state, time_left=-0.0).state_hash() == replace(
state, time_left=0.0
).state_hash()
# ...but a real difference above it always changes the hash
assert replace(state, time_left=90.000001).state_hash() != state.state_hash()
# --- KitchenState.with_* helpers -----------------------------------------------------
def test_with_tick_and_time_left_return_new_instances(state: KitchenState) -> None:
advanced = state.with_tick(7).with_time_left(83.5)
assert advanced.tick == 7 and advanced.time_left == 83.5
assert advanced is not state
assert state.tick == 0 and state.time_left == 90.0
def test_with_score_optionally_sets_streak(state: KitchenState) -> None:
scored = state.with_score(40)
assert scored.score == 40 and scored.streak == state.streak
with_streak = state.with_score(40, streak=3)
assert with_streak.score == 40 and with_streak.streak == 3
assert state.score == 0
@pytest.mark.parametrize("result", [RoundResult.WON, RoundResult.LOST, None])
def test_with_result(state: KitchenState, result: RoundResult | None) -> None:
updated = state.with_result(result)
assert updated.result is result
assert state.result is None
def test_with_entity_collections(state: KitchenState) -> None:
stations = (Station(id="s1", kind=StationKind.CHOP_BOARD),)
players = (Player(id="p1", seat=0),)
orders = (Order(id=1, recipe_id="soup"),)
hazards = (Hazard(id="h1", kind=HazardKind.SPILL),)
updated = (
state.with_stations(stations)
.with_players(players)
.with_orders(orders)
.with_hazards(hazards)
)
assert updated.stations == stations
assert updated.players == players
assert updated.orders == orders
assert updated.hazards == hazards
assert state.stations == () and state.players == ()
assert state.orders == () and state.hazards == ()
def test_with_counters_merges_without_mutating(state: KitchenState) -> None:
merged = state.with_counters(served=1).with_counters(served=2, missed=1)
assert dict(merged.counters) == {"served": 2, "missed": 1}
assert dict(state.counters) == {}
# --- _canonical ------------------------------------------------------------------------
def test_canonical_scalars_are_typed_tokens() -> None:
assert _canonical(None) == "n:"
assert _canonical(True) == "b:1"
assert _canonical(1) == "i:1" # bool is checked before int
assert _canonical("served") == "s:served"
assert _canonical(Facing.UP) == "s:up" # StrEnums canonicalise by value
assert _canonical(2.5) == "f:2.500000"
def test_canonical_sorts_mapping_keys() -> None:
assert _canonical({"b": 1, "a": 2}) == _canonical({"a": 2, "b": 1})
def test_canonical_renders_dataclasses_field_name_sorted() -> None:
assert _canonical(Vec2(1.0, 2.0)) == "d:Vec2(x=f:1.000000,y=f:2.000000)"
def test_canonical_rejects_unknown_types_so_fields_cannot_hide() -> None:
with pytest.raises(TypeError, match="cannot canonicalise"):
_canonical(object())
+81
View File
@@ -0,0 +1,81 @@
"""Unit tests for the A5 purity guard (phase 02 task 01).
Proves the shipped `sim/` package imports nothing forbidden, and — just as
important — that the guard actually catches a violation when one exists.
"""
from __future__ import annotations
from pathlib import Path
import pytest
from undercooked.sim.purity import (
FORBIDDEN_ROOTS,
PurityError,
assert_pure_imports,
find_violations,
)
pytestmark = pytest.mark.unit
def test_forbidden_roots_are_the_plan_a5_list() -> None:
assert frozenset({"asyncio", "fastapi", "socket", "time", "uvicorn"}) == FORBIDDEN_ROOTS
def test_shipped_sim_package_is_pure() -> None:
assert find_violations() == []
assert_pure_imports() # must not raise
def test_clean_module_with_stdlib_sibling_and_relative_imports(tmp_path: Path) -> None:
(tmp_path / "ok.py").write_text(
"import math\n"
"from hashlib import sha256\n"
"from undercooked.sim.model import Vec2\n"
"from . import model\n",
encoding="utf-8",
)
assert find_violations(tmp_path) == []
def test_comments_and_strings_are_not_imports(tmp_path: Path) -> None:
(tmp_path / "prose.py").write_text(
'# we must never "import asyncio" here\nDOC = "import time is forbidden"\n',
encoding="utf-8",
)
assert find_violations(tmp_path) == []
@pytest.mark.parametrize(
("statement", "root"),
[
("import asyncio", "asyncio"),
("import time", "time"),
("from time import monotonic", "time"),
("import socket", "socket"),
("from socket import socket", "socket"),
("from fastapi import FastAPI", "fastapi"),
("import uvicorn", "uvicorn"),
("import uvicorn.protocols", "uvicorn"),
],
)
def test_find_violations_detects_each_forbidden_import(
tmp_path: Path, statement: str, root: str
) -> None:
(tmp_path / "bad.py").write_text(f"{statement}\n", encoding="utf-8")
assert find_violations(tmp_path) == [f"bad.py: imports {root}"]
def test_find_violations_scans_subpackages(tmp_path: Path) -> None:
sub = tmp_path / "deep"
sub.mkdir()
(sub / "leak.py").write_text("import asyncio\n", encoding="utf-8")
assert find_violations(tmp_path) == ["deep/leak.py: imports asyncio"]
def test_assert_pure_imports_raises_with_the_violation_list(tmp_path: Path) -> None:
(tmp_path / "bad.py").write_text("import time\nimport socket\n", encoding="utf-8")
with pytest.raises(PurityError, match=r"bad\.py: imports socket"):
assert_pure_imports(tmp_path)
+490
View File
@@ -0,0 +1,490 @@
"""Unit tests for `undercooked.sim.validation` (phase 02 task 03).
One failing case per rule id (asserted against `RuleId.ALL`) plus the passing
counterpart, a planted-unreachable-station exactness test, the bundle rules,
and the escalation/target math. Every fixture level is built by `make_level`
so a rule's failing case is one readable override away.
"""
from __future__ import annotations
import copy
from collections.abc import Callable
from dataclasses import replace
from typing import Any
import pytest
from undercooked.sim.content import ContentBundle
from undercooked.sim.grid import Grid
from undercooked.sim.level import Level, LevelDef
from undercooked.sim.model import TileKind
from undercooked.sim.recipe import RecipeBook
from undercooked.sim.validation import (
TARGET_WARN_FRACTION,
RuleId,
escalation_axes,
theoretical_max_score,
validate_bundle,
validate_level,
validate_levels,
)
pytestmark = pytest.mark.unit
# --- fixtures ---------------------------------------------------------------------
RECIPES: dict[str, dict[str, Any]] = {
"soup": {
"id": "soup",
"name": "Tomato Soup",
"base_score": 40,
"ingredients": [{"kind": "tomato", "form": "raw"}, {"kind": "onion", "form": "raw"}],
"steps": [
{"kind": "chop", "station": "chop_board", "seconds": 0.9},
{"kind": "cook", "station": "stove", "seconds": 5.0},
{"kind": "plate", "station": "prep", "seconds": 0.5},
{"kind": "serve", "station": "serve"},
],
},
"salad": {
"id": "salad",
"name": "Garden Salad",
"base_score": 30,
"ingredients": [{"kind": "lettuce", "form": "chopped"}],
"steps": [
{"kind": "plate", "station": "prep", "seconds": 0.4},
{"kind": "serve", "station": "serve"},
],
},
}
CLEAN_GRID = [
"###########",
"#S.O....C.#",
"#.........#",
"#..X...P..#",
"#.........#",
"#......V..#",
"###########",
]
CLEAN_TILES: dict[str, dict[str, str]] = {
".": {"kind": "floor"},
"#": {"kind": "wall"},
"S": {"kind": "floor", "station": "spawn_shelf", "item": "tomato"},
"O": {"kind": "floor", "station": "spawn_shelf", "item": "onion"},
"C": {"kind": "floor", "station": "chop_board"},
"X": {"kind": "floor", "station": "stove"},
"P": {"kind": "floor", "station": "prep"},
"V": {"kind": "floor", "station": "serve"},
}
#: The clean kitchen grown by two rows: a sealed 3-cell pocket at (7..9, 7).
POCKET_GRID = [
"###########",
"#S.O....C.#",
"#.........#",
"#..X...P..#",
"#.........#",
"#......V..#",
"#....######",
"#.....#..K#",
"###########",
]
#: Same pocket, empty floor inside — only the second spawn lives down there.
POCKET_FLOOR_GRID = [
"###########",
"#S.O....C.#",
"#.........#",
"#..X...P..#",
"#.........#",
"#......V..#",
"#....######",
"#.....#...#",
"###########",
]
#: The serve window itself sealed inside the pocket (nothing else serves).
SERVE_POCKET_GRID = [
"###########",
"#S.O....C.#",
"#.........#",
"#..X...P..#",
"#.........#",
"#....######",
"#.....#...#",
"#.....#..V#",
"###########",
]
NO_STOVE_GRID = [
"###########",
"#S.O....C.#",
"#.........#",
"#......P..#",
"#.........#",
"#......V..#",
"###########",
]
NO_ONION_GRID = [
"###########",
"#S......C.#",
"#.........#",
"#..X...P..#",
"#.........#",
"#......V..#",
"###########",
]
TINY_GRID = ["#####", "#SV.#", "#####"]
TINY_TILES: dict[str, dict[str, str]] = {
".": {"kind": "floor"},
"#": {"kind": "wall"},
"S": {"kind": "floor", "station": "spawn_shelf", "item": "tomato"},
"V": {"kind": "floor", "station": "serve"},
}
ORDERS: dict[str, Any] = {
"pool": ["soup"],
"concurrent": 1,
"spawn_interval": [16, 20],
"deadline": 60,
}
def level_data(**overrides: Any) -> dict[str, Any]:
data: dict[str, Any] = {
"id": "01_clean",
"name": "Clean Kitchen",
"order": 1,
"time_limit": 150,
"target_score": 45,
"difficulty": 1,
"grid": copy.deepcopy(CLEAN_GRID),
"tiles": copy.deepcopy(CLEAN_TILES),
"spawns": [[1, 1]],
"orders": copy.deepcopy(ORDERS),
}
data.update(overrides)
return data
def make_level(**overrides: Any) -> Level:
"""Build the clean fixture level with the given `LevelDef` overrides."""
return Level.build(LevelDef.model_validate(level_data(**overrides)))
def make_book(*recipe_ids: str) -> RecipeBook:
ids = recipe_ids or ("soup",)
return RecipeBook.model_validate(
{"recipes": {recipe_id: copy.deepcopy(RECIPES[recipe_id]) for recipe_id in ids}}
)
# --- the clean level is silent ------------------------------------------------------
def test_clean_level_reports_no_errors_or_warnings() -> None:
report = validate_level(make_level(), make_book())
assert report.ok
assert report.violations == ()
assert report.warnings == ()
assert report.render() == "content OK: no violations"
# --- failing cases, one per rule id ---------------------------------------------------
Case = Callable[[], tuple[Level, RecipeBook, Level | None]]
def _pocket_level() -> Level:
tiles = copy.deepcopy(CLEAN_TILES)
tiles["K"] = {"kind": "floor", "station": "sink"}
return make_level(grid=POCKET_GRID, tiles=tiles)
def _grid_shape_height() -> tuple[Level, RecipeBook, Level | None]:
level = make_level()
shrunk = Grid(
width=level.grid.width,
height=level.grid.height - 1,
tiles=level.grid.tiles[: level.grid.width * (level.grid.height - 1)],
)
return replace(level, grid=shrunk), make_book(), None
def _spawn_on_floor() -> tuple[Level, RecipeBook, Level | None]:
return make_level(spawns=[[0, 0]]), make_book(), None
def _spawn_count() -> tuple[Level, RecipeBook, Level | None]:
return replace(make_level(), spawns=()), make_book(), None
def _station_reachable() -> tuple[Level, RecipeBook, Level | None]:
return _pocket_level(), make_book(), None
def _serve_reachable() -> tuple[Level, RecipeBook, Level | None]:
return make_level(grid=SERVE_POCKET_GRID), make_book(), None
def _spawns_mutual() -> tuple[Level, RecipeBook, Level | None]:
level = make_level(grid=POCKET_FLOOR_GRID, spawns=[[1, 1], [9, 7], [5, 4]])
return level, make_book(), None
def _recipe_station_missing() -> tuple[Level, RecipeBook, Level | None]:
tiles = {c: spec for c, spec in copy.deepcopy(CLEAN_TILES).items() if c != "X"}
return make_level(grid=NO_STOVE_GRID, tiles=tiles), make_book(), None
def _recipe_ingredient_missing() -> tuple[Level, RecipeBook, Level | None]:
tiles = {c: spec for c, spec in copy.deepcopy(CLEAN_TILES).items() if c != "O"}
return make_level(grid=NO_ONION_GRID, tiles=tiles), make_book(), None
def _pool_unknown() -> tuple[Level, RecipeBook, Level | None]:
level = make_level(orders=dict(ORDERS, pool=["soup", "ghost"]))
return level, make_book(), None
def _target_too_high() -> tuple[Level, RecipeBook, Level | None]:
book = make_book()
cap = theoretical_max_score(make_level(), book)
assert cap is not None
return make_level(target_score=int(cap * 2)), book, None
def _time_limit() -> tuple[Level, RecipeBook, Level | None]:
return make_level(time_limit=10), make_book(), None
def _hazard_ref() -> tuple[Level, RecipeBook, Level | None]:
level = make_level(hazards=[{"type": "fire", "station": "stove_9"}])
return level, make_book(), None
def _hazard_tile() -> tuple[Level, RecipeBook, Level | None]:
level = make_level(hazards=[{"type": "spill", "x": 0, "y": 0}])
return level, make_book(), None
def _limit_zero() -> tuple[Level, RecipeBook, Level | None]:
return make_level(station_limits={"plates": 0, "pots": 1}), make_book(), None
def _difficulty_order() -> tuple[Level, RecipeBook, Level | None]:
return make_level(), make_book(), make_level()
def _travel_short() -> tuple[Level, RecipeBook, Level | None]:
level = make_level(difficulty=3, grid=TINY_GRID, tiles=TINY_TILES)
return level, make_book(), None
FAILING: dict[str, Case] = {
RuleId.GRID_SHAPE: _grid_shape_height,
RuleId.SPAWN_ON_FLOOR: _spawn_on_floor,
RuleId.SPAWN_COUNT: _spawn_count,
RuleId.STATION_REACHABLE: _station_reachable,
RuleId.SERVE_REACHABLE: _serve_reachable,
RuleId.SPAWNS_MUTUALLY_REACHABLE: _spawns_mutual,
RuleId.RECIPE_STATION_MISSING: _recipe_station_missing,
RuleId.RECIPE_INGREDIENT_UNAVAILABLE: _recipe_ingredient_missing,
RuleId.ORDER_POOL_UNKNOWN_RECIPE: _pool_unknown,
RuleId.TARGET_IMPLAUSIBLE: _target_too_high,
RuleId.TIME_LIMIT_INVALID: _time_limit,
RuleId.HAZARD_REF_UNKNOWN: _hazard_ref,
RuleId.HAZARD_ON_UNREACHABLE_TILE: _hazard_tile,
RuleId.STATION_LIMIT_ZERO: _limit_zero,
RuleId.DIFFICULTY_ORDER: _difficulty_order,
RuleId.TRAVEL_TOO_SHORT: _travel_short,
}
def test_failing_cases_cover_every_rule_id() -> None:
"""Every rule id has a failing case here; DUPLICATE_ID is a bundle rule."""
assert set(FAILING) | {RuleId.DUPLICATE_ID} == set(RuleId.ALL)
assert len(FAILING) + 1 == len(RuleId.ALL)
@pytest.mark.parametrize("rule", sorted(FAILING))
def test_rule_fires_with_a_human_message(rule: str) -> None:
level, book, prev = FAILING[rule]()
report = validate_level(level, book, prev)
assert not report.ok
fired = [violation for violation in report.violations if violation.rule == rule]
assert fired, f"{rule} did not fire:\n{report.render()}"
def test_planted_unreachable_station_reports_exactly_station_reachable() -> None:
report = validate_level(_pocket_level(), make_book())
assert len(report.violations) == 1, report.render()
violation = report.violations[0]
assert violation.rule == RuleId.STATION_REACHABLE
assert "(9,7)" in violation.message
assert dict(violation.detail) == {"x": 9, "y": 7, "station_id": "sink_0"}
assert report.errors_for("01_clean") == (violation,)
assert report.errors_for("nope") == ()
# --- passing counterparts beyond the clean level ---------------------------------------
def test_grid_shape_also_flags_a_width_disagreement() -> None:
level = make_level()
wider = Grid(
width=level.grid.width + 1,
height=level.grid.height,
tiles=level.grid.tiles + (TileKind.WALL,) * level.grid.height,
)
report = validate_level(replace(level, grid=wider), make_book())
fired = [v.rule for v in report.violations]
assert RuleId.GRID_SHAPE in fired
def test_spawn_on_the_serve_window_travels_zero_tiles() -> None:
assert escalation_axes(make_level(spawns=[[7, 5]]))["travel_distance"] == 0.0
def test_non_raw_ingredients_are_not_shelf_checked() -> None:
level = make_level(orders=dict(ORDERS, pool=["salad"]))
report = validate_level(level, make_book("salad"))
assert report.ok, report.render()
def test_tight_target_warns_without_failing() -> None:
cap = theoretical_max_score(make_level(), make_book())
assert cap is not None
tight = make_level(target_score=int(TARGET_WARN_FRACTION * cap) + 10)
report = validate_level(tight, make_book())
assert report.ok # warnings never fail
assert [warning.rule for warning in report.warnings] == [RuleId.TARGET_IMPLAUSIBLE]
def test_two_axes_moved_satisfies_difficulty_order() -> None:
prev = make_level()
nxt = make_level(
order=2,
hazards=[{"type": "spill", "x": 5, "y": 2}],
orders=dict(ORDERS, concurrent=2),
)
report = validate_level(nxt, make_book(), prev)
assert report.ok, report.render()
def test_high_difficulty_level_with_real_travel_is_clean() -> None:
report = validate_level(make_level(difficulty=3), make_book())
assert report.ok, report.render()
def test_hazard_path_point_off_the_floor_fires() -> None:
level = make_level(
hazards=[{"type": "moving_obstacle", "path": [[1, 1], [0, 0]], "period_s": 8.0}]
)
fired = [
violation.message
for violation in validate_level(level, make_book()).violations
if violation.rule == RuleId.HAZARD_ON_UNREACHABLE_TILE
]
assert len(fired) == 1
assert "path point 1" in fired[0]
def test_hazard_outside_the_grid_fires() -> None:
level = make_level(hazards=[{"type": "spill", "x": 99, "y": 99}])
fired = [
violation.message
for violation in validate_level(level, make_book()).violations
if violation.rule == RuleId.HAZARD_ON_UNREACHABLE_TILE
]
assert len(fired) == 1
assert "outside the 11x7 grid" in fired[0] # clean fixture is 11x7
# --- bundle-level rules -------------------------------------------------------------
def test_duplicate_id_is_a_bundle_violation() -> None:
first = make_level()
second = make_level(order=2) # forgets to rename: same id again
report = validate_levels([first, second], make_book())
dupes = [violation for violation in report.violations if violation.rule == RuleId.DUPLICATE_ID]
assert len(dupes) == 1
assert dupes[0].level_id == "01_clean"
assert dupes[0].detail["count"] == 2
def test_validate_bundle_accepts_a_clean_two_level_campaign() -> None:
first = make_level()
second = make_level(
id="02_later",
order=2,
difficulty=2,
hazards=[{"type": "spill", "x": 5, "y": 2}],
orders=dict(ORDERS, concurrent=2),
)
bundle = ContentBundle(recipe_book=make_book(), levels=(first, second))
report = validate_bundle(bundle)
assert report.ok and not report.warnings, report.render()
def test_validate_bundle_refuses_a_difficulty_drop() -> None:
first = make_level(difficulty=2)
second = make_level(id="02_later", order=2, difficulty=1)
bundle = ContentBundle(recipe_book=make_book(), levels=(first, second))
report = validate_bundle(bundle)
drops = [
violation
for violation in report.violations
if violation.rule == RuleId.DIFFICULTY_ORDER and "drops" in violation.message
]
assert len(drops) == 1
assert drops[0].level_id == "02_later"
# --- the bound itself ---------------------------------------------------------------
def test_theoretical_max_score_needs_known_recipes_and_valid_time() -> None:
book = make_book()
ghost = make_level(orders=dict(ORDERS, pool=["ghost"]))
assert theoretical_max_score(ghost, book) is None
assert theoretical_max_score(make_level(time_limit=10), book) is None
def test_theoretical_max_score_counts_orders_the_time_allows() -> None:
book = make_book()
level = make_level()
cap = theoretical_max_score(level, book)
assert cap is not None
# 150 s / (7.0 s soup + 2*10 tiles / 3.5 tiles/s travel) -> 11 orders x 40 pts.
assert cap == 11 * 40
def test_escalation_axes_are_named_and_higher_is_harder() -> None:
level = make_level()
axes = escalation_axes(level)
assert set(axes) == {
"recipe_complexity",
"order_pressure",
"time_budget",
"station_scarcity",
"layout_hostility",
"hazard_density",
"travel_distance",
}
assert axes["hazard_density"] == 0.0
assert axes["travel_distance"] == 10.0
harder = make_level(
hazards=[{"type": "spill", "x": 5, "y": 2}],
orders=dict(ORDERS, concurrent=2),
)
harder_axes = escalation_axes(harder)
assert harder_axes["hazard_density"] > axes["hazard_density"]
assert harder_axes["order_pressure"] > axes["order_pressure"]
+81
View File
@@ -0,0 +1,81 @@
"""`ValidationReport.render()` is deterministic, rule-naming, and bounded.
The CLI (task 05) prints this text verbatim and the content-gate criterion
greps it for rule names, so the render must be stable (same report, same
bytes — input order cannot leak in), mention every rule id it holds, stay
free of ANSI escapes, and not explode into a wall of text for a broken level.
"""
from __future__ import annotations
import pytest
from undercooked.sim.validation import LevelViolation, RuleId, ValidationReport
pytestmark = pytest.mark.unit
def _violation(rule: str, level_id: str, message: str) -> LevelViolation:
return LevelViolation(rule=rule, level_id=level_id, message=message)
def _ten_violation_report() -> ValidationReport:
errors = tuple(
_violation(rule, f"level_{index % 2}", f"synthetic {rule} message {index}")
for index, rule in enumerate(RuleId.ALL[:10])
)
warnings = (_violation(RuleId.TARGET_IMPLAUSIBLE, "level_0", "tight but fair"),)
return ValidationReport(violations=errors, warnings=warnings)
def test_render_is_deterministic_across_runs_and_input_order() -> None:
report = _ten_violation_report()
assert report.render() == report.render()
shuffled = ValidationReport(
violations=tuple(reversed(report.violations)),
warnings=report.warnings,
)
assert shuffled.render() == report.render()
def test_render_names_every_rule_level_and_severity_without_ansi() -> None:
rendered = _ten_violation_report().render()
for violation in _ten_violation_report().violations:
assert violation.rule in rendered
assert violation.message in rendered
assert "level_0" in rendered and "level_1" in rendered
assert "ERROR" in rendered and "WARN" in rendered
assert "\x1b" not in rendered
def test_render_stays_under_40_lines_for_a_10_violation_level() -> None:
assert len(_ten_violation_report().render().splitlines()) <= 40
def test_render_orders_errors_by_rule_within_a_level() -> None:
report = ValidationReport(
violations=(
_violation(RuleId.TRAVEL_TOO_SHORT, "l1", "b travels too little"),
_violation(RuleId.GRID_SHAPE, "l1", "a shape disagrees"),
)
)
error_lines = [line for line in report.render().splitlines() if "ERROR" in line]
assert len(error_lines) == 2
assert RuleId.GRID_SHAPE in error_lines[0]
assert RuleId.TRAVEL_TOO_SHORT in error_lines[1]
def test_clean_report_renders_one_ok_line() -> None:
report = ValidationReport()
assert report.ok
assert report.render() == "content OK: no violations"
def test_warnings_only_report_renders_ok_and_warn_lines() -> None:
report = ValidationReport(
warnings=(_violation(RuleId.TARGET_IMPLAUSIBLE, "l1", "tight but fair"),)
)
rendered = report.render()
assert report.ok # warnings never fail
assert "WARN" in rendered and "ERROR" not in rendered
assert "0 error(s), 1 warning(s)" in rendered