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,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"
|
||||
Reference in New Issue
Block a user