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:
+6
-3
@@ -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
@@ -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
@@ -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}
|
||||
Vendored
+18
@@ -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}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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())
|
||||
@@ -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)
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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"])
|
||||
|
||||
@@ -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")
|
||||
@@ -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}"
|
||||
Reference in New Issue
Block a user