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
+440
View File
@@ -0,0 +1,440 @@
"""Unit tests for `undercooked.sim.level` (phase 02 task 02).
The level schema is what makes a YAML file *well-formed* — rectangular grid,
complete legend both directions, spawns on the map, exactly one serve, ordered
spawn intervals, placed hazards — and `build_grid`/`Level.build` are the
deterministic geometry those rules guarantee. Each validator branch gets a
failing document; the happy path asserts the documented defaults and station
id conventions from `docs/LEVELS.md`.
"""
from __future__ import annotations
from typing import Any
import pytest
from pydantic import ValidationError
from undercooked.sim.grid import MAX_GRID, Grid
from undercooked.sim.level import (
HazardSpec,
Level,
LevelDef,
OrdersSpec,
StationLimits,
TileSpec,
build_grid,
build_station_items,
build_stations,
)
from undercooked.sim.model import HazardKind, ItemKind, StationKind, TileKind
pytestmark = pytest.mark.unit
TILES: dict[str, Any] = {
".": {"kind": "floor"},
"#": {"kind": "wall"},
"S": {"kind": "floor", "station": "spawn_shelf", "item": "tomato"},
"C": {"kind": "floor", "station": "chop_board"},
"V": {"kind": "floor", "station": "serve"},
}
GRID = ["#####", "#S..#", "#.C.#", "#..V#", "#####"]
def level_dict(**overrides: Any) -> dict[str, Any]:
"""A minimal valid level body; overrides replace top-level fields."""
body: dict[str, Any] = {
"id": "01_first_kitchen",
"name": "First Kitchen",
"order": 1,
"time_limit": 150,
"target_score": 45,
"grid": list(GRID),
"tiles": {key: dict(value) for key, value in TILES.items()},
"spawns": [[1, 1]],
"orders": {
"pool": ["soup"],
"concurrent": 1,
"spawn_interval": [16, 20],
"deadline": 60,
},
}
body.update(overrides)
return body
def make_level(**overrides: Any) -> LevelDef:
return LevelDef.model_validate(level_dict(**overrides))
# ------------------------------------------------------------------------- TileSpec
def test_plain_tiles_default_to_no_station_no_item() -> None:
spec = TileSpec.model_validate({"kind": "floor"})
assert spec.kind is TileKind.FLOOR
assert spec.station is None
assert spec.item is None
assert spec.id_prefix is None
def test_spawn_shelf_must_name_the_item_it_spawns() -> None:
with pytest.raises(ValidationError, match="must name the item"):
TileSpec.model_validate({"kind": "floor", "station": "spawn_shelf"})
def test_only_a_spawn_shelf_may_carry_an_item() -> None:
with pytest.raises(ValidationError, match="only a spawn_shelf"):
TileSpec.model_validate({"kind": "floor", "station": "chop_board", "item": "tomato"})
def test_item_without_any_station_is_meaningless() -> None:
with pytest.raises(ValidationError, match="only a spawn_shelf"):
TileSpec.model_validate({"kind": "floor", "item": "tomato"})
def test_id_prefix_belongs_to_a_station_tile() -> None:
with pytest.raises(ValidationError, match="id_prefix belongs to a station"):
TileSpec.model_validate({"kind": "wall", "id_prefix": "north"})
def test_id_prefix_must_not_be_empty() -> None:
with pytest.raises(ValidationError):
TileSpec.model_validate({"kind": "floor", "station": "stove", "id_prefix": ""})
def test_tilespec_rejects_unknown_keys_and_values() -> None:
with pytest.raises(ValidationError):
TileSpec.model_validate({"kind": "floor", "glow": True})
with pytest.raises(ValidationError):
TileSpec.model_validate({"kind": "swamp"})
# ------------------------------------------------------------------------ OrdersSpec
def test_orders_defaults() -> None:
orders = OrdersSpec.model_validate(
{"pool": ["soup"], "concurrent": 1, "spawn_interval": [7, 11], "deadline": 42}
)
assert orders.penalty == 10
assert orders.spawn_interval == (7.0, 11.0)
def test_spawn_interval_min_must_not_exceed_max() -> None:
with pytest.raises(ValidationError, match="greater than its end"):
OrdersSpec.model_validate(
{"pool": ["soup"], "concurrent": 1, "spawn_interval": [12, 8], "deadline": 42}
)
def test_spawn_interval_must_start_above_zero() -> None:
with pytest.raises(ValidationError, match="above 0"):
OrdersSpec.model_validate(
{"pool": ["soup"], "concurrent": 1, "spawn_interval": [0, 8], "deadline": 42}
)
def test_orders_pool_needs_recipes_and_sane_numbers() -> None:
with pytest.raises(ValidationError, match="pool"):
OrdersSpec.model_validate(
{"pool": [], "concurrent": 1, "spawn_interval": [7, 11], "deadline": 42}
)
with pytest.raises(ValidationError, match="concurrent"):
OrdersSpec.model_validate(
{"pool": ["soup"], "concurrent": 0, "spawn_interval": [7, 11], "deadline": 42}
)
with pytest.raises(ValidationError, match="deadline"):
OrdersSpec.model_validate(
{"pool": ["soup"], "concurrent": 1, "spawn_interval": [7, 11], "deadline": 0}
)
with pytest.raises(ValidationError, match="penalty"):
OrdersSpec.model_validate(
{"pool": ["soup"], "concurrent": 1, "spawn_interval": [7, 11], "deadline": 42,
"penalty": -1}
)
# ------------------------------------------------------------------------ HazardSpec
def hazard_dict(**overrides: Any) -> dict[str, Any]:
body: dict[str, Any] = {"type": "spill", "x": 3, "y": 4}
body.update(overrides)
return body
def test_hazard_defaults() -> None:
hazard = HazardSpec.model_validate(hazard_dict())
assert hazard.type is HazardKind.SPILL
assert hazard.chance_per_s is None
assert hazard.respawn_s is None
assert hazard.path is None
assert hazard.intensity == 1.0
def test_fire_may_attach_to_a_station() -> None:
hazard = HazardSpec.model_validate({"type": "fire", "station": "stove_2"})
assert hazard.station == "stove_2"
def test_fire_needs_a_station_or_coordinates() -> None:
with pytest.raises(ValidationError, match="fire hazard needs a station or x/y"):
HazardSpec.model_validate({"type": "fire"})
def test_spill_needs_a_location_too() -> None:
with pytest.raises(ValidationError, match="spill hazard needs a station or x/y"):
HazardSpec.model_validate({"type": "spill"})
def test_station_and_coordinates_are_exclusive() -> None:
with pytest.raises(ValidationError, match="not both"):
HazardSpec.model_validate({"type": "fire", "station": "stove_0", "x": 2, "y": 3})
def test_coordinates_come_as_a_pair() -> None:
with pytest.raises(ValidationError, match="given together"):
HazardSpec.model_validate({"type": "spill", "x": 2})
with pytest.raises(ValidationError, match="given together"):
HazardSpec.model_validate({"type": "spill", "y": 3})
def test_moving_obstacle_needs_a_route_and_a_period() -> None:
with pytest.raises(ValidationError, match="path of at least 2 points"):
HazardSpec.model_validate({"type": "moving_obstacle", "period_s": 9})
with pytest.raises(ValidationError, match="path of at least 2 points"):
HazardSpec.model_validate(
{"type": "moving_obstacle", "path": [[4, 3]], "period_s": 9}
)
with pytest.raises(ValidationError, match="needs period_s"):
HazardSpec.model_validate({"type": "moving_obstacle", "path": [[4, 3], [10, 3]]})
def test_moving_obstacle_happy_path_needs_no_placement() -> None:
hazard = HazardSpec.model_validate(
{"type": "moving_obstacle", "path": [[4, 3], [10, 3]], "period_s": 9}
)
assert hazard.path == [(4, 3), (10, 3)]
assert hazard.period_s == 9.0
def test_rush_is_global_but_needs_positive_intensity() -> None:
assert HazardSpec.model_validate({"type": "rush"}).intensity == 1.0
with pytest.raises(ValidationError, match="positive intensity"):
HazardSpec.model_validate({"type": "rush", "intensity": 0})
def test_hazard_numeric_bounds() -> None:
with pytest.raises(ValidationError, match="chance_per_s"):
HazardSpec.model_validate(hazard_dict(chance_per_s=-0.01))
with pytest.raises(ValidationError, match="respawn_s"):
HazardSpec.model_validate(hazard_dict(respawn_s=0))
# -------------------------------------------------------------------------- LevelDef
def test_level_defaults_match_the_documented_values() -> None:
level = make_level()
assert level.hazards == []
assert level.station_limits.plates == 99
assert level.station_limits.pots == 99
assert level.difficulty == 1
assert level.blurb == ""
assert level.escalation_notes == ""
def test_ragged_grid_is_refused() -> None:
with pytest.raises(ValidationError, match="ragged grid: row 4 is 4 chars, want 5"):
make_level(grid=["#####", "#S..#", "#.C.#", "#..V#", "####"])
def test_empty_rows_are_refused() -> None:
with pytest.raises(ValidationError, match="rows must not be empty"):
make_level(grid=["", ""], tiles={".": {"kind": "floor"}, "V": {"kind": "floor",
"station": "serve"}}, spawns=[])
def test_grid_larger_than_the_engine_supports_is_refused() -> None:
wide = "#" * (MAX_GRID + 1)
with pytest.raises(ValidationError, match=f"at most {MAX_GRID}x{MAX_GRID}"):
make_level(grid=[wide, wide])
def test_grid_character_without_a_legend_entry_is_refused() -> None:
with pytest.raises(ValidationError, match="grid character 'Z' has no tiles"):
make_level(grid=["#####", "#Z..#", "#.C.#", "#..V#", "#####"])
def test_unused_legend_character_is_refused() -> None:
with pytest.raises(ValidationError, match="legend character 'X' is never used"):
make_level(tiles={**TILES, "X": {"kind": "wall"}})
def test_level_needs_at_least_one_spawn() -> None:
with pytest.raises(ValidationError, match="at least one spawn"):
make_level(spawns=[])
def test_spawns_must_be_inside_the_grid() -> None:
with pytest.raises(ValidationError, match=r"spawn \(9,9\) is outside the 5x5 grid"):
make_level(spawns=[[9, 9]])
with pytest.raises(ValidationError, match=r"spawn \(-1,2\) is outside"):
make_level(spawns=[[-1, 2]])
def test_level_needs_exactly_one_serve_legend() -> None:
no_serve = {key: dict(value) for key, value in TILES.items() if key != "V"}
with pytest.raises(ValidationError, match="found 0"):
make_level(grid=["#####", "#S..#", "#.C.#", "#...#", "#####"], tiles=no_serve)
with pytest.raises(ValidationError, match="found 2"):
make_level(
grid=["#####", "#S..#", "#.C.#", "#V.U#", "#####"],
tiles={**TILES, "U": {"kind": "floor", "station": "serve"}},
)
def test_level_numbers_are_validated() -> None:
with pytest.raises(ValidationError, match="difficulty"):
make_level(difficulty=11)
with pytest.raises(ValidationError, match="difficulty"):
make_level(difficulty=0)
with pytest.raises(ValidationError, match="order"):
make_level(order=0)
with pytest.raises(ValidationError, match="time_limit"):
make_level(time_limit=0)
with pytest.raises(ValidationError, match="target_score"):
make_level(target_score=0)
def test_level_rejects_unknown_keys() -> None:
with pytest.raises(ValidationError):
make_level(weather="rain")
def test_level_requires_a_grid_and_a_legend() -> None:
with pytest.raises(ValidationError, match="grid"):
make_level(grid=[])
with pytest.raises(ValidationError, match="tiles"):
make_level(tiles={})
def test_hazards_and_station_limits_round_trip_on_the_level() -> None:
level = make_level(
hazards=[
{"type": "fire", "station": "stove_2", "chance_per_s": 0.02},
{"type": "moving_obstacle", "path": [[4, 3], [10, 3]], "period_s": 9},
],
station_limits={"plates": 3, "pots": 2},
difficulty=7,
blurb="A kitchen.",
escalation_notes="tighter",
)
assert [hazard.type for hazard in level.hazards] == [
HazardKind.FIRE,
HazardKind.MOVING_OBSTACLE,
]
assert level.station_limits == StationLimits(plates=3, pots=2)
assert level.difficulty == 7
# ------------------------------------------------------------------- building geometry
def test_build_grid_maps_the_legend_and_marks_station_cells() -> None:
level = make_level()
grid = build_grid(level)
assert isinstance(grid, Grid)
assert (grid.width, grid.height) == (5, 5)
assert grid.tile_at(0, 0) is TileKind.WALL
assert grid.tile_at(1, 1) is TileKind.FLOOR
assert dict(grid.station_at) == {6: "spawn_shelf_0", 12: "chop_board_0", 18: "serve_0"}
def test_station_ids_count_per_kind_in_reading_order() -> None:
stations = build_stations(make_level())
assert [(station.id, station.kind) for station in stations] == [
("spawn_shelf_0", StationKind.SPAWN_SHELF),
("chop_board_0", StationKind.CHOP_BOARD),
("serve_0", StationKind.SERVE),
]
def test_two_legend_chars_of_one_station_kind_share_one_counter() -> None:
level = make_level(
grid=["######", "#T...#", "#..t.#", "#...V#", "######"],
tiles={
".": {"kind": "floor"},
"#": {"kind": "wall"},
"T": {"kind": "floor", "station": "stove"},
"t": {"kind": "floor", "station": "stove", "id_prefix": "burner"},
"V": {"kind": "floor", "station": "serve"},
},
)
stations = build_stations(level)
assert [station.id for station in stations] == ["stove_0", "burner_1", "serve_0"]
def test_station_positions_are_float_tile_coordinates() -> None:
chop = next(station for station in build_stations(make_level()) if station.kind
is StationKind.CHOP_BOARD)
assert (chop.x, chop.y) == (2.0, 2.0)
def test_a_station_on_a_non_floor_tile_still_builds_and_is_task_03_problem() -> None:
level = make_level(
grid=["#####", "#S..#", "#.C.#", "#.=V#", "#####"],
tiles={**TILES, "=": {"kind": "counter", "station": "stove"}},
)
grid = build_grid(level)
assert grid.tile_at(2, 3) is TileKind.COUNTER
assert grid.station_at[17] == "stove_0"
def test_station_items_cover_only_spawn_shelves() -> None:
level = make_level(
grid=["######", "#Ss..#", "#.C..#", "#...V#", "######"],
tiles={
".": {"kind": "floor"},
"#": {"kind": "wall"},
"S": {"kind": "floor", "station": "spawn_shelf", "item": "tomato"},
"s": {"kind": "floor", "station": "spawn_shelf", "item": "onion"},
"C": {"kind": "floor", "station": "chop_board"},
"V": {"kind": "floor", "station": "serve"},
},
spawns=[[1, 1], [2, 1]],
)
assert build_station_items(level) == {
"spawn_shelf_0": ItemKind.TOMATO,
"spawn_shelf_1": ItemKind.ONION,
}
def test_level_build_pairs_the_spec_with_grid_stations_and_spawns() -> None:
spec = make_level()
level = Level.build(spec)
assert level.spec is spec
assert level.grid == build_grid(spec)
assert level.stations == build_stations(spec)
assert level.spawns == ((1, 1),)
assert level.station_items == {"spawn_shelf_0": ItemKind.TOMATO}
def test_level_delegates_the_authoring_fields_to_its_spec() -> None:
level = Level.build(
make_level(difficulty=3, blurb="Cook!", escalation_notes="harder",
station_limits={"plates": 4})
)
assert level.id == "01_first_kitchen"
assert level.name == "First Kitchen"
assert level.order == 1
assert level.difficulty == 3
assert level.time_limit == 150
assert level.target_score == 45
assert level.orders.pool == ["soup"]
assert level.hazards == []
assert level.station_limits.plates == 4
assert level.blurb == "Cook!"
assert level.escalation_notes == "harder"