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
+6 -3
View File
@@ -42,10 +42,13 @@ def _as_env_value(value: object) -> str:
@pytest.fixture(scope="session", autouse=True)
def isolated_levels_dir() -> Iterator[None]:
"""Point `UNDERCOOKED_LEVELS_DIR` at an empty fixture dir for every test.
"""Point `UNDERCOOKED_LEVELS_DIR` at this level-free fixture dir for every test.
No test may read the real `levels/` content implicitly; the shipped
content pipeline only arrives with phase 02.
No test may read the real `levels/` content implicitly. Since phase 02
task 04 `create_app()` also needs a recipe book, and the dir carries a
committed minimal `recipes.yaml` (zero levels — a legal campaign) so
fixture-isolated apps still boot; tests that need real levels build one
under `tmp_path` or point explicitly at `levels/`.
"""
FIXTURE_LEVELS_DIR.mkdir(parents=True, exist_ok=True)
previous = os.environ.get("UNDERCOOKED_LEVELS_DIR")
+46
View File
@@ -0,0 +1,46 @@
# A deliberately broken level for `undercooked-levels validate` (phase 02 task 05).
#
# It *loads* (the schema is satisfied) and then breaks three PLAN §8 rules, so
# the CLI's exit-1 path, its named-rule report and the `show --grid` "?" overlay
# all have a stable input to be tested against:
#
# * the right-hand box (x6..x9, y1..y2) is sealed on all four sides, so the
# serve window inside it is cut off from the spawn -> SERVE_REACHABLE, and
# those same cells show up as `?` in `show --grid`;
# * the recipe's steps name a stove this kitchen does not have
# -> RECIPE_STATION_MISSING;
# * target_score 999 is far past 60 % of the theoretical maximum
# -> TARGET_IMPLAUSIBLE.
#
# The `_bad` directory name opts out of content discovery (`_is_draft`), so this
# campaign is only loaded when something points `--dir` straight at it — the
# server and the rest of the test suite never see it.
id: 99_broken_room
name: Broken Room
order: 1
difficulty: 5
time_limit: 60
target_score: 999
blurb: A kitchen with a sealed-off servery, the fixture for the validator itself.
grid:
- "###########"
- "#T..C#..V.#"
- "#....#....#"
- "#.#########"
- "#S........#"
- "###########"
tiles:
"#": {kind: wall}
".": {kind: floor}
"T": {kind: floor, station: spawn_shelf, item: tomato}
"C": {kind: floor, station: chop_board}
"V": {kind: floor, station: serve}
"S": {kind: floor, station: spawn_shelf, item: onion}
spawns: [[1, 4]]
orders:
pool: [soup]
concurrent: 1
spawn_interval: [12, 16]
deadline: 60
hazards: []
escalation_notes: Fixture only, never shipped and never part of a real campaign.
+23
View File
@@ -0,0 +1,23 @@
# The recipe book for the `_bad` fixture campaign (phase 02 task 05).
#
# `undercooked-levels validate --dir tests/fixtures/levels/_bad` needs a book of
# its own so the fixture stays self-contained: the tests assert on the rule hits
# of `99_broken_room.yaml`, and those numbers depend on this book, not on the
# shipped `levels/recipes.yaml`. One dish, `soup`, whose steps name a
# `chop_board`, a `stove` and a `prep` station — the fixture kitchen omits the
# stove on purpose.
ingredients: [tomato, onion]
recipes:
soup:
id: soup
name: Tomato Soup
base_score: 40
ingredients:
- {kind: tomato, form: raw}
- {kind: onion, form: raw}
steps:
- {kind: pick, station: spawn_shelf}
- {kind: chop, station: chop_board, seconds: 0.9}
- {kind: cook, station: stove, seconds: 5.0}
- {kind: plate, station: prep, seconds: 0.4}
+18
View File
@@ -0,0 +1,18 @@
# tests/fixtures/levels/recipes.yaml — the minimal book for the test-isolated
# levels directory (see tests/conftest.py `isolated_levels_dir`).
#
# A campaign with zero levels is legal (docs/LEVELS.md), but every
# `create_app()` still needs a recipe book to load. Tests that want actual
# levels build their own under `tmp_path`; the shipped campaign lives in
# `levels/` and is covered by tests/unit/test_shipped_content.py.
ingredients: [tomato]
recipes:
tomato_soup:
id: tomato_soup
name: Test Soup
base_score: 10
ingredients:
- {kind: tomato, form: raw}
steps:
- {kind: pick, station: spawn_shelf}
- {kind: serve, station: serve}
+234
View File
@@ -0,0 +1,234 @@
"""Content API contract over in-process HTTP (task 04).
`/api/levels`, `/api/levels/{id}` and `/api/config` are the only channels the
client uses for content and settings. These tests build the real app against
the shipped ``levels/`` campaign and assert the exact wire shape: shipped
ids in campaign order, responses that validate against the strict response
models (which are ``extra="forbid"``, so a leaked field is a failure, not a
silence), 404 for unknown ids, and the four-key client-config subset with no
secret-, operator- or origin-named key. A content bug must crash startup
(see ``tests/unit/test_content_startup.py``), never a request.
"""
from __future__ import annotations
from collections.abc import Iterator
from pathlib import Path
from typing import Any
import pytest
from fastapi.testclient import TestClient
from undercooked.config import Settings
from undercooked.server.app import create_app
from undercooked.server.routes_content import (
PROTOCOL_VERSION,
ClientConfig,
LevelDetail,
LevelSummary,
)
REPO_ROOT = Path(__file__).resolve().parents[2]
SHIPPED_LEVELS = REPO_ROOT / "levels"
SHIPPED_IDS = ["01_first_kitchen", "02_double_shift"]
#: Keys that must never appear anywhere in a content response. Layout and
#: internals stay server-side (PLAN §8); operator settings stay in config.py.
FORBIDDEN_KEYS = {
"grid",
"tiles",
"legend",
"spawns",
"spawn",
"stations",
"station",
"station_limits",
"orders",
"pool",
"steps",
"secret",
"password",
"origins",
}
@pytest.fixture(scope="module")
def campaign_client() -> Iterator[TestClient]:
with TestClient(create_app(Settings(levels_dir=SHIPPED_LEVELS))) as test_client:
yield test_client
def walk_keys(node: Any) -> set[str]:
"""Every JSON object key at any depth of a parsed response body."""
if isinstance(node, dict):
return set(node) | {k for child in node.values() for k in walk_keys(child)}
if isinstance(node, list):
return {k for child in node for k in walk_keys(child)}
return set()
# --- GET /api/levels -----------------------------------------------------------
def test_lists_the_shipped_levels_in_campaign_order(campaign_client: TestClient) -> None:
response = campaign_client.get("/api/levels")
assert response.status_code == 200
body = response.json()
assert [level["id"] for level in body] == SHIPPED_IDS
assert [level["order"] for level in body] == [1, 2]
def test_level_cards_match_the_response_model_exactly(
campaign_client: TestClient,
) -> None:
body = campaign_client.get("/api/levels").json()
for card in body:
# extra="forbid" on LevelSummary: an unexpected key fails this, and
# the explicit key-set check catches a *missing* field too.
LevelSummary.model_validate(card)
assert set(card) == set(LevelSummary.model_fields)
@pytest.mark.parametrize("level_id", SHIPPED_IDS)
def test_level_card_exposes_the_expected_fields(
campaign_client: TestClient, level_id: str
) -> None:
card = next(
level for level in campaign_client.get("/api/levels").json()
if level["id"] == level_id
)
assert isinstance(card["name"], str) and card["name"]
assert isinstance(card["blurb"], str) and len(card["blurb"]) >= 20
assert isinstance(card["difficulty"], int) and 1 <= card["difficulty"] <= 10
assert isinstance(card["target_score"], int)
assert isinstance(card["time_limit"], (int, float))
# Recipes exist — the ids the client may later send back in `join.ready`.
assert card["recipes"]
known_recipe_names = {"Tomato Soup", "Garden Salad", "Fried Rice", "Chef's Burger"}
assert set(card["recipes"]) <= known_recipe_names
# --- GET /api/levels/{id} -------------------------------------------------------
@pytest.mark.parametrize("level_id", SHIPPED_IDS)
def test_level_detail_matches_the_response_model(
campaign_client: TestClient, level_id: str
) -> None:
response = campaign_client.get(f"/api/levels/{level_id}")
assert response.status_code == 200
detail = response.json()
LevelDetail.model_validate(detail)
assert set(detail) == set(LevelSummary.model_fields) | {"hazards"}
assert detail["id"] == level_id
def test_level_detail_hazards_are_type_only(campaign_client: TestClient) -> None:
detail = campaign_client.get("/api/levels/02_double_shift").json()
# Neither shipped level carries a hazard; when one does, each entry is
# exactly {"type": ...} — kind only, never a position (US-07).
assert detail["hazards"] == []
def test_unknown_level_is_404(campaign_client: TestClient) -> None:
response = campaign_client.get("/api/levels/99_ghost_kitchen")
assert response.status_code == 404
def test_path_style_level_ids_cannot_reach_the_filesystem(
campaign_client: TestClient,
) -> None:
# Raw ".." would be normalised away by the HTTP client before it leaves;
# the encoded forms are what a hostile browser can actually send.
for hostile in ["..%2F..%2Fetc%2Fpasswd", "%2e%2e/levels", "%2e%2e"]:
response = campaign_client.get(f"/api/levels/{hostile}")
assert response.status_code == 404, hostile
# --- no internals leak ----------------------------------------------------------
@pytest.mark.parametrize(
"path",
[
"/api/levels",
"/api/levels/01_first_kitchen",
"/api/levels/02_double_shift",
"/api/config",
],
)
def test_no_internal_or_secret_keys_any_in_response(
campaign_client: TestClient, path: str
) -> None:
response = campaign_client.get(path)
assert response.status_code == 200
leaked = walk_keys(response.json()) & FORBIDDEN_KEYS
assert not leaked, f"{path} leaked keys: {sorted(leaked)}"
def test_no_grid_rows_leak_into_any_content_response(
campaign_client: TestClient,
) -> None:
for path in [
"/api/levels",
"/api/levels/01_first_kitchen",
"/api/levels/02_double_shift",
"/api/config",
]:
body = campaign_client.get(path).text
assert "####" not in body, f"{path} appears to carry a raw grid row"
# --- GET /api/config --------------------------------------------------------------
def test_config_exposes_exactly_the_client_safe_fields(
campaign_client: TestClient,
) -> None:
response = campaign_client.get("/api/config")
assert response.status_code == 200
body = response.json()
ClientConfig.model_validate(body)
assert set(body) == {"max_players", "tick_hz", "snapshot_hz", "protocol_version"}
assert body == {
"max_players": 4,
"tick_hz": 20,
"snapshot_hz": 20,
"protocol_version": PROTOCOL_VERSION,
}
def test_config_has_no_secret_operator_or_origin_named_key(
campaign_client: TestClient,
) -> None:
body = campaign_client.get("/api/config").json()
for key in body:
lowered = key.lower()
assert not any(
marker in lowered
for marker in ("secret", "password", "origins", "token", "key")
), key
def test_config_reflects_the_settings_built_into_this_app() -> None:
settings = Settings(levels_dir=SHIPPED_LEVELS, max_players=2, tick_hz=20, snapshot_hz=10)
with TestClient(create_app(settings)) as test_client:
body = test_client.get("/api/config").json()
assert body == {
"max_players": 2,
"tick_hz": 20,
"snapshot_hz": 10,
"protocol_version": PROTOCOL_VERSION,
}
# --- the test-isolated campaign ----------------------------------------------------
def test_the_env_isolated_app_starts_with_zero_levels(client: TestClient) -> None:
# The session's isolated levels dir holds only a recipe book (A8 allows
# a zero-level campaign); the app must still boot and answer cleanly.
response = client.get("/api/levels")
assert response.status_code == 200
assert response.json() == []
assert client.get("/api/levels/01_first_kitchen").status_code == 404
+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
+281
View File
@@ -0,0 +1,281 @@
"""Unit tests for `undercooked.sim.content` (phase 02 task 02).
The loader is the "no hard-coded content lists" rule (A8) in executable form:
a directory of YAML in, a campaign-ordered `ContentBundle` out. These tests
pin discovery (recursion, `_`-drafts, the recipe book's own name), the
`(order, id)` sort, and the promise that *every* file-level problem — bad
YAML, schema violations, unknown order-pool recipes, duplicate ids — is
reported in one `ContentError`, not one per run.
"""
from __future__ import annotations
from pathlib import Path
from typing import Any
import pytest
import yaml
from undercooked.sim.content import (
LEVEL_FILE_SUFFIXES,
ContentBundle,
ContentError,
load_content,
load_level_file,
)
from undercooked.sim.grid import GridError
from undercooked.sim.level import Level, LevelDef
from undercooked.sim.model import ItemKind
from undercooked.sim.recipe import RecipeBook
pytestmark = pytest.mark.unit
BOOK_YAML = """
ingredients: [tomato, onion]
recipes:
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}
"""
def level_body(level_id: str, order: int, **overrides: Any) -> dict[str, Any]:
body: dict[str, Any] = {
"id": level_id,
"name": level_id.replace("_", " ").title(),
"order": order,
"time_limit": 150,
"target_score": 45,
"grid": ["#####", "#S..#", "#.C.#", "#..V#", "#####"],
"tiles": {
".": {"kind": "floor"},
"#": {"kind": "wall"},
"S": {"kind": "floor", "station": "spawn_shelf", "item": "tomato"},
"C": {"kind": "floor", "station": "chop_board"},
"V": {"kind": "floor", "station": "serve"},
},
"spawns": [[1, 1]],
"orders": {
"pool": ["soup"],
"concurrent": 1,
"spawn_interval": [16, 20],
"deadline": 60,
},
}
body.update(overrides)
return body
def write_yaml(directory: Path, name: str, body: dict[str, Any]) -> Path:
path = directory / name
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(yaml.safe_dump(body, sort_keys=False), encoding="utf-8")
return path
def make_content_dir(tmp_path: Path, *levels: tuple[str, int]) -> tuple[Path, Path]:
"""A content dir with a recipe book and the named (filename, order) levels."""
levels_dir = tmp_path / "levels"
levels_dir.mkdir()
recipes_path = levels_dir / "recipes.yaml"
recipes_path.write_text(BOOK_YAML, encoding="utf-8")
for name, order in levels:
write_yaml(levels_dir, name, level_body(Path(name).stem, order))
return levels_dir, recipes_path
# ---------------------------------------------------------------------------- happy path
def test_loads_the_book_and_every_level_file(tmp_path: Path) -> None:
levels_dir, recipes_path = make_content_dir(tmp_path, ("01_a.yaml", 1), ("02_b.yaml", 2))
bundle = load_content(levels_dir, recipes_path)
assert bundle.recipe_book.ids() == ("soup",)
assert [level.id for level in bundle.levels] == ["01_a", "02_b"]
assert set(bundle.by_id) == {"01_a", "02_b"}
assert bundle.by_id["02_b"].order == 2
def test_levels_order_by_order_then_id_not_by_filename(tmp_path: Path) -> None:
levels_dir, recipes_path = make_content_dir(
tmp_path, ("zzz_first.yaml", 1), ("01_second.yaml", 2), ("aaa_tie_b.yaml", 1)
)
write_yaml(levels_dir, "aab_tie.yaml", level_body("aab_tie", 1))
bundle = load_content(levels_dir, recipes_path)
assert [level.id for level in bundle.levels] == [
"aaa_tie_b",
"aab_tie",
"zzz_first",
"01_second",
]
def test_an_empty_levels_dir_is_a_legal_empty_campaign(tmp_path: Path) -> None:
levels_dir, recipes_path = make_content_dir(tmp_path)
bundle = load_content(levels_dir, recipes_path)
assert bundle.levels == ()
assert dict(bundle.by_id) == {}
def test_paths_may_be_plain_strings(tmp_path: Path) -> None:
levels_dir, recipes_path = make_content_dir(tmp_path, ("01_a.yaml", 1))
bundle = load_content(str(levels_dir), str(recipes_path))
assert [level.id for level in bundle.levels] == ["01_a"]
# --------------------------------------------------------------------------- discovery
def test_discovery_recurses_into_subdirectories(tmp_path: Path) -> None:
levels_dir, recipes_path = make_content_dir(tmp_path, ("01_a.yaml", 1))
write_yaml(levels_dir / "campaign" / "deep", "02_b.yaml", level_body("02_b", 2))
bundle = load_content(levels_dir, recipes_path)
assert [level.id for level in bundle.levels] == ["01_a", "02_b"]
def test_underscore_prefixed_files_are_ignored_even_when_broken(tmp_path: Path) -> None:
levels_dir, recipes_path = make_content_dir(tmp_path, ("01_a.yaml", 1))
(levels_dir / "_draft.yaml").write_text("grid: [unclosed\n", encoding="utf-8")
(levels_dir / "_wip").mkdir()
write_yaml(levels_dir / "_wip", "scratch.yaml", {"id": "nothing"})
bundle = load_content(levels_dir, recipes_path)
assert [level.id for level in bundle.levels] == ["01_a"]
def test_recipes_yaml_in_the_levels_dir_is_not_a_level(tmp_path: Path) -> None:
levels_dir, recipes_path = make_content_dir(tmp_path)
# recipes.yaml itself would not validate as a level; loading must not even try.
bundle = load_content(levels_dir, recipes_path)
assert bundle.levels == ()
def test_non_yaml_files_are_ignored(tmp_path: Path) -> None:
levels_dir, recipes_path = make_content_dir(tmp_path, ("01_a.yaml", 1))
(levels_dir / "notes.md").write_text("# ideas\n- more soup\n", encoding="utf-8")
(levels_dir / "level.json").write_text("{not yaml at all", encoding="utf-8")
bundle = load_content(levels_dir, recipes_path)
assert [level.id for level in bundle.levels] == ["01_a"]
def test_suffix_set_covers_yaml_and_yml(tmp_path: Path) -> None:
assert set(LEVEL_FILE_SUFFIXES) == {".yaml", ".yml"}
levels_dir, recipes_path = make_content_dir(tmp_path)
(levels_dir / "02_b.yml").write_text(
yaml.safe_dump(level_body("02_b", 2), sort_keys=False), encoding="utf-8"
)
bundle = load_content(levels_dir, recipes_path)
assert [level.id for level in bundle.levels] == ["02_b"]
# ---------------------------------------------------------------- error aggregation
def test_unknown_order_pool_recipe_is_a_load_error(tmp_path: Path) -> None:
levels_dir, recipes_path = make_content_dir(tmp_path)
write_yaml(levels_dir, "01_a.yaml", level_body("01_a", 1, orders={
"pool": ["soup", "ghost"],
"concurrent": 1,
"spawn_interval": [16, 20],
"deadline": 60,
}))
with pytest.raises(ContentError, match="unknown recipes: ghost"):
load_content(levels_dir, recipes_path)
def test_duplicate_level_ids_are_refused(tmp_path: Path) -> None:
levels_dir, recipes_path = make_content_dir(tmp_path, ("01_a.yaml", 1))
write_yaml(levels_dir, "90_copy.yaml", level_body("01_a", 5))
with pytest.raises(ContentError, match="duplicate level ids: 01_a"):
load_content(levels_dir, recipes_path)
def test_missing_levels_directory_is_reported(tmp_path: Path) -> None:
_, recipes_path = make_content_dir(tmp_path)
with pytest.raises(ContentError, match="does not exist"):
load_content(tmp_path / "nowhere", recipes_path)
def test_missing_recipe_book_is_reported(tmp_path: Path) -> None:
levels_dir, _ = make_content_dir(tmp_path)
with pytest.raises(ContentError, match="cannot read content file"):
load_content(levels_dir, tmp_path / "no" / "recipes.yaml")
def test_all_file_errors_survive_to_one_content_error(tmp_path: Path) -> None:
levels_dir, recipes_path = make_content_dir(tmp_path)
(levels_dir / "bad_one.yaml").write_text("grid: [unclosed\n", encoding="utf-8")
write_yaml(levels_dir, "bad_two.yaml", {"id": "x"}) # mostly-missing schema
write_yaml(levels_dir, "bad_three.yaml", level_body("ghost", 1, orders={
"pool": ["phantom"],
"concurrent": 1,
"spawn_interval": [16, 20],
"deadline": 60,
}))
with pytest.raises(ContentError) as excinfo:
load_content(levels_dir, recipes_path)
message = str(excinfo.value)
assert "bad_one.yaml" in message
assert "invalid YAML" in message
assert "bad_two.yaml" in message
assert "Field required" in message
assert "bad_three.yaml" in message
assert "phantom" in message
# every broken file opens its own issue block
assert f"{levels_dir / 'bad_one.yaml'}: " in message
assert f"{levels_dir / 'bad_two.yaml'}: " in message
assert f"{levels_dir / 'bad_three.yaml'}: " in message
# ------------------------------------------------------------------ load_level_file
def test_load_level_file_returns_a_built_level(tmp_path: Path) -> None:
path = write_yaml(tmp_path, "01_a.yaml", level_body("01_a", 1))
level = load_level_file(path)
assert isinstance(level, Level)
assert level.station_items == {"spawn_shelf_0": ItemKind.TOMATO}
assert level.grid.width == 5
def test_load_level_file_names_the_file_on_schema_errors(tmp_path: Path) -> None:
path = write_yaml(tmp_path, "broken.yaml", level_body("x", 1, grid=["###", "#..#"]))
with pytest.raises(ContentError, match=r"broken.yaml.*ragged grid"):
load_level_file(path)
def test_load_level_file_wraps_geometry_failures(tmp_path: Path, monkeypatch: Any) -> None:
def explode(_spec: Any) -> Level:
raise GridError("planted geometry failure")
monkeypatch.setattr(Level, "build", staticmethod(explode))
path = write_yaml(tmp_path, "01_a.yaml", level_body("01_a", 1))
with pytest.raises(ContentError, match=r"01_a\.yaml: planted geometry failure"):
load_level_file(path)
# --------------------------------------------------------------------- ContentBundle
def empty_book() -> RecipeBook:
return RecipeBook.model_validate({"recipes": {}})
def built_level(level_id: str, order: int) -> Level:
return Level.build(LevelDef.model_validate(level_body(level_id, order)))
def test_bundle_by_id_is_derived_and_can_be_read_back() -> None:
bundle = ContentBundle(recipe_book=empty_book(), levels=(built_level("a", 2),))
assert list(bundle.by_id) == ["a"]
assert bundle.by_id["a"].order == 2
def test_a_hand_built_bundle_cannot_smuggle_duplicate_ids() -> None:
levels = (built_level("a", 1), built_level("a", 2))
with pytest.raises(ContentError, match="duplicate level ids: a"):
ContentBundle(recipe_book=empty_book(), levels=levels)
+234
View File
@@ -0,0 +1,234 @@
"""A8, enforced: the campaign is YAML on disk and never a list in Python.
Phase 02 task 05's regression lock. The loader (`sim/content.py`), the content
API (`server/routes_content.py`) and the author CLI (`tools/levels.py`) all
discover levels by *reading a directory*, so a new `.yaml` file is the only
thing an author writes to ship a level. This test proves that twice over:
* **addition without code** — copy `levels/` to `tmp_path`, drop in
`90_zerocool.yaml` with `order: 99`, and the loader, `/api/levels` and
`undercooked-levels list`/`show`/`validate` all carry it, while the id
appears in no Python file at all (and deleting the file removes it again);
* **absence of literals** — no file under `src/undercooked/` (and no browser
asset) contains a shipped level id or recipe id as a literal, so a hard-coded
list cannot be quietly re-added later.
The session fixture points `UNDERCOOKED_LEVELS_DIR` at the empty
`tests/fixtures/levels/` directory, so every path here is set explicitly.
"""
from __future__ import annotations
import shutil
from collections.abc import Mapping
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from undercooked.config import load_settings
from undercooked.server.app import create_app
from undercooked.server.routes_content import RECIPES_FILENAME
from undercooked.sim.content import load_content
from undercooked.sim.recipe import load_recipe_book
from undercooked.tools.levels import EXIT_OK, EXIT_UNUSABLE, main
pytestmark = pytest.mark.unit
REPO_ROOT = Path(__file__).resolve().parents[2]
SHIPPED_LEVELS = REPO_ROOT / "levels"
SHIPPED_RECIPES = SHIPPED_LEVELS / RECIPES_FILENAME
SOURCE_ROOT = REPO_ROOT / "src" / "undercooked"
STATIC_ROOT = REPO_ROOT / "app" / "static"
def shipped_level_ids() -> set[str]:
"""Every id the shipped `levels/` directory carries (the book is not a level)."""
return {
path.stem
for path in SHIPPED_LEVELS.glob("*.yaml")
if path.name not in {RECIPES_FILENAME, "recipes.yml"}
}
#: The level this test adds. `order: 99` puts it last in the campaign whatever
#: the shipped levels do, and three dishes with three orders live at once move
#: the escalation axes the campaign rule (`DIFFICULTY_ORDER`) asks about.
NEW_LEVEL_ID = "90_zerocool"
NEW_LEVEL_YAML = """\
id: 90_zerocool
name: Zero Cool
order: 99
difficulty: 3
time_limit: 120
target_score: 110
blurb: >-
A manifest probe, not a campaign level: three dishes, three orders live at
once, and the same pinched floor as the second kitchen.
grid:
- "#############"
- "#T....C....O#"
- "#.....S.....#"
- "####=...=####"
- "#....P.X....#"
- "#...........#"
- "####=...=####"
- "#R.K.....L..#"
- "#############"
tiles:
"#": {kind: wall}
".": {kind: floor}
"=": {kind: counter}
"T": {kind: floor, station: spawn_shelf, item: tomato}
"O": {kind: floor, station: spawn_shelf, item: onion}
"L": {kind: floor, station: spawn_shelf, item: lettuce}
"R": {kind: floor, station: spawn_shelf, item: rice}
"C": {kind: floor, station: chop_board}
"S": {kind: floor, station: stove}
"P": {kind: floor, station: prep}
"X": {kind: floor, station: serve}
"K": {kind: floor, station: sink}
spawns: [[2, 2], [10, 5]]
orders:
pool: [soup, salad, fried_rice]
concurrent: 3
spawn_interval: [10, 14]
deadline: 45
hazards: []
escalation_notes: >-
Moves recipe_complexity, order_pressure and time_budget up versus the last
shipped level, which is what the campaign rule asks for.
"""
def copy_shipped_levels(destination: Path) -> Path:
"""`levels/` copied verbatim — making a level here edits no Python."""
shutil.copytree(SHIPPED_LEVELS, destination)
return destination
def text_files(root: Path, suffixes: tuple[str, ...]) -> Mapping[str, str]:
"""Every file under `root` with one of `suffixes`, keyed by path."""
return {
str(path): path.read_text(encoding="utf-8")
for path in sorted(root.rglob("*"))
if path.is_file()
and path.suffix in suffixes
and "__pycache__" not in path.parts
}
def offenders(literals: set[str], texts: Mapping[str, str]) -> dict[str, list[str]]:
"""Which literals appear in which file — empty means the scan passed."""
return {
path: sorted(literal for literal in literals if literal in text)
for path, text in texts.items()
if any(literal in text for literal in literals)
}
@pytest.fixture()
def extended_campaign(tmp_path: Path) -> Path:
"""A copy of the shipped campaign plus one level nothing ever registered."""
campaign = copy_shipped_levels(tmp_path / "campaign")
(campaign / f"{NEW_LEVEL_ID}.yaml").write_text(NEW_LEVEL_YAML, encoding="utf-8")
return campaign
# --- a file is the only thing an author writes ----------------------------------
def test_a_new_yaml_file_reaches_the_loader_the_api_and_the_cli_without_code(
extended_campaign: Path,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
shipped_ids = [level.id for level in load_content(SHIPPED_LEVELS, SHIPPED_RECIPES).levels]
# 1. the pure loader
bundle = load_content(extended_campaign, extended_campaign / RECIPES_FILENAME)
assert [level.id for level in bundle.levels] == [*shipped_ids, NEW_LEVEL_ID]
assert list(bundle.by_id) == [*shipped_ids, NEW_LEVEL_ID]
assert bundle.by_id[NEW_LEVEL_ID].order == 99
# 2. the HTTP content API, addressed only through the environment
monkeypatch.setenv("UNDERCOOKED_LEVELS_DIR", str(extended_campaign))
monkeypatch.delenv("UNDERCOOKED_RECIPES_PATH", raising=False)
with TestClient(create_app(load_settings())) as client:
listing = client.get("/api/levels")
assert listing.status_code == 200
assert [level["id"] for level in listing.json()] == [*shipped_ids, NEW_LEVEL_ID]
detail = client.get(f"/api/levels/{NEW_LEVEL_ID}")
assert detail.status_code == 200
assert detail.json()["order"] == 99
assert detail.json()["difficulty"] == 3
# 3. the author CLI, on the same environment and no `--dir`
assert main(["list"]) == EXIT_OK
assert capsys.readouterr().out.splitlines()[-1].split()[1] == NEW_LEVEL_ID
assert main(["show", NEW_LEVEL_ID, "--grid"]) == EXIT_OK
shown = capsys.readouterr().out
assert shown.startswith(f"{NEW_LEVEL_ID} - Zero Cool")
assert "concurrent=3" in shown
# 4. and the gate accepts it: a row in a table that breaks PLAN §8 is not a level
assert main(["validate"]) == EXIT_OK
assert NEW_LEVEL_ID in capsys.readouterr().out
# The proof of the whole point: the new level is everywhere except the code.
assert offenders({NEW_LEVEL_ID}, text_files(SOURCE_ROOT, (".py", ".pyi"))) == {}
def test_the_copied_campaign_on_its_own_is_still_the_shipped_two(tmp_path: Path) -> None:
"""The copy is faithful, so the test above measures exactly one added file."""
pristine = copy_shipped_levels(tmp_path / "pristine")
copied = load_content(pristine, pristine / RECIPES_FILENAME)
shipped = load_content(SHIPPED_LEVELS, SHIPPED_RECIPES)
assert [level.id for level in copied.levels] == [level.id for level in shipped.levels]
def test_a_deletion_is_as_invisible_as_an_addition(
extended_campaign: Path, capsys: pytest.CaptureFixture[str]
) -> None:
"""Nothing caches the campaign in Python either: remove the file, it is gone."""
(extended_campaign / f"{NEW_LEVEL_ID}.yaml").unlink()
assert main(["list", "--dir", str(extended_campaign)]) == EXIT_OK
assert NEW_LEVEL_ID not in capsys.readouterr().out
assert main(["show", NEW_LEVEL_ID, "--dir", str(extended_campaign)]) == EXIT_UNUSABLE
assert NEW_LEVEL_ID not in load_content(
extended_campaign, extended_campaign / RECIPES_FILENAME
).by_id
# --- no literals in the source --------------------------------------------------
def test_no_python_source_names_a_shipped_level_id() -> None:
"""`levels/*.yaml` owns level ids: nothing in `src/undercooked/` repeats one."""
shipped = load_content(SHIPPED_LEVELS, SHIPPED_RECIPES)
literals = shipped_level_ids() | {level.id for level in shipped.levels}
assert literals # a scan of an empty set would pass and prove nothing
assert offenders(literals, text_files(SOURCE_ROOT, (".py", ".pyi"))) == {}
def test_no_python_source_names_a_recipe_from_the_book() -> None:
"""A8 covers recipes too: `recipes.yaml` is the only list of dishes."""
literals = set(load_recipe_book(SHIPPED_RECIPES).ids())
assert literals
assert offenders(literals, text_files(SOURCE_ROOT, (".py", ".pyi"))) == {}
def test_no_browser_asset_names_a_shipped_level_id() -> None:
"""The client asks the API for the campaign; it does not carry one."""
literals = shipped_level_ids()
assets = text_files(STATIC_ROOT, (".js", ".css", ".html", ".svg"))
assert assets
assert offenders(literals, assets) == {}
def test_the_loader_discovers_levels_by_walking_the_directory() -> None:
"""The discovery mechanism behind every test above: a directory walk."""
loader = (SOURCE_ROOT / "sim" / "content.py").read_text(encoding="utf-8")
assert "rglob" in loader
assert "iterdir" not in loader # one walk, at any depth, not a flat listing
+186
View File
@@ -0,0 +1,186 @@
"""Startup content gate (task 04): broken content refuses to start the app.
``create_app`` loads and validates the campaign once through
``load_validated_content``. These tests drive that function and the factory
directly: the shipped content loads; a level whose serve window is sealed
into a wall pocket raises ``ContentError`` and prints the named-rule report
on stderr; a missing recipe book does the same; and a *warning* band target
score logs a warning without refusing startup.
"""
from __future__ import annotations
import logging
import shutil
import textwrap
from pathlib import Path
import pytest
from undercooked.config import Settings
from undercooked.server.app import create_app
from undercooked.server.routes_content import (
RECIPES_FILENAME,
load_validated_content,
resolve_recipes_path,
)
from undercooked.sim.recipe import ContentError
REPO_ROOT = Path(__file__).resolve().parents[2]
SHIPPED_LEVELS = REPO_ROOT / "levels"
def make_settings(levels_dir: Path) -> Settings:
return Settings(levels_dir=levels_dir, recipes_path=levels_dir / RECIPES_FILENAME)
# --- path resolution ----------------------------------------------------------
def test_recipes_path_defaults_inside_the_levels_dir() -> None:
settings = Settings(levels_dir=Path("/content/campaign"))
assert settings.recipes_path is None # the shipped default is "derive it"
assert resolve_recipes_path(settings) == Path("/content/campaign/recipes.yaml")
def test_recipes_path_override_wins() -> None:
settings = Settings(
levels_dir=Path("/content/campaign"), recipes_path=Path("/other/book.yaml")
)
assert resolve_recipes_path(settings) == Path("/other/book.yaml")
# --- the happy path ------------------------------------------------------------
def test_startup_loads_the_shipped_bundle() -> None:
application = create_app(Settings(levels_dir=SHIPPED_LEVELS))
loaded = application.state.content
assert [level.id for level in loaded.levels] == [
"01_first_kitchen",
"02_double_shift",
]
assert "burger" in loaded.recipe_book.recipes
# --- broken content refuses startup ---------------------------------------------
def copy_shipped_content(tmp_path: Path) -> Path:
"""A writable copy of the shipped campaign the tests may vandalise."""
target = tmp_path / "levels"
shutil.copytree(SHIPPED_LEVELS, target)
return target
def test_missing_recipe_book_refuses_startup_with_a_stderr_report(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
capsys.readouterr() # discard anything earlier tests printed
with pytest.raises(ContentError, match=r"recipes\.yaml"):
load_validated_content(make_settings(tmp_path))
err = capsys.readouterr().err
assert "content failed to load" in err
assert "recipes.yaml" in err
def test_unparsable_level_file_refuses_startup(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
levels = copy_shipped_content(tmp_path)
(levels / "03_broken.yaml").write_text("\tthis: [is not", encoding="utf-8")
capsys.readouterr()
with pytest.raises(ContentError, match=r"03_broken\.yaml"):
create_app(make_settings(levels))
assert "03_broken.yaml" in capsys.readouterr().err
def test_sealed_serve_window_refuses_startup_with_named_rule_report(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
levels = copy_shipped_content(tmp_path)
path = levels / "02_double_shift.yaml"
text = path.read_text(encoding="utf-8")
vandalism = text.replace('- "####=...=####"', '- "####=..#=####"', 1)
vandalism = vandalism.replace('- "#....P.X....#"', '- "#....P#X#...#"')
vandalism = vandalism.replace('- "#...........#"', '- "#......#....#"')
assert vandalism != text, "the grid rows this test vandalises changed"
path.write_text(vandalism, encoding="utf-8")
capsys.readouterr()
with pytest.raises(ContentError, match="SERVE_REACHABLE"):
create_app(make_settings(levels))
err = capsys.readouterr().err
assert "SERVE_REACHABLE" in err
assert "02_double_shift" in err
# --- warnings do not refuse startup ----------------------------------------------
WARN_BAND_CONTENT = """\
id: probe_level
name: Probe
order: 1
difficulty: 1
time_limit: 30
target_score: 40
blurb: A synthetic level that lands just inside the target-score warning band.
grid:
- "#######"
- "#T....#"
- "#.....#"
- "#....X#"
- "#######"
tiles:
"#": {kind: wall}
".": {kind: floor}
"T": {kind: floor, station: spawn_shelf, item: tomato}
"X": {kind: floor, station: serve}
spawns: [[1, 1]]
orders:
pool: [probe]
concurrent: 1
spawn_interval: [10, 14]
deadline: 40
"""
WARN_BAND_RECIPES = """\
recipes:
probe:
id: probe
name: Probe Dish
base_score: 10
ingredients:
- {kind: tomato, form: raw}
steps:
- {kind: pick, station: spawn_shelf}
- {kind: serve, station: serve}
"""
def write_warn_band_content(tmp_path: Path) -> Path:
(tmp_path / RECIPES_FILENAME).write_text(
textwrap.dedent(WARN_BAND_RECIPES), encoding="utf-8"
)
(tmp_path / "probe_level.yaml").write_text(
textwrap.dedent(WARN_BAND_CONTENT), encoding="utf-8"
)
return tmp_path
def test_warn_band_target_starts_but_logs_the_warning(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
levels = write_warn_band_content(tmp_path)
with caplog.at_level(logging.WARNING, logger="undercooked.server.routes_content"):
bundle = load_validated_content(make_settings(levels))
assert [level.id for level in bundle.levels] == ["probe_level"]
messages = [record.getMessage() for record in caplog.records]
assert any("TARGET_IMPLAUSIBLE" in message for message in messages), messages
def test_warn_band_level_still_builds_an_app(tmp_path: Path) -> None:
levels = write_warn_band_content(tmp_path)
application = create_app(make_settings(levels))
assert application.state.content.by_id["probe_level"].target_score == 40
+7 -2
View File
@@ -204,9 +204,14 @@ def test_no_doc_file_exists_that_nothing_promises() -> None:
)
def test_the_docs_on_disk_are_the_three_phase_01_documents() -> None:
def test_the_docs_on_disk_are_the_shipped_documents() -> None:
"""Pins today's tree so that adding a document is a deliberate, named act."""
assert written_docs() == {"docs/DESIGN.md", "docs/DEPLOYMENT.md", "docs/TESTING.md"}
assert written_docs() == {
"docs/DESIGN.md",
"docs/DEPLOYMENT.md",
"docs/LEVELS.md",
"docs/TESTING.md",
}
def stub_without_owner(relative: str, text: str) -> str | None:
+6 -1
View File
@@ -50,7 +50,12 @@ def test_fixture_levels_dir_exists(isolated_levels_dir: None) -> None:
levels_dir = Path(os.environ["UNDERCOOKED_LEVELS_DIR"])
assert levels_dir == REPO_ROOT / "tests" / "fixtures" / "levels"
assert levels_dir.is_dir()
assert not list(levels_dir.glob("*.yaml"))
# Hermeticity is about *levels*: a default test must never see the shipped
# campaign. The committed `recipes.yaml` is not one (the loader never treats
# it as a level) — startup has required a recipe book since phase 02 task 04,
# and this minimal one is what lets fixture-isolated apps boot at all.
levels = [p.name for p in levels_dir.glob("*.yaml") if p.name != "recipes.yaml"]
assert not levels
def test_e2e_tests_follow_the_story_naming_convention() -> None:
+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"
+839
View File
@@ -0,0 +1,839 @@
"""Unit tests for the ``undercooked-levels`` author CLI (phase 02 task 05).
The CLI is the author's half of the content contract, so these tests pin the
three things a human depends on:
* **the same code path as the server** — `validate` is `load_content` +
`validate_bundle`, so a green run here is a green `create_app()`;
* **the output contract** — one line per level, the named-rule report, a
`--json` document a script can parse, the `show --grid` reachability overlay
that makes a sealed pocket visible, and the `axes` table phase 11 will feed;
* **the exit codes** — 0 usable, 1 a rule is broken, 2 nothing usable to work
with (load failure, unknown id, refused overwrite, usage error).
Content is addressed explicitly with ``--dir`` (the session-wide
`UNDERCOOKED_LEVELS_DIR` fixture points at an empty campaign), plus one test
that deletes the variable and ``chdir``s to prove the documented commands still
resolve ``levels/`` from the checkout rather than from luck about cwd.
"""
from __future__ import annotations
import json
import os
import shutil
import subprocess
import sys
import tomllib
from collections.abc import Iterator, Sequence
from dataclasses import replace
from pathlib import Path
import pytest
import yaml
from undercooked.config import Settings, load_settings
from undercooked.server.routes_content import RECIPES_FILENAME
from undercooked.sim.content import ContentBundle, load_content, load_level_file
from undercooked.sim.recipe import ContentError, RecipeBook, load_recipe_book
from undercooked.sim.validation import (
AXIS_NAMES,
LevelViolation,
ValidationReport,
escalation_axes,
)
from undercooked.tools import levels
from undercooked.tools.levels import (
CHECKOUT_ROOT,
EXIT_OK,
EXIT_UNUSABLE,
EXIT_VIOLATIONS,
LIST_HEADER,
STARTER_HEIGHT,
_assign_chars,
_char_for,
_CliError,
_level_status,
describe_level,
main,
reachability_overlay,
render_starter_yaml,
resolve_content_paths,
)
pytestmark = pytest.mark.unit
REPO_ROOT = Path(__file__).resolve().parents[2]
SHIPPED_LEVELS = REPO_ROOT / "levels"
SHIPPED_RECIPES = SHIPPED_LEVELS / RECIPES_FILENAME
BAD_LEVELS = REPO_ROOT / "tests" / "fixtures" / "levels" / "_bad"
PYPROJECT = REPO_ROOT / "pyproject.toml"
def run(argv: Sequence[str]) -> int:
"""The console script's entry point, in-process (same target as the wrapper)."""
return main(list(argv))
@pytest.fixture()
def shipped_bundle() -> ContentBundle:
return load_content(SHIPPED_LEVELS, SHIPPED_RECIPES)
@pytest.fixture()
def campaign_copy(tmp_path: Path) -> Iterator[Path]:
"""A writable copy of the shipped campaign that `new` may extend."""
target = tmp_path / "campaign"
shutil.copytree(SHIPPED_LEVELS, target)
yield target
def write_campaign(tmp_path: Path, level_yaml: str, book_yaml: str) -> Path:
"""A throwaway campaign directory built from two YAML documents."""
directory = tmp_path / "mini"
directory.mkdir(parents=True, exist_ok=True)
(directory / RECIPES_FILENAME).write_text(book_yaml, encoding="utf-8")
(directory / "01_mini.yaml").write_text(level_yaml, encoding="utf-8")
return directory
# --- path resolution -----------------------------------------------------------
def test_absolute_flags_win_over_everything() -> None:
levels_dir, recipes = resolve_content_paths(
Settings(levels_dir=SHIPPED_LEVELS, recipes_path=SHIPPED_RECIPES),
Path("/content/levels"),
Path("/content/book.yaml"),
)
assert levels_dir == Path("/content/levels")
assert recipes == Path("/content/book.yaml")
def test_the_recipe_book_defaults_to_the_one_inside_the_levels_dir() -> None:
levels_dir, recipes = resolve_content_paths(Settings(levels_dir=SHIPPED_LEVELS))
assert levels_dir == SHIPPED_LEVELS
assert recipes == SHIPPED_RECIPES
def test_the_recipe_book_flag_beats_the_environment(tmp_path: Path, monkeypatch) -> None:
env_book = tmp_path / "env-book.yaml"
monkeypatch.setenv("UNDERCOOKED_RECIPES_PATH", str(env_book))
_, from_env = resolve_content_paths(load_settings())
assert from_env == env_book
_, from_flag = resolve_content_paths(load_settings(), None, SHIPPED_RECIPES)
assert from_flag == SHIPPED_RECIPES
def test_a_relative_path_that_exists_here_is_used_here(tmp_path: Path, monkeypatch) -> None:
base = tmp_path.resolve()
(base / "levels").mkdir()
monkeypatch.chdir(base)
levels_dir, recipes = resolve_content_paths(Settings(levels_dir=Path("levels")))
assert levels_dir == base / "levels"
assert recipes == base / "levels" / RECIPES_FILENAME
def test_a_relative_path_with_nothing_local_anchors_to_the_checkout(
tmp_path: Path, monkeypatch
) -> None:
"""The documented `undercooked-levels validate` works from any directory."""
monkeypatch.chdir(tmp_path)
levels_dir, recipes = resolve_content_paths(Settings(levels_dir=Path("levels")))
assert levels_dir == CHECKOUT_ROOT / "levels"
assert recipes == CHECKOUT_ROOT / "levels" / RECIPES_FILENAME
assert levels_dir.is_dir()
def test_a_path_that_exists_nowhere_is_reported_as_it_was_typed(
tmp_path: Path, monkeypatch
) -> None:
monkeypatch.chdir(tmp_path)
levels_dir, recipes = resolve_content_paths(
Settings(levels_dir=Path("nope/levels"), recipes_path=Path("nope/book.yaml"))
)
assert levels_dir == Path("nope/levels")
assert recipes == Path("nope/book.yaml")
def test_a_home_relative_path_is_expanded() -> None:
levels_dir, _ = resolve_content_paths(Settings(levels_dir=Path("~/levels")))
assert levels_dir == Path.home() / "levels"
# --- validate -------------------------------------------------------------------
def test_validate_is_green_on_the_shipped_campaign_and_prints_one_line_per_level(
capsys: pytest.CaptureFixture[str], shipped_bundle: ContentBundle
) -> None:
assert run(["validate", "--dir", str(SHIPPED_LEVELS)]) == EXIT_OK
lines = capsys.readouterr().out.splitlines()
assert lines[-1] == "content OK: no violations"
assert len(lines) == len(shipped_bundle.levels) + 1
for level, line in zip(shipped_bundle.levels, lines[:-1], strict=True):
assert line.startswith(level.id)
assert "ok" in line
assert f"{level.grid.width}x{level.grid.height}" in line
def test_validate_exits_1_and_names_every_rule_it_hit(
capsys: pytest.CaptureFixture[str]
) -> None:
assert run(["validate", "--dir", str(BAD_LEVELS)]) == EXIT_VIOLATIONS
out = capsys.readouterr().out
assert "99_broken_room" in out
assert "4 error(s)" in out
for rule in ("SERVE_REACHABLE", "RECIPE_STATION_MISSING", "TARGET_IMPLAUSIBLE"):
assert rule in out
def test_validate_json_is_machine_readable_when_green(
capsys: pytest.CaptureFixture[str], shipped_bundle: ContentBundle
) -> None:
assert run(["validate", "--dir", str(SHIPPED_LEVELS), "--json"]) == EXIT_OK
payload = json.loads(capsys.readouterr().out)
assert payload["ok"] is True
assert payload["errors"] == []
assert payload["warnings"] == []
assert payload["counts"] == {"errors": 0, "warnings": 0}
assert [level["id"] for level in payload["levels"]] == [
level.id for level in shipped_bundle.levels
]
def test_validate_json_carries_the_rule_hits_and_their_detail(
capsys: pytest.CaptureFixture[str]
) -> None:
assert run(["validate", "--dir", str(BAD_LEVELS), "--json"]) == EXIT_VIOLATIONS
payload = json.loads(capsys.readouterr().out)
errors = payload["errors"]
assert payload["ok"] is False
assert payload["counts"] == {"errors": len(errors), "warnings": len(payload["warnings"])}
assert {error["rule"] for error in errors} >= {
"SERVE_REACHABLE",
"RECIPE_STATION_MISSING",
"TARGET_IMPLAUSIBLE",
}
assert {error["level"] for error in errors} == {"99_broken_room"}
assert {"recipe": "soup", "station": "stove"} in [error["detail"] for error in errors]
keys = [(error["level"], error["rule"], error["message"]) for error in errors]
assert keys == sorted(keys)
def test_validate_json_is_what_python_m_json_tool_accepts(
capsys: pytest.CaptureFixture[str], tmp_path: Path
) -> None:
"""The documented pipe: `undercooked-levels validate --json | python -m json.tool`."""
assert run(["validate", "--dir", str(BAD_LEVELS), "--json"]) == EXIT_VIOLATIONS
raw = capsys.readouterr().out
formatted = subprocess.run(
[sys.executable, "-m", "json.tool"],
input=raw,
capture_output=True,
text=True,
timeout=60,
check=False,
)
assert formatted.returncode == 0, formatted.stderr
assert "SERVE_REACHABLE" in formatted.stdout
assert json.loads(formatted.stdout)["ok"] is False
assert tmp_path # nothing is written; the report only goes to stdout
def test_validate_names_a_missing_directory_and_exits_2(
capsys: pytest.CaptureFixture[str], tmp_path: Path
) -> None:
missing = tmp_path / "not-here"
assert run(["validate", "--dir", str(missing)]) == EXIT_UNUSABLE
err = capsys.readouterr().err
assert err.startswith("undercooked-levels:")
assert "not-here" in err
def test_validate_reports_a_broken_recipe_book_as_exit_2(
capsys: pytest.CaptureFixture[str], tmp_path: Path
) -> None:
directory = tmp_path / "levels"
directory.mkdir()
(directory / RECIPES_FILENAME).write_text("\tthis: [is not", encoding="utf-8")
assert run(["validate", "--dir", str(directory)]) == EXIT_UNUSABLE
assert "recipes.yaml" in capsys.readouterr().err
def test_validate_follows_the_levels_dir_from_the_environment(
capsys: pytest.CaptureFixture[str], monkeypatch, campaign_copy: Path
) -> None:
monkeypatch.setenv("UNDERCOOKED_LEVELS_DIR", str(campaign_copy))
assert run(["validate"]) == EXIT_OK
assert "02_double_shift" in capsys.readouterr().out
@pytest.mark.parametrize(
"argv",
[
["validate"],
["list"],
["axes"],
["show", "01_first_kitchen", "--grid"],
],
)
def test_every_subcommand_resolves_levels_from_the_checkout_not_from_cwd(
argv: list[str], capsys: pytest.CaptureFixture[str], tmp_path: Path, monkeypatch
) -> None:
"""No `--dir`, empty cwd, unset env: the built-in `levels` still finds campaign 02."""
monkeypatch.delenv("UNDERCOOKED_LEVELS_DIR", raising=False)
monkeypatch.delenv("UNDERCOOKED_RECIPES_PATH", raising=False)
monkeypatch.chdir(tmp_path)
assert run(argv) == EXIT_OK
assert "01_first_kitchen" in capsys.readouterr().out
def test_level_status_counts_warnings_of_a_warning_only_level() -> None:
bundle = load_content(SHIPPED_LEVELS, SHIPPED_RECIPES)
level = bundle.levels[0]
warning = LevelViolation(rule="TARGET_IMPLAUSIBLE", level_id=level.id, message="tight")
clean = _level_status(level, ValidationReport())
noisy = _level_status(level, ValidationReport(violations=(), warnings=(warning,)))
error = LevelViolation(rule="SERVE_REACHABLE", level_id=level.id, message="sealed")
broken = _level_status(level, ValidationReport(violations=(error,), warnings=(warning,)))
assert clean == f"{level.id:<28} {'ok':<24} {level.grid.width}x{level.grid.height}" + (
f", {len(level.stations)} stations"
)
assert "0 error(s)" not in clean
assert ", 1 warning(s)" in noisy
assert "1 error(s), 1 warning(s)" in broken
# --- list -----------------------------------------------------------------------
def test_list_prints_one_line_per_level_in_campaign_order(
capsys: pytest.CaptureFixture[str], shipped_bundle: ContentBundle
) -> None:
assert run(["list", "--dir", str(SHIPPED_LEVELS)]) == EXIT_OK
lines = capsys.readouterr().out.splitlines()
assert lines[0] == LIST_HEADER
assert len(lines) == 1 + len(shipped_bundle.levels)
for level, line in zip(shipped_bundle.levels, lines[1:], strict=True):
assert line.startswith(f"{level.order:>5} {level.id:<28}")
assert str(level.target_score) in line
assert f"{level.time_limit:>6.1f}" in line
for recipe_id in level.orders.pool:
assert recipe_id in line
def test_list_of_an_empty_campaign_is_only_the_header(
capsys: pytest.CaptureFixture[str], tmp_path: Path
) -> None:
empty = tmp_path / "empty"
empty.mkdir()
shutil.copy(SHIPPED_RECIPES, empty / RECIPES_FILENAME)
assert run(["list", "--dir", str(empty)]) == EXIT_OK
assert capsys.readouterr().out.splitlines() == [LIST_HEADER]
# --- show -----------------------------------------------------------------------
def test_show_prints_the_resolved_level(
capsys: pytest.CaptureFixture[str], shipped_bundle: ContentBundle
) -> None:
level = shipped_bundle.levels[1]
assert run(["show", level.id, "--dir", str(SHIPPED_LEVELS)]) == EXIT_OK
out = capsys.readouterr().out
assert out.startswith(f"{level.id} - {level.name}")
for label in (
"order",
"difficulty",
"time_limit",
"target_score",
"grid",
"spawns",
"stations",
"orders",
"limits",
"hazards",
"reachability",
"recipes",
"blurb",
"escalation_notes",
):
assert f"{label:<18}" in out
assert "spawn_shelf x" in out
assert "map (as authored)" not in out # --grid is opt-in
def test_show_grid_adds_the_authored_map_and_the_reachability_overlay(
capsys: pytest.CaptureFixture[str], shipped_bundle: ContentBundle
) -> None:
level = shipped_bundle.levels[0]
assert run(["show", level.id, "--grid", "--dir", str(SHIPPED_LEVELS)]) == EXIT_OK
out = capsys.readouterr().out
assert "map (as authored)" in out
for row in level.spec.grid:
assert f" {row}" in out
assert ". reachable" in out
assert "?" not in out.split("reachability")[1] # the shipped kitchen has no dead pockets
assert f"{level.grid.width}x{level.grid.height}" in out
def test_show_grid_marks_a_sealed_pocket_as_unreachable(
capsys: pytest.CaptureFixture[str]
) -> None:
assert run(["show", "99_broken_room", "--grid", "--dir", str(BAD_LEVELS)]) == EXIT_OK
out = capsys.readouterr().out
overlay = out.split("? walkable but cut off")[1]
assert "??" in overlay
assert "????#" in overlay # the walled-in servery row
assert "walkable tiles are reachable from the spawns" in overlay
def test_show_rejects_an_unknown_level_id(
capsys: pytest.CaptureFixture[str], shipped_bundle: ContentBundle
) -> None:
assert run(["show", "07_no_such_kitchen", "--dir", str(SHIPPED_LEVELS)]) == EXIT_UNUSABLE
err = capsys.readouterr().err
assert "no level '07_no_such_kitchen'" in err
assert "01_first_kitchen" in err # it says what does exist
# --- axes -----------------------------------------------------------------------
def test_axes_table_lists_every_axis_for_every_level_in_campaign_order(
capsys: pytest.CaptureFixture[str], shipped_bundle: ContentBundle
) -> None:
assert run(["axes", "--dir", str(SHIPPED_LEVELS)]) == EXIT_OK
lines = capsys.readouterr().out.splitlines()
header = lines[0]
assert header.startswith("LEVEL")
for name in AXIS_NAMES:
assert name in header
assert set(AXIS_NAMES) == set(escalation_axes(shipped_bundle.levels[0]))
assert len(lines) == 1 + len(shipped_bundle.levels)
for level, line in zip(shipped_bundle.levels, lines[1:], strict=True):
assert line.startswith(f"{level.id:<28}{level.order:>6}")
axes = escalation_axes(level)
for name in AXIS_NAMES:
assert f"{axes[name]:>20.4f}" in line
def test_axes_of_an_empty_campaign_is_only_the_header(
capsys: pytest.CaptureFixture[str], tmp_path: Path
) -> None:
empty = tmp_path / "empty"
empty.mkdir()
shutil.copy(SHIPPED_RECIPES, empty / RECIPES_FILENAME)
assert run(["axes", "--dir", str(empty)]) == EXIT_OK
assert len(capsys.readouterr().out.splitlines()) == 1
# --- new ------------------------------------------------------------------------
def test_new_writes_a_starter_level_and_validates_it(
capsys: pytest.CaptureFixture[str], campaign_copy: Path, shipped_bundle: ContentBundle
) -> None:
assert run(["new", "03_zero_cool", "--dir", str(campaign_copy)]) == EXIT_OK
out = capsys.readouterr().out
assert f"wrote {campaign_copy / '03_zero_cool.yaml'}" in out
assert "content OK: no violations" in out
written = campaign_copy / "03_zero_cool.yaml"
body = yaml.safe_load(written.read_text(encoding="utf-8"))
top_difficulty = max(level.difficulty for level in shipped_bundle.levels)
top_order = max(level.order for level in shipped_bundle.levels)
assert body["id"] == "03_zero_cool"
assert body["order"] == top_order + 1
assert body["difficulty"] == top_difficulty + 1
# It is a real level: it loads, and the campaign now carries three of them.
level = load_level_file(written)
assert level.name == "Zero Cool"
assert load_content(campaign_copy, SHIPPED_RECIPES).levels[-1].id == "03_zero_cool"
# ...and the scaffolded kitchen is self-consistent: every grid letter used is
# declared, and nothing declared is left unused.
assert set(body["tiles"]) == {cell for row in body["grid"] for cell in row}
def test_new_uses_the_requested_order(
capsys: pytest.CaptureFixture[str], campaign_copy: Path
) -> None:
assert run(["new", "07_late_shift", "--order", "7", "--dir", str(campaign_copy)]) == EXIT_OK
body = yaml.safe_load((campaign_copy / "07_late_shift.yaml").read_text(encoding="utf-8"))
assert body["order"] == 7
capsys.readouterr()
def test_new_refuses_to_overwrite_an_existing_level(
capsys: pytest.CaptureFixture[str], campaign_copy: Path
) -> None:
target = campaign_copy / "01_first_kitchen.yaml"
before = target.read_text(encoding="utf-8")
assert run(["new", "01_first_kitchen", "--dir", str(campaign_copy)]) == EXIT_UNUSABLE
err = capsys.readouterr().err
assert "already exists" in err
assert "refusing to overwrite" in err
assert target.read_text(encoding="utf-8") == before
@pytest.mark.parametrize("candidate", ["ZeroCool", "03 zero cool", "_private", "03-second"])
def test_new_rejects_an_id_that_is_not_a_filename_safe_slug(
candidate: str, capsys: pytest.CaptureFixture[str], campaign_copy: Path
) -> None:
assert run(["new", candidate, "--dir", str(campaign_copy)]) == EXIT_UNUSABLE
assert "slug" in capsys.readouterr().err
assert not (campaign_copy / f"{candidate}.yaml").exists()
def test_new_rejects_an_order_below_one(
capsys: pytest.CaptureFixture[str], campaign_copy: Path
) -> None:
assert run(["new", "03_probe", "--order", "0", "--dir", str(campaign_copy)]) == EXIT_UNUSABLE
assert "--order must be at least 1" in capsys.readouterr().err
def test_new_needs_a_readable_campaign(
capsys: pytest.CaptureFixture[str], tmp_path: Path
) -> None:
assert run(["new", "03_probe", "--dir", str(tmp_path / "nothing")]) == EXIT_UNUSABLE
assert "undercooked-levels:" in capsys.readouterr().err
@pytest.mark.parametrize(
"error",
[OSError("disk full"), OSError(28, "No space left on device")],
ids=["no-strerror", "with-strerror"],
)
def test_new_reports_a_write_failure(
error: OSError,
capsys: pytest.CaptureFixture[str],
campaign_copy: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
def refuse(self: Path, *args: object, **kwargs: object) -> int:
raise error
monkeypatch.setattr(Path, "write_text", refuse)
assert run(["new", "03_probe", "--dir", str(campaign_copy)]) == EXIT_UNUSABLE
err = capsys.readouterr().err
assert "cannot write" in err
assert (error.strerror or str(error)) in err
def test_new_reports_a_file_the_loader_rejects(
capsys: pytest.CaptureFixture[str], campaign_copy: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
def refuse(path: Path | str) -> object:
raise ContentError("the loader hated the scaffold")
monkeypatch.setattr(levels, "load_level_file", refuse)
assert run(["new", "03_probe", "--dir", str(campaign_copy)]) == EXIT_UNUSABLE
assert "the loader hated the scaffold" in capsys.readouterr().err
# --- the scaffold itself --------------------------------------------------------
def test_the_scaffold_is_built_from_the_recipe_book_not_a_template_kitchen() -> None:
book = RecipeBook.model_validate(
{
"recipes": {
"broth": {
"id": "broth",
"name": "Plain Broth",
"base_score": 20,
"ingredients": [
{"kind": "rice", "form": "raw"},
{"kind": "fish", "form": "raw"},
],
"steps": [
{"kind": "pick", "station": "spawn_shelf"},
{"kind": "wash", "station": "sink"},
{"kind": "cook", "station": "stove"},
{"kind": "serve", "station": "serve"},
],
}
}
}
)
text = render_starter_yaml(level_id="05_broth_house", order=5, difficulty=4, book=book)
body = yaml.safe_load(text)
assert body["id"] == "05_broth_house"
assert body["name"] == "Broth House"
assert body["order"] == 5
assert body["difficulty"] == 4
assert body["orders"]["pool"] == ["broth"]
stations = {spec["station"] for spec in body["tiles"].values() if "station" in spec}
items = {spec["item"] for spec in body["tiles"].values() if "item" in spec}
assert stations == {"spawn_shelf", "sink", "stove", "serve"} # no chop board: no chop step
assert items == {"rice", "fish"}
assert set(body["tiles"]) == {cell for row in body["grid"] for cell in row}
assert len({len(row) for row in body["grid"]}) == 1
def test_the_scaffold_of_the_shipped_book_shelves_that_books_first_dish() -> None:
book = load_content(SHIPPED_LEVELS, SHIPPED_RECIPES).recipe_book
first = book.ids()[0]
body = yaml.safe_load(
render_starter_yaml(level_id="05_probe", order=5, difficulty=1, book=book)
)
expected_items = {
ingredient.kind.value
for ingredient in book.get(first).ingredients
if str(ingredient.form) == "raw"
}
assert {spec["item"] for spec in body["tiles"].values() if "item" in spec} == expected_items
assert body["orders"]["pool"] == [first]
def test_the_scaffold_needs_something_to_cook() -> None:
with pytest.raises(_CliError):
render_starter_yaml(level_id="05_probe", order=1, difficulty=1, book=RecipeBook(recipes={}))
def test_the_scaffold_keeps_the_spawn_far_enough_from_the_window() -> None:
book = load_content(SHIPPED_LEVELS, SHIPPED_RECIPES).recipe_book
body = yaml.safe_load(
render_starter_yaml(level_id="05_probe", order=1, difficulty=5, book=book)
)
width = len(body["grid"][0])
assert body["grid"][0] == "#" * width == body["grid"][-1]
assert len(body["grid"]) == STARTER_HEIGHT
fixtures = body["grid"][4]
serve_x = fixtures.index(
next(char for char, spec in body["tiles"].items() if spec.get("station") == "serve")
)
assert fixtures[serve_x] != "." # the window really stands on the fixture row
for x, y in body["spawns"]:
assert abs(x - serve_x) + abs(y - 4) >= 3 # TRAVEL_TOO_SHORT's floor
def test_grid_letters_stay_unique_when_a_preferred_letter_is_taken() -> None:
free = {"#", "."}
assert _char_for("T", free) == "T" # an untouched preference wins
assert _char_for("T", free | {"T"}) not in free | {"T"} # taken: a spare instead
assert _char_for(None, free) not in free
used = set(free)
assigned = _assign_chars(
["tomato", "onion", "lettuce", "rice"],
{"tomato": "T", "onion": "T", "lettuce": "T", "rice": "R"},
used,
)
assert list(assigned) == ["tomato", "onion", "lettuce", "rice"]
assert assigned["tomato"] == "T" # first in line keeps its preferred letter
assert assigned["onion"] != "T" # the collisions fall back to spares
assert assigned["rice"] == "R"
assert len(set(assigned.values())) == 4
assert used == {"#", ".", *assigned.values()} # the caller's set is the ledger
# --- describe_level / overlay directly ------------------------------------------
MINI_BOOK = """\
recipes:
snack:
id: snack
name: Plain Snack
base_score: 20
ingredients:
- {kind: tomato, form: raw}
steps:
- {kind: pick, station: spawn_shelf}
- {kind: serve, station: serve}
"""
MINI_LEVEL = """\
id: 01_mini
name: Mini
order: 1
time_limit: 90
target_score: 40
grid:
- "#######"
- "#T....#"
- "#.#.#.#"
- "#....X#"
- "#######"
tiles:
"#": {kind: wall}
".": {kind: floor}
"T": {kind: floor, station: spawn_shelf, item: tomato}
"X": {kind: floor, station: serve}
spawns: [[1, 1]]
orders:
pool: [snack]
concurrent: 1
spawn_interval: [10, 14]
deadline: 40
"""
#: The same kitchen with a cupboard walled in at (3, 3) and (4, 3): walkable
#: floor no spawn can reach, which is exactly what the overlay exists to show.
POCKET_LEVEL = MINI_LEVEL.replace(
"""\
id: 01_mini
name: Mini""",
"""\
id: 02_pocket
name: Pocket""",
).replace(
"""\
- "#######"
- "#T....#"
- "#.#.#.#"
- "#....X#"
- "#######""",
"""\
- "########"
- "#T....X#"
- "#.####.#"
- "#.#BB#.#"
- "#.####.#"
- "########""",
).replace(
' "X": {kind: floor, station: serve}',
' "X": {kind: floor, station: serve}\n "B": {kind: floor}',
)
def test_describe_marks_missing_prose_as_none(tmp_path: Path) -> None:
directory = write_campaign(tmp_path, MINI_LEVEL, MINI_BOOK)
level = load_level_file(directory / "01_mini.yaml")
out = "\n".join(describe_level(level, load_recipe_book(directory / RECIPES_FILENAME)))
assert "snack: Plain Snack" in out # the prose is missing, the recipe is not
assert out.count("(none)") == 2 # blurb and escalation_notes say so out loud
def test_describe_reports_a_pool_recipe_the_book_does_not_define(tmp_path: Path) -> None:
directory = write_campaign(tmp_path, MINI_LEVEL, MINI_BOOK)
level = load_level_file(directory / "01_mini.yaml")
out = "\n".join(describe_level(level, RecipeBook(recipes={})))
assert "snack: MISSING from the recipe book" in out
def test_describe_counts_hazards(tmp_path: Path) -> None:
level_yaml = MINI_LEVEL.replace("hazards: []", "") + (
"hazards:\n - {type: spill, x: 4, y: 1}\n - {type: fire, x: 2, y: 1}\n"
)
directory = tmp_path / "haz"
directory.mkdir()
(directory / RECIPES_FILENAME).write_text(MINI_BOOK, encoding="utf-8")
(directory / "01_mini.yaml").write_text(level_yaml, encoding="utf-8")
level = load_level_file(directory / "01_mini.yaml")
lines = describe_level(level, load_recipe_book(directory / RECIPES_FILENAME))
hazards = next(line for line in lines if line.startswith("hazards"))
assert "fire x1" in hazards
assert "spill x1" in hazards
def test_describe_says_none_when_a_level_has_no_stations(tmp_path: Path) -> None:
"""A hand-built level with no stations (content forbids one: no serve window).
`LevelDef` requires exactly one serve tile, so the station-less `Level` can
only come from a hand-built dataclass — which is what a phase-03 caller
holding a partially-built level does.
"""
directory = write_campaign(tmp_path, MINI_LEVEL, MINI_BOOK)
level = load_level_file(directory / "01_mini.yaml")
assert level.stations # the authored level has some; the stripped one has none
lines = describe_level(replace(level, stations=()), RecipeBook(recipes={}))
empty = "stations".ljust(18) + "none"
assert next(line for line in lines if line.startswith("stations")) == empty
def test_reachability_overlay_only_uses_the_three_terrain_characters(tmp_path: Path) -> None:
directory = write_campaign(tmp_path, POCKET_LEVEL, MINI_BOOK)
level = load_level_file(directory / "01_mini.yaml")
overlay = reachability_overlay(level)
assert {cell for row in overlay for cell in row} == {".", "?", "#"}
# the walled-in pocket reads as `??`; everything else the cooks can walk to.
assert overlay[3] == "#.#??#.#"
assert sum(row.count("?") for row in overlay) == 2
# --- entry point ----------------------------------------------------------------
def test_no_subcommand_is_a_usage_error(capsys: pytest.CaptureFixture[str]) -> None:
assert run([]) == EXIT_UNUSABLE
err = capsys.readouterr().err
assert "usage: undercooked-levels" in err
assert "arguments are required: COMMAND" in err
def test_an_unknown_subcommand_is_a_usage_error(capsys: pytest.CaptureFixture[str]) -> None:
assert run(["scullery"]) == EXIT_UNUSABLE
err = capsys.readouterr().err
assert "usage: undercooked-levels" in err
assert "invalid choice: 'scullery'" in err
def test_an_unknown_flag_is_a_usage_error(capsys: pytest.CaptureFixture[str]) -> None:
assert run(["validate", "--nope"]) == EXIT_UNUSABLE
assert "unrecognized arguments" in capsys.readouterr().err
@pytest.mark.parametrize("argv", [["--help"], ["validate", "--help"], ["new", "-h"]])
def test_help_exits_cleanly(argv: list[str], capsys: pytest.CaptureFixture[str]) -> None:
assert run(argv) == EXIT_OK
out = capsys.readouterr().out
assert "usage: undercooked-levels" in out
def test_help_documents_the_subcommands_and_exit_codes(
capsys: pytest.CaptureFixture[str],
) -> None:
assert run(["--help"]) == EXIT_OK
out = capsys.readouterr().out
for command in ("validate", "list", "show", "new", "axes"):
assert command in out
assert "0 ok, 1 a level breaks a validation rule" in out
def test_the_parser_builds_with_a_handler_for_every_subcommand() -> None:
parser = levels.build_parser()
for command, expected in (
(["validate"], "validate"),
(["list"], "list"),
(["show", "x"], "show"),
(["new", "x"], "new"),
(["axes"], "axes"),
):
args = parser.parse_args(command)
assert args.command == expected
assert callable(args.handler)
assert args.dir is None or isinstance(args.dir, Path)
def test_console_script_is_declared_and_runs_as_a_module() -> None:
"""The real entry point: `[project.scripts]` -> this module's `main`."""
declared = tomllib.loads(PYPROJECT.read_text(encoding="utf-8"))["project"]["scripts"]
assert declared["undercooked-levels"] == "undercooked.tools.levels:main"
result = subprocess.run( # fixed argv, the repo's own interpreter
[
sys.executable,
"-m",
"undercooked.tools.levels",
"list",
],
capture_output=True,
text=True,
timeout=120,
check=False,
cwd=REPO_ROOT,
env={**os.environ, "UNDERCOOKED_LEVELS_DIR": str(SHIPPED_LEVELS)},
)
assert result.returncode == EXIT_OK, result.stderr
assert "01_first_kitchen" in result.stdout
assert "02_double_shift" in result.stdout
def test_exit_codes_are_the_documented_ones() -> None:
assert (EXIT_OK, EXIT_VIOLATIONS, EXIT_UNUSABLE) == (0, 1, 2)
+104
View File
@@ -0,0 +1,104 @@
"""docs/LEVELS.md must document exactly the fields the models have.
The doc is the content author's contract; the Pydantic models are its
executable form. This test parses every ``### <Model>`` section's *Field*
table (the first table whose header row starts with ``Field``, so the
HazardSpec placement table is not mistaken for a field list) and requires the
back-quoted field names to equal ``model_fields`` — no undocumented field,
no documented ghost. A planted-drift case proves the extraction can fail.
"""
from __future__ import annotations
from pathlib import Path
import pytest
from pydantic import BaseModel
from undercooked.sim.level import (
HazardSpec,
LevelDef,
OrdersSpec,
StationLimits,
TileSpec,
)
from undercooked.sim.recipe import Ingredient, Recipe, RecipeBook, Step
pytestmark = pytest.mark.unit
DOC_PATH = Path(__file__).resolve().parents[2] / "docs" / "LEVELS.md"
MODELS: tuple[type[BaseModel], ...] = (
Ingredient,
Step,
Recipe,
RecipeBook,
TileSpec,
OrdersSpec,
HazardSpec,
StationLimits,
LevelDef,
)
def _section(lines: list[str], model_name: str) -> list[str]:
"""Lines of the `### model_name` section, up to the next heading."""
heading = f"### {model_name}"
collected: list[str] = []
in_section = False
for line in lines:
if line.startswith("#"):
if in_section:
break
in_section = line.strip() == heading
continue
if in_section:
collected.append(line)
return collected
def _table_fields(section: list[str]) -> set[str]:
"""Back-quoted first-column entries of the section's `Field` table."""
start = None
for index, line in enumerate(section):
stripped = line.strip()
if stripped.startswith("|") and stripped.strip("|").split("|")[0].strip() == "Field":
start = index
break
if start is None:
return set()
fields: set[str] = set()
for line in section[start + 2 :]: # skip header + |---| separator
stripped = line.strip()
if not stripped.startswith("|"):
break
first = stripped.strip("|").split("|")[0].strip()
if len(first) > 2 and first.startswith("`") and first.endswith("`"):
fields.add(first[1:-1])
return fields
@pytest.fixture(scope="module")
def doc_lines() -> list[str]:
assert DOC_PATH.is_file(), f"{DOC_PATH} is missing"
return DOC_PATH.read_text(encoding="utf-8").splitlines()
@pytest.mark.parametrize("model", MODELS, ids=lambda model: model.__name__)
def test_every_model_has_an_exact_field_table(doc_lines: list[str], model: type[BaseModel]) -> None:
documented = _table_fields(_section(doc_lines, model.__name__))
assert documented, f"docs/LEVELS.md has no Field table for {model.__name__}"
assert documented == set(model.model_fields), (
f"docs/LEVELS.md vs {model.__name__}: "
f"undocumented={sorted(set(model.model_fields) - documented)}, "
f"not-in-model={sorted(documented - set(model.model_fields))}"
)
def test_the_comparison_catches_planted_drift(doc_lines: list[str]) -> None:
planted = [
line.replace("| `penalty` |", "| `reward` |") for line in doc_lines
]
documented = _table_fields(_section(planted, "OrdersSpec"))
assert "reward" in documented
assert documented != set(OrdersSpec.model_fields)
+34
View File
@@ -37,6 +37,7 @@ from undercooked.config import (
get_settings,
reset_settings_cache,
)
from undercooked.sim.recipe import ContentError
if TYPE_CHECKING:
from collections.abc import Iterator
@@ -214,6 +215,39 @@ def test_main_reports_an_invalid_environment(
assert "UNDERCOOKED_TICK_HZ=99" in capsys.readouterr().err
def test_main_exits_two_when_the_content_gate_refuses_startup(
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
"""Unusable levels are a startup error, not a traceback.
Content is read when uvicorn imports the app string, so `create_app`'s
`ContentError` surfaces out of `uvicorn.run` — and the CLI answers with exit 2,
the same code an unusable `UNDERCOOKED_PORT` gets. The named-rule report itself
is written by `create_app` before it raises, so this only adds the closing line.
"""
def refusing_run(_target: str, **_kwargs: Any) -> None:
raise ContentError("undercooked: level content failed validation:\nBOGUS_RULE: nope")
monkeypatch.setattr(uvicorn, "run", refusing_run)
assert main([]) == EXIT_CONFIG_ERROR
err = capsys.readouterr().err
assert "refusing to start" in err
assert "undercooked-levels validate" in err
assert "Traceback" not in err
def test_main_lets_any_other_serving_failure_propagate(monkeypatch: pytest.MonkeyPatch) -> None:
"""Only the content gate is translated: a real crash stays a real crash."""
def crashing_run(_target: str, **_kwargs: Any) -> None:
raise OSError("port in use")
monkeypatch.setattr(uvicorn, "run", crashing_run)
with pytest.raises(OSError, match="port in use"):
main([])
def test_unknown_flag_is_an_argument_error() -> None:
with pytest.raises(SystemExit) as excinfo:
main(["--nope"])
+311
View File
@@ -0,0 +1,311 @@
"""Unit tests for `undercooked.sim.recipe` (phase 02 task 02).
The recipe book is the content contract every later phase cooks against: strict
schemas (typos are load errors, not defaults), deterministic helpers, and a
`min_seconds_to_complete` solver bound the validator trusts. Every failing case
here is a YAML an author could plausibly write.
"""
from __future__ import annotations
from pathlib import Path
from typing import Any
import pytest
from pydantic import ValidationError
from undercooked.sim.model import ItemForm, ItemKind, StationKind
from undercooked.sim.recipe import (
HANDLING_S,
ContentError,
Ingredient,
Recipe,
RecipeBook,
Step,
describe_validation_error,
load_recipe_book,
read_yaml_mapping,
)
pytestmark = pytest.mark.unit
def soup_dict(**overrides: Any) -> dict[str, Any]:
"""A minimal valid soup recipe body; overrides replace top-level fields."""
body: dict[str, Any] = {
"id": "soup",
"name": "Tomato Soup",
"base_score": 40,
"ingredients": [
{"kind": "tomato", "form": "raw"},
{"kind": "onion", "form": "raw", "count": 2},
],
"steps": [
{"kind": "chop", "station": "chop_board", "seconds": 0.9},
{"kind": "cook", "station": "stove", "seconds": 5.0},
],
}
body.update(overrides)
return body
def book_dict(**overrides: Any) -> dict[str, Any]:
data: dict[str, Any] = {"recipes": {"soup": soup_dict()}}
data.update(overrides)
return data
def write_book(directory: Path, text: str) -> Path:
path = directory / "recipes.yaml"
path.write_text(text, encoding="utf-8")
return path
# --------------------------------------------------------------------- Ingredient / Step
def test_handling_constant_is_the_documented_bound() -> None:
assert HANDLING_S == 0.6
def test_ingredient_defaults_count_to_one() -> None:
ingredient = Ingredient(kind=ItemKind.TOMATO, form=ItemForm.RAW)
assert ingredient.count == 1
def test_ingredient_count_must_be_positive() -> None:
with pytest.raises(ValidationError, match="count"):
Ingredient(kind=ItemKind.TOMATO, form=ItemForm.RAW, count=0)
def test_ingredient_kinds_are_the_enum_not_arbitrary_strings() -> None:
with pytest.raises(ValidationError):
Ingredient.model_validate({"kind": "durian", "form": "raw"})
def test_step_defaults_to_zero_seconds() -> None:
step = Step(kind="pick", station=StationKind.SPAWN_SHELF)
assert step.seconds == 0.0
def test_step_kind_is_a_closed_vocabulary() -> None:
with pytest.raises(ValidationError, match="kind"):
Step.model_validate({"kind": "throw", "station": "chop_board"})
def test_step_seconds_cannot_be_negative() -> None:
with pytest.raises(ValidationError, match="seconds"):
Step(kind="chop", station=StationKind.CHOP_BOARD, seconds=-0.1)
def test_step_station_must_be_a_known_station_kind() -> None:
with pytest.raises(ValidationError, match="station"):
Step.model_validate({"kind": "cook", "station": "deep_fryer"})
# --------------------------------------------------------------------------- Recipe
def test_recipe_happy_path_carries_documented_defaults() -> None:
recipe = Recipe.model_validate(soup_dict())
assert recipe.requires_plating is True
assert recipe.tags == []
assert [ingredient.count for ingredient in recipe.ingredients] == [1, 2]
def test_recipe_rejects_unknown_keys() -> None:
with pytest.raises(ValidationError, match=r"extra_forbidden|Extra inputs"):
Recipe.model_validate(soup_dict(garnish="parsley"))
def test_recipe_requires_ingredients_and_steps() -> None:
with pytest.raises(ValidationError, match="ingredients"):
Recipe.model_validate(soup_dict(ingredients=[]))
with pytest.raises(ValidationError, match="steps"):
Recipe.model_validate(soup_dict(steps=[]))
def test_recipe_score_and_ids_are_sane() -> None:
with pytest.raises(ValidationError):
Recipe.model_validate(soup_dict(base_score=-1))
with pytest.raises(ValidationError):
Recipe.model_validate(soup_dict(id=""))
# ------------------------------------------------------------------------ RecipeBook
def test_book_reads_keys_ids_and_the_ingredient_catalog() -> None:
book = RecipeBook.model_validate(
book_dict(ingredients=["tomato", "onion", "plate"])
)
assert book.ids() == ("soup",)
assert book.get("soup").base_score == 40
assert book.ingredients == [ItemKind.TOMATO, ItemKind.ONION, ItemKind.PLATE]
def test_book_ids_follow_declaration_order() -> None:
book = RecipeBook.model_validate(
book_dict(recipes={"salad": soup_dict(id="salad"), "soup": soup_dict()})
)
assert book.ids() == ("salad", "soup")
def test_book_key_must_equal_the_recipe_id() -> None:
with pytest.raises(ValidationError, match="keys must equal"):
RecipeBook.model_validate(book_dict(recipes={"soup": soup_dict(id="chowder")}))
def test_get_unknown_recipe_names_the_known_ones() -> None:
book = RecipeBook.model_validate(book_dict())
with pytest.raises(KeyError, match="unknown recipe 'ghost'"):
book.get("ghost")
def test_required_station_kinds_for_a_two_step_recipe() -> None:
book = RecipeBook.model_validate(book_dict())
assert book.required_station_kinds("soup") == frozenset(
{StationKind.CHOP_BOARD, StationKind.STOVE}
)
def four_step_book() -> RecipeBook:
recipe = soup_dict(
steps=[
{"kind": "chop", "station": "chop_board", "seconds": 0.9},
{"kind": "cook", "station": "stove", "seconds": 4.0},
{"kind": "plate", "station": "prep", "seconds": 1.2},
{"kind": "serve", "station": "serve"},
]
)
return RecipeBook.model_validate(book_dict(recipes={"soup": recipe}))
def test_required_station_kinds_for_a_four_step_recipe() -> None:
assert four_step_book().required_station_kinds("soup") == frozenset(
{StationKind.CHOP_BOARD, StationKind.STOVE, StationKind.PREP, StationKind.SERVE}
)
def test_required_item_kinds_adds_a_plate_only_when_plating_is_required() -> None:
book = RecipeBook.model_validate(book_dict())
assert book.required_item_kinds("soup") == frozenset(
{ItemKind.TOMATO, ItemKind.ONION, ItemKind.PLATE}
)
unplated = RecipeBook.model_validate(
book_dict(recipes={"soup": soup_dict(requires_plating=False)})
)
assert unplated.required_item_kinds("soup") == frozenset({ItemKind.TOMATO, ItemKind.ONION})
def test_min_seconds_to_complete_is_steps_plus_one_handling_allowance() -> None:
book = RecipeBook.model_validate(book_dict())
assert book.min_seconds_to_complete("soup") == pytest.approx(0.9 + 5.0 + HANDLING_S)
def test_min_seconds_grows_with_more_steps() -> None:
minutes = four_step_book().min_seconds_to_complete("soup")
assert minutes == pytest.approx(0.9 + 4.0 + 1.2 + 0.0 + HANDLING_S)
@pytest.mark.parametrize(
"method",
["required_station_kinds", "required_item_kinds", "min_seconds_to_complete"],
)
def test_solver_helpers_reject_unknown_recipes(method: str) -> None:
book = RecipeBook.model_validate(book_dict())
with pytest.raises(KeyError, match="unknown recipe"):
getattr(book, method)("ghost")
# ------------------------------------------------------------------- load_recipe_book
def test_load_recipe_book_reads_a_real_yaml_file(tmp_path: Path) -> None:
path = write_book(
tmp_path,
"""
ingredients: [tomato, onion, plate]
recipes:
soup:
id: soup
name: Tomato Soup
base_score: 40
ingredients: [{kind: tomato, form: raw}, {kind: onion, form: raw, count: 2}]
steps:
- {kind: chop, station: chop_board, seconds: 0.9}
- {kind: cook, station: stove, seconds: 5}
""",
)
book = load_recipe_book(path)
assert book.ids() == ("soup",)
assert book.ingredients == [ItemKind.TOMATO, ItemKind.ONION, ItemKind.PLATE]
def test_load_recipe_book_reports_every_field_problem(tmp_path: Path) -> None:
path = write_book(
tmp_path,
"""
recipes:
soup:
id: soup
name: Soup
base_score: not-a-number
ingredients: [{kind: durian, form: raw}]
steps: [{kind: throw, station: chop_board}]
""",
)
with pytest.raises(ContentError) as excinfo:
load_recipe_book(path)
message = str(excinfo.value)
assert "base_score" in message
assert "ingredients.0.kind" in message
assert "steps.0.kind" in message
def test_load_recipe_book_rejects_unknown_top_level_keys(tmp_path: Path) -> None:
path = write_book(tmp_path, "recipes: {}\ndesserts: {}\n")
with pytest.raises(ContentError, match="desserts"):
load_recipe_book(path)
def test_load_recipe_book_missing_file_is_a_content_error(tmp_path: Path) -> None:
with pytest.raises(ContentError, match="cannot read content file"):
load_recipe_book(tmp_path / "nope.yaml")
def test_load_recipe_book_invalid_yaml_is_a_content_error(tmp_path: Path) -> None:
path = write_book(tmp_path, "recipes: [unclosed\n")
with pytest.raises(ContentError, match="invalid YAML"):
load_recipe_book(path)
def test_load_recipe_book_rejects_a_list_document(tmp_path: Path) -> None:
path = write_book(tmp_path, "- just\n- a\n- list\n")
with pytest.raises(ContentError, match="expected a YAML mapping"):
load_recipe_book(path)
def test_empty_file_reads_as_an_empty_mapping_then_fails_the_schema(tmp_path: Path) -> None:
path = write_book(tmp_path, "")
assert read_yaml_mapping(path) == {}
with pytest.raises(ContentError, match="recipes: Field required"):
load_recipe_book(path)
# ----------------------------------------------------------------- strictness plumbing
def test_assignment_revalidates_so_late_edits_cannot_corrupt_a_model() -> None:
ingredient = Ingredient(kind=ItemKind.TOMATO, form=ItemForm.RAW)
ingredient.count = 3
assert ingredient.count == 3
with pytest.raises(ValidationError):
ingredient.count = 0
def test_describe_validation_error_flattens_loc_paths(tmp_path: Path) -> None:
with pytest.raises(ValidationError) as excinfo:
RecipeBook.model_validate({})
described = describe_validation_error(excinfo.value, "book.yaml")
assert described.startswith("book.yaml: recipes: Field required")
+215
View File
@@ -0,0 +1,215 @@
"""Regression tests for the shipped campaign content (``levels/``, task 04).
These pin what campaign 02 ships, exactly as the phase task specifies: the
recipe book (ingredients + four named dishes with PLAN §5 step pipelines and
fixed base scores), the first two levels' tunables (pool, concurrency, spawn
interval, deadline, time limit, target), the blurb floor, campaign ordering,
and the level-1 → level-2 escalation contract (≥2 axes up). They read the
real files from ``levels/`` — not a copy — so editing shipped content that
a test pins fails here, in review, instead of at a player's startup.
Startup behaviour and the HTTP surface live in ``test_content_startup.py``
and ``tests/integration/test_content_api.py``; the generic loader and
validator have their own unit suites.
"""
from __future__ import annotations
from pathlib import Path
import pytest
from undercooked.sim.content import ContentBundle, load_content
from undercooked.sim.level import Level
from undercooked.sim.recipe import RecipeBook, load_recipe_book
from undercooked.sim.validation import escalation_axes, validate_bundle
REPO_ROOT = Path(__file__).resolve().parents[2]
LEVELS_DIR = REPO_ROOT / "levels"
RECIPES_PATH = LEVELS_DIR / "recipes.yaml"
LEVEL_FILES = sorted(
path for path in LEVELS_DIR.glob("*.yaml") if path.name != "recipes.yaml"
)
#: The campaign order this task ships (PLAN §8: soup tutorial, then salad).
SHIPPED_IDS = ["01_first_kitchen", "02_double_shift"]
#: PLAN §5 station pipelines as (interaction, station) pairs:
#: pick at the shelf -> chop at the board -> cook on the stove -> plate at
#: prep -> serve at the window; a dish skips the stages its recipe lacks.
EXPECTED_STEPS = {
"soup": [
("pick", "spawn_shelf"),
("chop", "chop_board"),
("cook", "stove"),
("plate", "prep"),
("serve", "serve"),
],
"salad": [
("pick", "spawn_shelf"),
("chop", "chop_board"),
("plate", "prep"),
("serve", "serve"),
],
"fried_rice": [
("pick", "spawn_shelf"),
("cook", "stove"),
("plate", "prep"),
("serve", "serve"),
],
"burger": [
("pick", "spawn_shelf"),
("chop", "chop_board"),
("cook", "stove"),
("plate", "prep"),
("serve", "serve"),
],
}
@pytest.fixture(scope="module")
def book() -> RecipeBook:
return load_recipe_book(RECIPES_PATH)
@pytest.fixture(scope="module")
def bundle() -> ContentBundle:
return load_content(LEVELS_DIR, RECIPES_PATH)
def level_of(bundle: ContentBundle, level_id: str) -> Level:
return bundle.by_id[level_id]
# --- the recipe book ---------------------------------------------------------
def test_recipe_book_ships_the_named_ingredient_catalog(book: RecipeBook) -> None:
assert set(book.ingredients) == {
"tomato",
"onion",
"lettuce",
"dough",
"patty",
"rice",
"fish",
"plate",
}
def test_recipe_book_ships_exactly_the_four_dishes(book: RecipeBook) -> None:
assert set(book.recipes) == {"soup", "salad", "fried_rice", "burger"}
@pytest.mark.parametrize(
("recipe_id", "base_score"),
[("soup", 40), ("salad", 30), ("fried_rice", 50), ("burger", 70)],
)
def test_dishes_carry_their_pinned_base_scores(
book: RecipeBook, recipe_id: str, base_score: int
) -> None:
assert book.get(recipe_id).base_score == base_score
@pytest.mark.parametrize("recipe_id", sorted(EXPECTED_STEPS))
def test_dishes_follow_the_plan_station_pipelines(
book: RecipeBook, recipe_id: str
) -> None:
recipe = book.get(recipe_id)
assert [(step.kind, step.station.value) for step in recipe.steps] == (
EXPECTED_STEPS[recipe_id]
)
@pytest.mark.parametrize("recipe_id", sorted(EXPECTED_STEPS))
def test_every_dish_consumes_catalogued_ingredients(
book: RecipeBook, recipe_id: str
) -> None:
recipe = book.get(recipe_id)
assert recipe.ingredients, recipe_id
assert {item.kind for item in recipe.ingredients} <= set(book.ingredients)
# --- the level files ----------------------------------------------------------
def test_every_yaml_file_in_levels_is_a_shipped_level(bundle: ContentBundle) -> None:
stems = {path.stem for path in LEVEL_FILES}
assert stems, "levels/ must ship at least one level"
assert stems == set(bundle.by_id)
def test_shipped_campaign_validates_without_warnings(bundle: ContentBundle) -> None:
report = validate_bundle(bundle)
assert report.ok, report.render()
assert not report.warnings, report.render()
def test_campaign_order_is_unique_and_starts_at_one(bundle: ContentBundle) -> None:
orders = [level.order for level in bundle.levels]
assert orders == list(range(1, len(bundle.levels) + 1))
def test_campaign_ships_the_first_two_levels(bundle: ContentBundle) -> None:
assert [level.id for level in bundle.levels] == SHIPPED_IDS
@pytest.mark.parametrize("level_id", SHIPPED_IDS)
def test_each_level_has_a_readable_premise(bundle: ContentBundle, level_id: str) -> None:
blurb = level_of(bundle, level_id).blurb
assert len(blurb) >= 20
@pytest.mark.parametrize("level_id", SHIPPED_IDS)
def test_difficulty_is_within_the_declared_range(
bundle: ContentBundle, level_id: str
) -> None:
assert 1 <= level_of(bundle, level_id).difficulty <= 10
@pytest.mark.parametrize("level_id", SHIPPED_IDS)
def test_pool_recipes_exist_in_the_book(
bundle: ContentBundle, book: RecipeBook, level_id: str
) -> None:
recipe_ids = level_of(bundle, level_id).orders.pool
assert recipe_ids
for recipe_id in recipe_ids:
assert recipe_id in book.recipes
# --- the two levels' pinned tunables (task 04) --------------------------------
def test_level_one_is_the_soup_tutorial(bundle: ContentBundle) -> None:
level = level_of(bundle, "01_first_kitchen")
assert level.order == 1
assert level.difficulty == 1
assert level.time_limit == 150
assert level.target_score == 45
assert level.orders.pool == ["soup"]
assert level.orders.concurrent == 1
assert tuple(level.orders.spawn_interval) == (16, 20)
assert level.orders.deadline == 60
assert level.hazards == []
def test_level_two_adds_salad_and_pressure(bundle: ContentBundle) -> None:
level = level_of(bundle, "02_double_shift")
assert level.order == 2
assert level.difficulty == 2
assert level.time_limit == 130
assert level.target_score == 80
assert level.orders.pool == ["soup", "salad"]
assert level.orders.concurrent == 2
assert tuple(level.orders.spawn_interval) == (12, 16)
assert level.orders.deadline == 52
assert level.hazards == []
def test_level_two_escalates_at_least_two_axes(bundle: ContentBundle) -> None:
before = escalation_axes(level_of(bundle, "01_first_kitchen"))
after = escalation_axes(level_of(bundle, "02_double_shift"))
moved_up = sorted(
name for name, value in after.items() if value > before[name] + 1e-9
)
assert len(moved_up) >= 2, f"only these axes moved: {moved_up}"