phase: 02_level_content_and_validation

Phase `02_level_content_and_validation` — final verification **green**.

- **Gate:** `bash .agents/validate.sh` → ruff ✅ · pyright 0 errors ✅ · `undercooked-levels validate` OK (2 levels) ✅ · **668 passed** (incl. 10 Playwright e2e) ✅ · coverage **100%** ✅ · repo hygiene ✅
- **Criteria:** `validate` exit 0, one line per level; `list` = `01_first_kitchen`, `02_double_shift` in campaign order; live `:8000` `/healthz` 200, `/api/levels` (+detail) carries both levels with numeric `target_score`/`time_limit` and no grid/station leakage, `/api/config` correct, unknown id → 404, startup log `content loaded: 2 level(s), 4 recipe(s)`; `validate --dir tests/fixtures/levels/_bad/` → exit 1 with named `RECIPE_STATION_MISSING` / `SERVE_REACHABLE` / `TARGET_IMPLAUSIBLE`; A8 lock test proves a YAML-only level reaches loader+API+CLI.
- **Defect fixed (pre-existing path, phase-02-triggered):** `undercooked` with unusable content printed the report then a raw traceback and exited `1`. `src/undercooked/__main__.py` now catches `ContentError` → one closing line, exit `2` (like a bad setting); 2 new tests in `tests/unit/test_main.py` (incl. non-content failures still propagate).
- **Docs pass (full, project-wide):** README (content gate + exit `2`, config section), AGENTS.md §3, `docs/LEVELS.md` (startup refusal + exact CLI exit codes), `docs/DEPLOYMENT.md` (crash-loop diagnosis bullet); DESIGN/TESTING re-read, consistent.
- **Container re-verified (no phase-01 regression):** `podman build -f Containerfile` succeeds; the image ships `levels/` and serves `/healthz` 200, `/api/levels` (2 levels), `/` 200 as non-root.
- **Deviation to note:** the phase overview names `tests/fixtures/levels/bad/`; the actual broken-fixture dir is `tests/fixtures/levels/_bad/` (the `_` prefix is what keeps it out of normal discovery). Overview files are read-only to me — README/LEVELS.md document the real path.
- No git index/history commands run; all changes left unstaged for the harness. ntfy notification sent.
This commit is contained in:
2026-10-06 11:03:28 -04:00
parent 6fd83446ec
commit 3426264ffb
48 changed files with 8094 additions and 54 deletions
+281
View File
@@ -0,0 +1,281 @@
"""Unit tests for `undercooked.sim.content` (phase 02 task 02).
The loader is the "no hard-coded content lists" rule (A8) in executable form:
a directory of YAML in, a campaign-ordered `ContentBundle` out. These tests
pin discovery (recursion, `_`-drafts, the recipe book's own name), the
`(order, id)` sort, and the promise that *every* file-level problem — bad
YAML, schema violations, unknown order-pool recipes, duplicate ids — is
reported in one `ContentError`, not one per run.
"""
from __future__ import annotations
from pathlib import Path
from typing import Any
import pytest
import yaml
from undercooked.sim.content import (
LEVEL_FILE_SUFFIXES,
ContentBundle,
ContentError,
load_content,
load_level_file,
)
from undercooked.sim.grid import GridError
from undercooked.sim.level import Level, LevelDef
from undercooked.sim.model import ItemKind
from undercooked.sim.recipe import RecipeBook
pytestmark = pytest.mark.unit
BOOK_YAML = """
ingredients: [tomato, onion]
recipes:
soup:
id: soup
name: Tomato Soup
base_score: 40
ingredients: [{kind: tomato, form: raw}, {kind: onion, form: raw}]
steps:
- {kind: chop, station: chop_board, seconds: 0.9}
- {kind: cook, station: stove, seconds: 5}
"""
def level_body(level_id: str, order: int, **overrides: Any) -> dict[str, Any]:
body: dict[str, Any] = {
"id": level_id,
"name": level_id.replace("_", " ").title(),
"order": order,
"time_limit": 150,
"target_score": 45,
"grid": ["#####", "#S..#", "#.C.#", "#..V#", "#####"],
"tiles": {
".": {"kind": "floor"},
"#": {"kind": "wall"},
"S": {"kind": "floor", "station": "spawn_shelf", "item": "tomato"},
"C": {"kind": "floor", "station": "chop_board"},
"V": {"kind": "floor", "station": "serve"},
},
"spawns": [[1, 1]],
"orders": {
"pool": ["soup"],
"concurrent": 1,
"spawn_interval": [16, 20],
"deadline": 60,
},
}
body.update(overrides)
return body
def write_yaml(directory: Path, name: str, body: dict[str, Any]) -> Path:
path = directory / name
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(yaml.safe_dump(body, sort_keys=False), encoding="utf-8")
return path
def make_content_dir(tmp_path: Path, *levels: tuple[str, int]) -> tuple[Path, Path]:
"""A content dir with a recipe book and the named (filename, order) levels."""
levels_dir = tmp_path / "levels"
levels_dir.mkdir()
recipes_path = levels_dir / "recipes.yaml"
recipes_path.write_text(BOOK_YAML, encoding="utf-8")
for name, order in levels:
write_yaml(levels_dir, name, level_body(Path(name).stem, order))
return levels_dir, recipes_path
# ---------------------------------------------------------------------------- happy path
def test_loads_the_book_and_every_level_file(tmp_path: Path) -> None:
levels_dir, recipes_path = make_content_dir(tmp_path, ("01_a.yaml", 1), ("02_b.yaml", 2))
bundle = load_content(levels_dir, recipes_path)
assert bundle.recipe_book.ids() == ("soup",)
assert [level.id for level in bundle.levels] == ["01_a", "02_b"]
assert set(bundle.by_id) == {"01_a", "02_b"}
assert bundle.by_id["02_b"].order == 2
def test_levels_order_by_order_then_id_not_by_filename(tmp_path: Path) -> None:
levels_dir, recipes_path = make_content_dir(
tmp_path, ("zzz_first.yaml", 1), ("01_second.yaml", 2), ("aaa_tie_b.yaml", 1)
)
write_yaml(levels_dir, "aab_tie.yaml", level_body("aab_tie", 1))
bundle = load_content(levels_dir, recipes_path)
assert [level.id for level in bundle.levels] == [
"aaa_tie_b",
"aab_tie",
"zzz_first",
"01_second",
]
def test_an_empty_levels_dir_is_a_legal_empty_campaign(tmp_path: Path) -> None:
levels_dir, recipes_path = make_content_dir(tmp_path)
bundle = load_content(levels_dir, recipes_path)
assert bundle.levels == ()
assert dict(bundle.by_id) == {}
def test_paths_may_be_plain_strings(tmp_path: Path) -> None:
levels_dir, recipes_path = make_content_dir(tmp_path, ("01_a.yaml", 1))
bundle = load_content(str(levels_dir), str(recipes_path))
assert [level.id for level in bundle.levels] == ["01_a"]
# --------------------------------------------------------------------------- discovery
def test_discovery_recurses_into_subdirectories(tmp_path: Path) -> None:
levels_dir, recipes_path = make_content_dir(tmp_path, ("01_a.yaml", 1))
write_yaml(levels_dir / "campaign" / "deep", "02_b.yaml", level_body("02_b", 2))
bundle = load_content(levels_dir, recipes_path)
assert [level.id for level in bundle.levels] == ["01_a", "02_b"]
def test_underscore_prefixed_files_are_ignored_even_when_broken(tmp_path: Path) -> None:
levels_dir, recipes_path = make_content_dir(tmp_path, ("01_a.yaml", 1))
(levels_dir / "_draft.yaml").write_text("grid: [unclosed\n", encoding="utf-8")
(levels_dir / "_wip").mkdir()
write_yaml(levels_dir / "_wip", "scratch.yaml", {"id": "nothing"})
bundle = load_content(levels_dir, recipes_path)
assert [level.id for level in bundle.levels] == ["01_a"]
def test_recipes_yaml_in_the_levels_dir_is_not_a_level(tmp_path: Path) -> None:
levels_dir, recipes_path = make_content_dir(tmp_path)
# recipes.yaml itself would not validate as a level; loading must not even try.
bundle = load_content(levels_dir, recipes_path)
assert bundle.levels == ()
def test_non_yaml_files_are_ignored(tmp_path: Path) -> None:
levels_dir, recipes_path = make_content_dir(tmp_path, ("01_a.yaml", 1))
(levels_dir / "notes.md").write_text("# ideas\n- more soup\n", encoding="utf-8")
(levels_dir / "level.json").write_text("{not yaml at all", encoding="utf-8")
bundle = load_content(levels_dir, recipes_path)
assert [level.id for level in bundle.levels] == ["01_a"]
def test_suffix_set_covers_yaml_and_yml(tmp_path: Path) -> None:
assert set(LEVEL_FILE_SUFFIXES) == {".yaml", ".yml"}
levels_dir, recipes_path = make_content_dir(tmp_path)
(levels_dir / "02_b.yml").write_text(
yaml.safe_dump(level_body("02_b", 2), sort_keys=False), encoding="utf-8"
)
bundle = load_content(levels_dir, recipes_path)
assert [level.id for level in bundle.levels] == ["02_b"]
# ---------------------------------------------------------------- error aggregation
def test_unknown_order_pool_recipe_is_a_load_error(tmp_path: Path) -> None:
levels_dir, recipes_path = make_content_dir(tmp_path)
write_yaml(levels_dir, "01_a.yaml", level_body("01_a", 1, orders={
"pool": ["soup", "ghost"],
"concurrent": 1,
"spawn_interval": [16, 20],
"deadline": 60,
}))
with pytest.raises(ContentError, match="unknown recipes: ghost"):
load_content(levels_dir, recipes_path)
def test_duplicate_level_ids_are_refused(tmp_path: Path) -> None:
levels_dir, recipes_path = make_content_dir(tmp_path, ("01_a.yaml", 1))
write_yaml(levels_dir, "90_copy.yaml", level_body("01_a", 5))
with pytest.raises(ContentError, match="duplicate level ids: 01_a"):
load_content(levels_dir, recipes_path)
def test_missing_levels_directory_is_reported(tmp_path: Path) -> None:
_, recipes_path = make_content_dir(tmp_path)
with pytest.raises(ContentError, match="does not exist"):
load_content(tmp_path / "nowhere", recipes_path)
def test_missing_recipe_book_is_reported(tmp_path: Path) -> None:
levels_dir, _ = make_content_dir(tmp_path)
with pytest.raises(ContentError, match="cannot read content file"):
load_content(levels_dir, tmp_path / "no" / "recipes.yaml")
def test_all_file_errors_survive_to_one_content_error(tmp_path: Path) -> None:
levels_dir, recipes_path = make_content_dir(tmp_path)
(levels_dir / "bad_one.yaml").write_text("grid: [unclosed\n", encoding="utf-8")
write_yaml(levels_dir, "bad_two.yaml", {"id": "x"}) # mostly-missing schema
write_yaml(levels_dir, "bad_three.yaml", level_body("ghost", 1, orders={
"pool": ["phantom"],
"concurrent": 1,
"spawn_interval": [16, 20],
"deadline": 60,
}))
with pytest.raises(ContentError) as excinfo:
load_content(levels_dir, recipes_path)
message = str(excinfo.value)
assert "bad_one.yaml" in message
assert "invalid YAML" in message
assert "bad_two.yaml" in message
assert "Field required" in message
assert "bad_three.yaml" in message
assert "phantom" in message
# every broken file opens its own issue block
assert f"{levels_dir / 'bad_one.yaml'}: " in message
assert f"{levels_dir / 'bad_two.yaml'}: " in message
assert f"{levels_dir / 'bad_three.yaml'}: " in message
# ------------------------------------------------------------------ load_level_file
def test_load_level_file_returns_a_built_level(tmp_path: Path) -> None:
path = write_yaml(tmp_path, "01_a.yaml", level_body("01_a", 1))
level = load_level_file(path)
assert isinstance(level, Level)
assert level.station_items == {"spawn_shelf_0": ItemKind.TOMATO}
assert level.grid.width == 5
def test_load_level_file_names_the_file_on_schema_errors(tmp_path: Path) -> None:
path = write_yaml(tmp_path, "broken.yaml", level_body("x", 1, grid=["###", "#..#"]))
with pytest.raises(ContentError, match=r"broken.yaml.*ragged grid"):
load_level_file(path)
def test_load_level_file_wraps_geometry_failures(tmp_path: Path, monkeypatch: Any) -> None:
def explode(_spec: Any) -> Level:
raise GridError("planted geometry failure")
monkeypatch.setattr(Level, "build", staticmethod(explode))
path = write_yaml(tmp_path, "01_a.yaml", level_body("01_a", 1))
with pytest.raises(ContentError, match=r"01_a\.yaml: planted geometry failure"):
load_level_file(path)
# --------------------------------------------------------------------- ContentBundle
def empty_book() -> RecipeBook:
return RecipeBook.model_validate({"recipes": {}})
def built_level(level_id: str, order: int) -> Level:
return Level.build(LevelDef.model_validate(level_body(level_id, order)))
def test_bundle_by_id_is_derived_and_can_be_read_back() -> None:
bundle = ContentBundle(recipe_book=empty_book(), levels=(built_level("a", 2),))
assert list(bundle.by_id) == ["a"]
assert bundle.by_id["a"].order == 2
def test_a_hand_built_bundle_cannot_smuggle_duplicate_ids() -> None:
levels = (built_level("a", 1), built_level("a", 2))
with pytest.raises(ContentError, match="duplicate level ids: a"):
ContentBundle(recipe_book=empty_book(), levels=levels)
+234
View File
@@ -0,0 +1,234 @@
"""A8, enforced: the campaign is YAML on disk and never a list in Python.
Phase 02 task 05's regression lock. The loader (`sim/content.py`), the content
API (`server/routes_content.py`) and the author CLI (`tools/levels.py`) all
discover levels by *reading a directory*, so a new `.yaml` file is the only
thing an author writes to ship a level. This test proves that twice over:
* **addition without code** — copy `levels/` to `tmp_path`, drop in
`90_zerocool.yaml` with `order: 99`, and the loader, `/api/levels` and
`undercooked-levels list`/`show`/`validate` all carry it, while the id
appears in no Python file at all (and deleting the file removes it again);
* **absence of literals** — no file under `src/undercooked/` (and no browser
asset) contains a shipped level id or recipe id as a literal, so a hard-coded
list cannot be quietly re-added later.
The session fixture points `UNDERCOOKED_LEVELS_DIR` at the empty
`tests/fixtures/levels/` directory, so every path here is set explicitly.
"""
from __future__ import annotations
import shutil
from collections.abc import Mapping
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from undercooked.config import load_settings
from undercooked.server.app import create_app
from undercooked.server.routes_content import RECIPES_FILENAME
from undercooked.sim.content import load_content
from undercooked.sim.recipe import load_recipe_book
from undercooked.tools.levels import EXIT_OK, EXIT_UNUSABLE, main
pytestmark = pytest.mark.unit
REPO_ROOT = Path(__file__).resolve().parents[2]
SHIPPED_LEVELS = REPO_ROOT / "levels"
SHIPPED_RECIPES = SHIPPED_LEVELS / RECIPES_FILENAME
SOURCE_ROOT = REPO_ROOT / "src" / "undercooked"
STATIC_ROOT = REPO_ROOT / "app" / "static"
def shipped_level_ids() -> set[str]:
"""Every id the shipped `levels/` directory carries (the book is not a level)."""
return {
path.stem
for path in SHIPPED_LEVELS.glob("*.yaml")
if path.name not in {RECIPES_FILENAME, "recipes.yml"}
}
#: The level this test adds. `order: 99` puts it last in the campaign whatever
#: the shipped levels do, and three dishes with three orders live at once move
#: the escalation axes the campaign rule (`DIFFICULTY_ORDER`) asks about.
NEW_LEVEL_ID = "90_zerocool"
NEW_LEVEL_YAML = """\
id: 90_zerocool
name: Zero Cool
order: 99
difficulty: 3
time_limit: 120
target_score: 110
blurb: >-
A manifest probe, not a campaign level: three dishes, three orders live at
once, and the same pinched floor as the second kitchen.
grid:
- "#############"
- "#T....C....O#"
- "#.....S.....#"
- "####=...=####"
- "#....P.X....#"
- "#...........#"
- "####=...=####"
- "#R.K.....L..#"
- "#############"
tiles:
"#": {kind: wall}
".": {kind: floor}
"=": {kind: counter}
"T": {kind: floor, station: spawn_shelf, item: tomato}
"O": {kind: floor, station: spawn_shelf, item: onion}
"L": {kind: floor, station: spawn_shelf, item: lettuce}
"R": {kind: floor, station: spawn_shelf, item: rice}
"C": {kind: floor, station: chop_board}
"S": {kind: floor, station: stove}
"P": {kind: floor, station: prep}
"X": {kind: floor, station: serve}
"K": {kind: floor, station: sink}
spawns: [[2, 2], [10, 5]]
orders:
pool: [soup, salad, fried_rice]
concurrent: 3
spawn_interval: [10, 14]
deadline: 45
hazards: []
escalation_notes: >-
Moves recipe_complexity, order_pressure and time_budget up versus the last
shipped level, which is what the campaign rule asks for.
"""
def copy_shipped_levels(destination: Path) -> Path:
"""`levels/` copied verbatim — making a level here edits no Python."""
shutil.copytree(SHIPPED_LEVELS, destination)
return destination
def text_files(root: Path, suffixes: tuple[str, ...]) -> Mapping[str, str]:
"""Every file under `root` with one of `suffixes`, keyed by path."""
return {
str(path): path.read_text(encoding="utf-8")
for path in sorted(root.rglob("*"))
if path.is_file()
and path.suffix in suffixes
and "__pycache__" not in path.parts
}
def offenders(literals: set[str], texts: Mapping[str, str]) -> dict[str, list[str]]:
"""Which literals appear in which file — empty means the scan passed."""
return {
path: sorted(literal for literal in literals if literal in text)
for path, text in texts.items()
if any(literal in text for literal in literals)
}
@pytest.fixture()
def extended_campaign(tmp_path: Path) -> Path:
"""A copy of the shipped campaign plus one level nothing ever registered."""
campaign = copy_shipped_levels(tmp_path / "campaign")
(campaign / f"{NEW_LEVEL_ID}.yaml").write_text(NEW_LEVEL_YAML, encoding="utf-8")
return campaign
# --- a file is the only thing an author writes ----------------------------------
def test_a_new_yaml_file_reaches_the_loader_the_api_and_the_cli_without_code(
extended_campaign: Path,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
shipped_ids = [level.id for level in load_content(SHIPPED_LEVELS, SHIPPED_RECIPES).levels]
# 1. the pure loader
bundle = load_content(extended_campaign, extended_campaign / RECIPES_FILENAME)
assert [level.id for level in bundle.levels] == [*shipped_ids, NEW_LEVEL_ID]
assert list(bundle.by_id) == [*shipped_ids, NEW_LEVEL_ID]
assert bundle.by_id[NEW_LEVEL_ID].order == 99
# 2. the HTTP content API, addressed only through the environment
monkeypatch.setenv("UNDERCOOKED_LEVELS_DIR", str(extended_campaign))
monkeypatch.delenv("UNDERCOOKED_RECIPES_PATH", raising=False)
with TestClient(create_app(load_settings())) as client:
listing = client.get("/api/levels")
assert listing.status_code == 200
assert [level["id"] for level in listing.json()] == [*shipped_ids, NEW_LEVEL_ID]
detail = client.get(f"/api/levels/{NEW_LEVEL_ID}")
assert detail.status_code == 200
assert detail.json()["order"] == 99
assert detail.json()["difficulty"] == 3
# 3. the author CLI, on the same environment and no `--dir`
assert main(["list"]) == EXIT_OK
assert capsys.readouterr().out.splitlines()[-1].split()[1] == NEW_LEVEL_ID
assert main(["show", NEW_LEVEL_ID, "--grid"]) == EXIT_OK
shown = capsys.readouterr().out
assert shown.startswith(f"{NEW_LEVEL_ID} - Zero Cool")
assert "concurrent=3" in shown
# 4. and the gate accepts it: a row in a table that breaks PLAN §8 is not a level
assert main(["validate"]) == EXIT_OK
assert NEW_LEVEL_ID in capsys.readouterr().out
# The proof of the whole point: the new level is everywhere except the code.
assert offenders({NEW_LEVEL_ID}, text_files(SOURCE_ROOT, (".py", ".pyi"))) == {}
def test_the_copied_campaign_on_its_own_is_still_the_shipped_two(tmp_path: Path) -> None:
"""The copy is faithful, so the test above measures exactly one added file."""
pristine = copy_shipped_levels(tmp_path / "pristine")
copied = load_content(pristine, pristine / RECIPES_FILENAME)
shipped = load_content(SHIPPED_LEVELS, SHIPPED_RECIPES)
assert [level.id for level in copied.levels] == [level.id for level in shipped.levels]
def test_a_deletion_is_as_invisible_as_an_addition(
extended_campaign: Path, capsys: pytest.CaptureFixture[str]
) -> None:
"""Nothing caches the campaign in Python either: remove the file, it is gone."""
(extended_campaign / f"{NEW_LEVEL_ID}.yaml").unlink()
assert main(["list", "--dir", str(extended_campaign)]) == EXIT_OK
assert NEW_LEVEL_ID not in capsys.readouterr().out
assert main(["show", NEW_LEVEL_ID, "--dir", str(extended_campaign)]) == EXIT_UNUSABLE
assert NEW_LEVEL_ID not in load_content(
extended_campaign, extended_campaign / RECIPES_FILENAME
).by_id
# --- no literals in the source --------------------------------------------------
def test_no_python_source_names_a_shipped_level_id() -> None:
"""`levels/*.yaml` owns level ids: nothing in `src/undercooked/` repeats one."""
shipped = load_content(SHIPPED_LEVELS, SHIPPED_RECIPES)
literals = shipped_level_ids() | {level.id for level in shipped.levels}
assert literals # a scan of an empty set would pass and prove nothing
assert offenders(literals, text_files(SOURCE_ROOT, (".py", ".pyi"))) == {}
def test_no_python_source_names_a_recipe_from_the_book() -> None:
"""A8 covers recipes too: `recipes.yaml` is the only list of dishes."""
literals = set(load_recipe_book(SHIPPED_RECIPES).ids())
assert literals
assert offenders(literals, text_files(SOURCE_ROOT, (".py", ".pyi"))) == {}
def test_no_browser_asset_names_a_shipped_level_id() -> None:
"""The client asks the API for the campaign; it does not carry one."""
literals = shipped_level_ids()
assets = text_files(STATIC_ROOT, (".js", ".css", ".html", ".svg"))
assert assets
assert offenders(literals, assets) == {}
def test_the_loader_discovers_levels_by_walking_the_directory() -> None:
"""The discovery mechanism behind every test above: a directory walk."""
loader = (SOURCE_ROOT / "sim" / "content.py").read_text(encoding="utf-8")
assert "rglob" in loader
assert "iterdir" not in loader # one walk, at any depth, not a flat listing
+186
View File
@@ -0,0 +1,186 @@
"""Startup content gate (task 04): broken content refuses to start the app.
``create_app`` loads and validates the campaign once through
``load_validated_content``. These tests drive that function and the factory
directly: the shipped content loads; a level whose serve window is sealed
into a wall pocket raises ``ContentError`` and prints the named-rule report
on stderr; a missing recipe book does the same; and a *warning* band target
score logs a warning without refusing startup.
"""
from __future__ import annotations
import logging
import shutil
import textwrap
from pathlib import Path
import pytest
from undercooked.config import Settings
from undercooked.server.app import create_app
from undercooked.server.routes_content import (
RECIPES_FILENAME,
load_validated_content,
resolve_recipes_path,
)
from undercooked.sim.recipe import ContentError
REPO_ROOT = Path(__file__).resolve().parents[2]
SHIPPED_LEVELS = REPO_ROOT / "levels"
def make_settings(levels_dir: Path) -> Settings:
return Settings(levels_dir=levels_dir, recipes_path=levels_dir / RECIPES_FILENAME)
# --- path resolution ----------------------------------------------------------
def test_recipes_path_defaults_inside_the_levels_dir() -> None:
settings = Settings(levels_dir=Path("/content/campaign"))
assert settings.recipes_path is None # the shipped default is "derive it"
assert resolve_recipes_path(settings) == Path("/content/campaign/recipes.yaml")
def test_recipes_path_override_wins() -> None:
settings = Settings(
levels_dir=Path("/content/campaign"), recipes_path=Path("/other/book.yaml")
)
assert resolve_recipes_path(settings) == Path("/other/book.yaml")
# --- the happy path ------------------------------------------------------------
def test_startup_loads_the_shipped_bundle() -> None:
application = create_app(Settings(levels_dir=SHIPPED_LEVELS))
loaded = application.state.content
assert [level.id for level in loaded.levels] == [
"01_first_kitchen",
"02_double_shift",
]
assert "burger" in loaded.recipe_book.recipes
# --- broken content refuses startup ---------------------------------------------
def copy_shipped_content(tmp_path: Path) -> Path:
"""A writable copy of the shipped campaign the tests may vandalise."""
target = tmp_path / "levels"
shutil.copytree(SHIPPED_LEVELS, target)
return target
def test_missing_recipe_book_refuses_startup_with_a_stderr_report(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
capsys.readouterr() # discard anything earlier tests printed
with pytest.raises(ContentError, match=r"recipes\.yaml"):
load_validated_content(make_settings(tmp_path))
err = capsys.readouterr().err
assert "content failed to load" in err
assert "recipes.yaml" in err
def test_unparsable_level_file_refuses_startup(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
levels = copy_shipped_content(tmp_path)
(levels / "03_broken.yaml").write_text("\tthis: [is not", encoding="utf-8")
capsys.readouterr()
with pytest.raises(ContentError, match=r"03_broken\.yaml"):
create_app(make_settings(levels))
assert "03_broken.yaml" in capsys.readouterr().err
def test_sealed_serve_window_refuses_startup_with_named_rule_report(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
levels = copy_shipped_content(tmp_path)
path = levels / "02_double_shift.yaml"
text = path.read_text(encoding="utf-8")
vandalism = text.replace('- "####=...=####"', '- "####=..#=####"', 1)
vandalism = vandalism.replace('- "#....P.X....#"', '- "#....P#X#...#"')
vandalism = vandalism.replace('- "#...........#"', '- "#......#....#"')
assert vandalism != text, "the grid rows this test vandalises changed"
path.write_text(vandalism, encoding="utf-8")
capsys.readouterr()
with pytest.raises(ContentError, match="SERVE_REACHABLE"):
create_app(make_settings(levels))
err = capsys.readouterr().err
assert "SERVE_REACHABLE" in err
assert "02_double_shift" in err
# --- warnings do not refuse startup ----------------------------------------------
WARN_BAND_CONTENT = """\
id: probe_level
name: Probe
order: 1
difficulty: 1
time_limit: 30
target_score: 40
blurb: A synthetic level that lands just inside the target-score warning band.
grid:
- "#######"
- "#T....#"
- "#.....#"
- "#....X#"
- "#######"
tiles:
"#": {kind: wall}
".": {kind: floor}
"T": {kind: floor, station: spawn_shelf, item: tomato}
"X": {kind: floor, station: serve}
spawns: [[1, 1]]
orders:
pool: [probe]
concurrent: 1
spawn_interval: [10, 14]
deadline: 40
"""
WARN_BAND_RECIPES = """\
recipes:
probe:
id: probe
name: Probe Dish
base_score: 10
ingredients:
- {kind: tomato, form: raw}
steps:
- {kind: pick, station: spawn_shelf}
- {kind: serve, station: serve}
"""
def write_warn_band_content(tmp_path: Path) -> Path:
(tmp_path / RECIPES_FILENAME).write_text(
textwrap.dedent(WARN_BAND_RECIPES), encoding="utf-8"
)
(tmp_path / "probe_level.yaml").write_text(
textwrap.dedent(WARN_BAND_CONTENT), encoding="utf-8"
)
return tmp_path
def test_warn_band_target_starts_but_logs_the_warning(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
levels = write_warn_band_content(tmp_path)
with caplog.at_level(logging.WARNING, logger="undercooked.server.routes_content"):
bundle = load_validated_content(make_settings(levels))
assert [level.id for level in bundle.levels] == ["probe_level"]
messages = [record.getMessage() for record in caplog.records]
assert any("TARGET_IMPLAUSIBLE" in message for message in messages), messages
def test_warn_band_level_still_builds_an_app(tmp_path: Path) -> None:
levels = write_warn_band_content(tmp_path)
application = create_app(make_settings(levels))
assert application.state.content.by_id["probe_level"].target_score == 40
+7 -2
View File
@@ -204,9 +204,14 @@ def test_no_doc_file_exists_that_nothing_promises() -> None:
)
def test_the_docs_on_disk_are_the_three_phase_01_documents() -> None:
def test_the_docs_on_disk_are_the_shipped_documents() -> None:
"""Pins today's tree so that adding a document is a deliberate, named act."""
assert written_docs() == {"docs/DESIGN.md", "docs/DEPLOYMENT.md", "docs/TESTING.md"}
assert written_docs() == {
"docs/DESIGN.md",
"docs/DEPLOYMENT.md",
"docs/LEVELS.md",
"docs/TESTING.md",
}
def stub_without_owner(relative: str, text: str) -> str | None:
+6 -1
View File
@@ -50,7 +50,12 @@ def test_fixture_levels_dir_exists(isolated_levels_dir: None) -> None:
levels_dir = Path(os.environ["UNDERCOOKED_LEVELS_DIR"])
assert levels_dir == REPO_ROOT / "tests" / "fixtures" / "levels"
assert levels_dir.is_dir()
assert not list(levels_dir.glob("*.yaml"))
# Hermeticity is about *levels*: a default test must never see the shipped
# campaign. The committed `recipes.yaml` is not one (the loader never treats
# it as a level) — startup has required a recipe book since phase 02 task 04,
# and this minimal one is what lets fixture-isolated apps boot at all.
levels = [p.name for p in levels_dir.glob("*.yaml") if p.name != "recipes.yaml"]
assert not levels
def test_e2e_tests_follow_the_story_naming_convention() -> None:
+440
View File
@@ -0,0 +1,440 @@
"""Unit tests for `undercooked.sim.level` (phase 02 task 02).
The level schema is what makes a YAML file *well-formed* — rectangular grid,
complete legend both directions, spawns on the map, exactly one serve, ordered
spawn intervals, placed hazards — and `build_grid`/`Level.build` are the
deterministic geometry those rules guarantee. Each validator branch gets a
failing document; the happy path asserts the documented defaults and station
id conventions from `docs/LEVELS.md`.
"""
from __future__ import annotations
from typing import Any
import pytest
from pydantic import ValidationError
from undercooked.sim.grid import MAX_GRID, Grid
from undercooked.sim.level import (
HazardSpec,
Level,
LevelDef,
OrdersSpec,
StationLimits,
TileSpec,
build_grid,
build_station_items,
build_stations,
)
from undercooked.sim.model import HazardKind, ItemKind, StationKind, TileKind
pytestmark = pytest.mark.unit
TILES: dict[str, Any] = {
".": {"kind": "floor"},
"#": {"kind": "wall"},
"S": {"kind": "floor", "station": "spawn_shelf", "item": "tomato"},
"C": {"kind": "floor", "station": "chop_board"},
"V": {"kind": "floor", "station": "serve"},
}
GRID = ["#####", "#S..#", "#.C.#", "#..V#", "#####"]
def level_dict(**overrides: Any) -> dict[str, Any]:
"""A minimal valid level body; overrides replace top-level fields."""
body: dict[str, Any] = {
"id": "01_first_kitchen",
"name": "First Kitchen",
"order": 1,
"time_limit": 150,
"target_score": 45,
"grid": list(GRID),
"tiles": {key: dict(value) for key, value in TILES.items()},
"spawns": [[1, 1]],
"orders": {
"pool": ["soup"],
"concurrent": 1,
"spawn_interval": [16, 20],
"deadline": 60,
},
}
body.update(overrides)
return body
def make_level(**overrides: Any) -> LevelDef:
return LevelDef.model_validate(level_dict(**overrides))
# ------------------------------------------------------------------------- TileSpec
def test_plain_tiles_default_to_no_station_no_item() -> None:
spec = TileSpec.model_validate({"kind": "floor"})
assert spec.kind is TileKind.FLOOR
assert spec.station is None
assert spec.item is None
assert spec.id_prefix is None
def test_spawn_shelf_must_name_the_item_it_spawns() -> None:
with pytest.raises(ValidationError, match="must name the item"):
TileSpec.model_validate({"kind": "floor", "station": "spawn_shelf"})
def test_only_a_spawn_shelf_may_carry_an_item() -> None:
with pytest.raises(ValidationError, match="only a spawn_shelf"):
TileSpec.model_validate({"kind": "floor", "station": "chop_board", "item": "tomato"})
def test_item_without_any_station_is_meaningless() -> None:
with pytest.raises(ValidationError, match="only a spawn_shelf"):
TileSpec.model_validate({"kind": "floor", "item": "tomato"})
def test_id_prefix_belongs_to_a_station_tile() -> None:
with pytest.raises(ValidationError, match="id_prefix belongs to a station"):
TileSpec.model_validate({"kind": "wall", "id_prefix": "north"})
def test_id_prefix_must_not_be_empty() -> None:
with pytest.raises(ValidationError):
TileSpec.model_validate({"kind": "floor", "station": "stove", "id_prefix": ""})
def test_tilespec_rejects_unknown_keys_and_values() -> None:
with pytest.raises(ValidationError):
TileSpec.model_validate({"kind": "floor", "glow": True})
with pytest.raises(ValidationError):
TileSpec.model_validate({"kind": "swamp"})
# ------------------------------------------------------------------------ OrdersSpec
def test_orders_defaults() -> None:
orders = OrdersSpec.model_validate(
{"pool": ["soup"], "concurrent": 1, "spawn_interval": [7, 11], "deadline": 42}
)
assert orders.penalty == 10
assert orders.spawn_interval == (7.0, 11.0)
def test_spawn_interval_min_must_not_exceed_max() -> None:
with pytest.raises(ValidationError, match="greater than its end"):
OrdersSpec.model_validate(
{"pool": ["soup"], "concurrent": 1, "spawn_interval": [12, 8], "deadline": 42}
)
def test_spawn_interval_must_start_above_zero() -> None:
with pytest.raises(ValidationError, match="above 0"):
OrdersSpec.model_validate(
{"pool": ["soup"], "concurrent": 1, "spawn_interval": [0, 8], "deadline": 42}
)
def test_orders_pool_needs_recipes_and_sane_numbers() -> None:
with pytest.raises(ValidationError, match="pool"):
OrdersSpec.model_validate(
{"pool": [], "concurrent": 1, "spawn_interval": [7, 11], "deadline": 42}
)
with pytest.raises(ValidationError, match="concurrent"):
OrdersSpec.model_validate(
{"pool": ["soup"], "concurrent": 0, "spawn_interval": [7, 11], "deadline": 42}
)
with pytest.raises(ValidationError, match="deadline"):
OrdersSpec.model_validate(
{"pool": ["soup"], "concurrent": 1, "spawn_interval": [7, 11], "deadline": 0}
)
with pytest.raises(ValidationError, match="penalty"):
OrdersSpec.model_validate(
{"pool": ["soup"], "concurrent": 1, "spawn_interval": [7, 11], "deadline": 42,
"penalty": -1}
)
# ------------------------------------------------------------------------ HazardSpec
def hazard_dict(**overrides: Any) -> dict[str, Any]:
body: dict[str, Any] = {"type": "spill", "x": 3, "y": 4}
body.update(overrides)
return body
def test_hazard_defaults() -> None:
hazard = HazardSpec.model_validate(hazard_dict())
assert hazard.type is HazardKind.SPILL
assert hazard.chance_per_s is None
assert hazard.respawn_s is None
assert hazard.path is None
assert hazard.intensity == 1.0
def test_fire_may_attach_to_a_station() -> None:
hazard = HazardSpec.model_validate({"type": "fire", "station": "stove_2"})
assert hazard.station == "stove_2"
def test_fire_needs_a_station_or_coordinates() -> None:
with pytest.raises(ValidationError, match="fire hazard needs a station or x/y"):
HazardSpec.model_validate({"type": "fire"})
def test_spill_needs_a_location_too() -> None:
with pytest.raises(ValidationError, match="spill hazard needs a station or x/y"):
HazardSpec.model_validate({"type": "spill"})
def test_station_and_coordinates_are_exclusive() -> None:
with pytest.raises(ValidationError, match="not both"):
HazardSpec.model_validate({"type": "fire", "station": "stove_0", "x": 2, "y": 3})
def test_coordinates_come_as_a_pair() -> None:
with pytest.raises(ValidationError, match="given together"):
HazardSpec.model_validate({"type": "spill", "x": 2})
with pytest.raises(ValidationError, match="given together"):
HazardSpec.model_validate({"type": "spill", "y": 3})
def test_moving_obstacle_needs_a_route_and_a_period() -> None:
with pytest.raises(ValidationError, match="path of at least 2 points"):
HazardSpec.model_validate({"type": "moving_obstacle", "period_s": 9})
with pytest.raises(ValidationError, match="path of at least 2 points"):
HazardSpec.model_validate(
{"type": "moving_obstacle", "path": [[4, 3]], "period_s": 9}
)
with pytest.raises(ValidationError, match="needs period_s"):
HazardSpec.model_validate({"type": "moving_obstacle", "path": [[4, 3], [10, 3]]})
def test_moving_obstacle_happy_path_needs_no_placement() -> None:
hazard = HazardSpec.model_validate(
{"type": "moving_obstacle", "path": [[4, 3], [10, 3]], "period_s": 9}
)
assert hazard.path == [(4, 3), (10, 3)]
assert hazard.period_s == 9.0
def test_rush_is_global_but_needs_positive_intensity() -> None:
assert HazardSpec.model_validate({"type": "rush"}).intensity == 1.0
with pytest.raises(ValidationError, match="positive intensity"):
HazardSpec.model_validate({"type": "rush", "intensity": 0})
def test_hazard_numeric_bounds() -> None:
with pytest.raises(ValidationError, match="chance_per_s"):
HazardSpec.model_validate(hazard_dict(chance_per_s=-0.01))
with pytest.raises(ValidationError, match="respawn_s"):
HazardSpec.model_validate(hazard_dict(respawn_s=0))
# -------------------------------------------------------------------------- LevelDef
def test_level_defaults_match_the_documented_values() -> None:
level = make_level()
assert level.hazards == []
assert level.station_limits.plates == 99
assert level.station_limits.pots == 99
assert level.difficulty == 1
assert level.blurb == ""
assert level.escalation_notes == ""
def test_ragged_grid_is_refused() -> None:
with pytest.raises(ValidationError, match="ragged grid: row 4 is 4 chars, want 5"):
make_level(grid=["#####", "#S..#", "#.C.#", "#..V#", "####"])
def test_empty_rows_are_refused() -> None:
with pytest.raises(ValidationError, match="rows must not be empty"):
make_level(grid=["", ""], tiles={".": {"kind": "floor"}, "V": {"kind": "floor",
"station": "serve"}}, spawns=[])
def test_grid_larger_than_the_engine_supports_is_refused() -> None:
wide = "#" * (MAX_GRID + 1)
with pytest.raises(ValidationError, match=f"at most {MAX_GRID}x{MAX_GRID}"):
make_level(grid=[wide, wide])
def test_grid_character_without_a_legend_entry_is_refused() -> None:
with pytest.raises(ValidationError, match="grid character 'Z' has no tiles"):
make_level(grid=["#####", "#Z..#", "#.C.#", "#..V#", "#####"])
def test_unused_legend_character_is_refused() -> None:
with pytest.raises(ValidationError, match="legend character 'X' is never used"):
make_level(tiles={**TILES, "X": {"kind": "wall"}})
def test_level_needs_at_least_one_spawn() -> None:
with pytest.raises(ValidationError, match="at least one spawn"):
make_level(spawns=[])
def test_spawns_must_be_inside_the_grid() -> None:
with pytest.raises(ValidationError, match=r"spawn \(9,9\) is outside the 5x5 grid"):
make_level(spawns=[[9, 9]])
with pytest.raises(ValidationError, match=r"spawn \(-1,2\) is outside"):
make_level(spawns=[[-1, 2]])
def test_level_needs_exactly_one_serve_legend() -> None:
no_serve = {key: dict(value) for key, value in TILES.items() if key != "V"}
with pytest.raises(ValidationError, match="found 0"):
make_level(grid=["#####", "#S..#", "#.C.#", "#...#", "#####"], tiles=no_serve)
with pytest.raises(ValidationError, match="found 2"):
make_level(
grid=["#####", "#S..#", "#.C.#", "#V.U#", "#####"],
tiles={**TILES, "U": {"kind": "floor", "station": "serve"}},
)
def test_level_numbers_are_validated() -> None:
with pytest.raises(ValidationError, match="difficulty"):
make_level(difficulty=11)
with pytest.raises(ValidationError, match="difficulty"):
make_level(difficulty=0)
with pytest.raises(ValidationError, match="order"):
make_level(order=0)
with pytest.raises(ValidationError, match="time_limit"):
make_level(time_limit=0)
with pytest.raises(ValidationError, match="target_score"):
make_level(target_score=0)
def test_level_rejects_unknown_keys() -> None:
with pytest.raises(ValidationError):
make_level(weather="rain")
def test_level_requires_a_grid_and_a_legend() -> None:
with pytest.raises(ValidationError, match="grid"):
make_level(grid=[])
with pytest.raises(ValidationError, match="tiles"):
make_level(tiles={})
def test_hazards_and_station_limits_round_trip_on_the_level() -> None:
level = make_level(
hazards=[
{"type": "fire", "station": "stove_2", "chance_per_s": 0.02},
{"type": "moving_obstacle", "path": [[4, 3], [10, 3]], "period_s": 9},
],
station_limits={"plates": 3, "pots": 2},
difficulty=7,
blurb="A kitchen.",
escalation_notes="tighter",
)
assert [hazard.type for hazard in level.hazards] == [
HazardKind.FIRE,
HazardKind.MOVING_OBSTACLE,
]
assert level.station_limits == StationLimits(plates=3, pots=2)
assert level.difficulty == 7
# ------------------------------------------------------------------- building geometry
def test_build_grid_maps_the_legend_and_marks_station_cells() -> None:
level = make_level()
grid = build_grid(level)
assert isinstance(grid, Grid)
assert (grid.width, grid.height) == (5, 5)
assert grid.tile_at(0, 0) is TileKind.WALL
assert grid.tile_at(1, 1) is TileKind.FLOOR
assert dict(grid.station_at) == {6: "spawn_shelf_0", 12: "chop_board_0", 18: "serve_0"}
def test_station_ids_count_per_kind_in_reading_order() -> None:
stations = build_stations(make_level())
assert [(station.id, station.kind) for station in stations] == [
("spawn_shelf_0", StationKind.SPAWN_SHELF),
("chop_board_0", StationKind.CHOP_BOARD),
("serve_0", StationKind.SERVE),
]
def test_two_legend_chars_of_one_station_kind_share_one_counter() -> None:
level = make_level(
grid=["######", "#T...#", "#..t.#", "#...V#", "######"],
tiles={
".": {"kind": "floor"},
"#": {"kind": "wall"},
"T": {"kind": "floor", "station": "stove"},
"t": {"kind": "floor", "station": "stove", "id_prefix": "burner"},
"V": {"kind": "floor", "station": "serve"},
},
)
stations = build_stations(level)
assert [station.id for station in stations] == ["stove_0", "burner_1", "serve_0"]
def test_station_positions_are_float_tile_coordinates() -> None:
chop = next(station for station in build_stations(make_level()) if station.kind
is StationKind.CHOP_BOARD)
assert (chop.x, chop.y) == (2.0, 2.0)
def test_a_station_on_a_non_floor_tile_still_builds_and_is_task_03_problem() -> None:
level = make_level(
grid=["#####", "#S..#", "#.C.#", "#.=V#", "#####"],
tiles={**TILES, "=": {"kind": "counter", "station": "stove"}},
)
grid = build_grid(level)
assert grid.tile_at(2, 3) is TileKind.COUNTER
assert grid.station_at[17] == "stove_0"
def test_station_items_cover_only_spawn_shelves() -> None:
level = make_level(
grid=["######", "#Ss..#", "#.C..#", "#...V#", "######"],
tiles={
".": {"kind": "floor"},
"#": {"kind": "wall"},
"S": {"kind": "floor", "station": "spawn_shelf", "item": "tomato"},
"s": {"kind": "floor", "station": "spawn_shelf", "item": "onion"},
"C": {"kind": "floor", "station": "chop_board"},
"V": {"kind": "floor", "station": "serve"},
},
spawns=[[1, 1], [2, 1]],
)
assert build_station_items(level) == {
"spawn_shelf_0": ItemKind.TOMATO,
"spawn_shelf_1": ItemKind.ONION,
}
def test_level_build_pairs_the_spec_with_grid_stations_and_spawns() -> None:
spec = make_level()
level = Level.build(spec)
assert level.spec is spec
assert level.grid == build_grid(spec)
assert level.stations == build_stations(spec)
assert level.spawns == ((1, 1),)
assert level.station_items == {"spawn_shelf_0": ItemKind.TOMATO}
def test_level_delegates_the_authoring_fields_to_its_spec() -> None:
level = Level.build(
make_level(difficulty=3, blurb="Cook!", escalation_notes="harder",
station_limits={"plates": 4})
)
assert level.id == "01_first_kitchen"
assert level.name == "First Kitchen"
assert level.order == 1
assert level.difficulty == 3
assert level.time_limit == 150
assert level.target_score == 45
assert level.orders.pool == ["soup"]
assert level.hazards == []
assert level.station_limits.plates == 4
assert level.blurb == "Cook!"
assert level.escalation_notes == "harder"
+839
View File
@@ -0,0 +1,839 @@
"""Unit tests for the ``undercooked-levels`` author CLI (phase 02 task 05).
The CLI is the author's half of the content contract, so these tests pin the
three things a human depends on:
* **the same code path as the server** — `validate` is `load_content` +
`validate_bundle`, so a green run here is a green `create_app()`;
* **the output contract** — one line per level, the named-rule report, a
`--json` document a script can parse, the `show --grid` reachability overlay
that makes a sealed pocket visible, and the `axes` table phase 11 will feed;
* **the exit codes** — 0 usable, 1 a rule is broken, 2 nothing usable to work
with (load failure, unknown id, refused overwrite, usage error).
Content is addressed explicitly with ``--dir`` (the session-wide
`UNDERCOOKED_LEVELS_DIR` fixture points at an empty campaign), plus one test
that deletes the variable and ``chdir``s to prove the documented commands still
resolve ``levels/`` from the checkout rather than from luck about cwd.
"""
from __future__ import annotations
import json
import os
import shutil
import subprocess
import sys
import tomllib
from collections.abc import Iterator, Sequence
from dataclasses import replace
from pathlib import Path
import pytest
import yaml
from undercooked.config import Settings, load_settings
from undercooked.server.routes_content import RECIPES_FILENAME
from undercooked.sim.content import ContentBundle, load_content, load_level_file
from undercooked.sim.recipe import ContentError, RecipeBook, load_recipe_book
from undercooked.sim.validation import (
AXIS_NAMES,
LevelViolation,
ValidationReport,
escalation_axes,
)
from undercooked.tools import levels
from undercooked.tools.levels import (
CHECKOUT_ROOT,
EXIT_OK,
EXIT_UNUSABLE,
EXIT_VIOLATIONS,
LIST_HEADER,
STARTER_HEIGHT,
_assign_chars,
_char_for,
_CliError,
_level_status,
describe_level,
main,
reachability_overlay,
render_starter_yaml,
resolve_content_paths,
)
pytestmark = pytest.mark.unit
REPO_ROOT = Path(__file__).resolve().parents[2]
SHIPPED_LEVELS = REPO_ROOT / "levels"
SHIPPED_RECIPES = SHIPPED_LEVELS / RECIPES_FILENAME
BAD_LEVELS = REPO_ROOT / "tests" / "fixtures" / "levels" / "_bad"
PYPROJECT = REPO_ROOT / "pyproject.toml"
def run(argv: Sequence[str]) -> int:
"""The console script's entry point, in-process (same target as the wrapper)."""
return main(list(argv))
@pytest.fixture()
def shipped_bundle() -> ContentBundle:
return load_content(SHIPPED_LEVELS, SHIPPED_RECIPES)
@pytest.fixture()
def campaign_copy(tmp_path: Path) -> Iterator[Path]:
"""A writable copy of the shipped campaign that `new` may extend."""
target = tmp_path / "campaign"
shutil.copytree(SHIPPED_LEVELS, target)
yield target
def write_campaign(tmp_path: Path, level_yaml: str, book_yaml: str) -> Path:
"""A throwaway campaign directory built from two YAML documents."""
directory = tmp_path / "mini"
directory.mkdir(parents=True, exist_ok=True)
(directory / RECIPES_FILENAME).write_text(book_yaml, encoding="utf-8")
(directory / "01_mini.yaml").write_text(level_yaml, encoding="utf-8")
return directory
# --- path resolution -----------------------------------------------------------
def test_absolute_flags_win_over_everything() -> None:
levels_dir, recipes = resolve_content_paths(
Settings(levels_dir=SHIPPED_LEVELS, recipes_path=SHIPPED_RECIPES),
Path("/content/levels"),
Path("/content/book.yaml"),
)
assert levels_dir == Path("/content/levels")
assert recipes == Path("/content/book.yaml")
def test_the_recipe_book_defaults_to_the_one_inside_the_levels_dir() -> None:
levels_dir, recipes = resolve_content_paths(Settings(levels_dir=SHIPPED_LEVELS))
assert levels_dir == SHIPPED_LEVELS
assert recipes == SHIPPED_RECIPES
def test_the_recipe_book_flag_beats_the_environment(tmp_path: Path, monkeypatch) -> None:
env_book = tmp_path / "env-book.yaml"
monkeypatch.setenv("UNDERCOOKED_RECIPES_PATH", str(env_book))
_, from_env = resolve_content_paths(load_settings())
assert from_env == env_book
_, from_flag = resolve_content_paths(load_settings(), None, SHIPPED_RECIPES)
assert from_flag == SHIPPED_RECIPES
def test_a_relative_path_that_exists_here_is_used_here(tmp_path: Path, monkeypatch) -> None:
base = tmp_path.resolve()
(base / "levels").mkdir()
monkeypatch.chdir(base)
levels_dir, recipes = resolve_content_paths(Settings(levels_dir=Path("levels")))
assert levels_dir == base / "levels"
assert recipes == base / "levels" / RECIPES_FILENAME
def test_a_relative_path_with_nothing_local_anchors_to_the_checkout(
tmp_path: Path, monkeypatch
) -> None:
"""The documented `undercooked-levels validate` works from any directory."""
monkeypatch.chdir(tmp_path)
levels_dir, recipes = resolve_content_paths(Settings(levels_dir=Path("levels")))
assert levels_dir == CHECKOUT_ROOT / "levels"
assert recipes == CHECKOUT_ROOT / "levels" / RECIPES_FILENAME
assert levels_dir.is_dir()
def test_a_path_that_exists_nowhere_is_reported_as_it_was_typed(
tmp_path: Path, monkeypatch
) -> None:
monkeypatch.chdir(tmp_path)
levels_dir, recipes = resolve_content_paths(
Settings(levels_dir=Path("nope/levels"), recipes_path=Path("nope/book.yaml"))
)
assert levels_dir == Path("nope/levels")
assert recipes == Path("nope/book.yaml")
def test_a_home_relative_path_is_expanded() -> None:
levels_dir, _ = resolve_content_paths(Settings(levels_dir=Path("~/levels")))
assert levels_dir == Path.home() / "levels"
# --- validate -------------------------------------------------------------------
def test_validate_is_green_on_the_shipped_campaign_and_prints_one_line_per_level(
capsys: pytest.CaptureFixture[str], shipped_bundle: ContentBundle
) -> None:
assert run(["validate", "--dir", str(SHIPPED_LEVELS)]) == EXIT_OK
lines = capsys.readouterr().out.splitlines()
assert lines[-1] == "content OK: no violations"
assert len(lines) == len(shipped_bundle.levels) + 1
for level, line in zip(shipped_bundle.levels, lines[:-1], strict=True):
assert line.startswith(level.id)
assert "ok" in line
assert f"{level.grid.width}x{level.grid.height}" in line
def test_validate_exits_1_and_names_every_rule_it_hit(
capsys: pytest.CaptureFixture[str]
) -> None:
assert run(["validate", "--dir", str(BAD_LEVELS)]) == EXIT_VIOLATIONS
out = capsys.readouterr().out
assert "99_broken_room" in out
assert "4 error(s)" in out
for rule in ("SERVE_REACHABLE", "RECIPE_STATION_MISSING", "TARGET_IMPLAUSIBLE"):
assert rule in out
def test_validate_json_is_machine_readable_when_green(
capsys: pytest.CaptureFixture[str], shipped_bundle: ContentBundle
) -> None:
assert run(["validate", "--dir", str(SHIPPED_LEVELS), "--json"]) == EXIT_OK
payload = json.loads(capsys.readouterr().out)
assert payload["ok"] is True
assert payload["errors"] == []
assert payload["warnings"] == []
assert payload["counts"] == {"errors": 0, "warnings": 0}
assert [level["id"] for level in payload["levels"]] == [
level.id for level in shipped_bundle.levels
]
def test_validate_json_carries_the_rule_hits_and_their_detail(
capsys: pytest.CaptureFixture[str]
) -> None:
assert run(["validate", "--dir", str(BAD_LEVELS), "--json"]) == EXIT_VIOLATIONS
payload = json.loads(capsys.readouterr().out)
errors = payload["errors"]
assert payload["ok"] is False
assert payload["counts"] == {"errors": len(errors), "warnings": len(payload["warnings"])}
assert {error["rule"] for error in errors} >= {
"SERVE_REACHABLE",
"RECIPE_STATION_MISSING",
"TARGET_IMPLAUSIBLE",
}
assert {error["level"] for error in errors} == {"99_broken_room"}
assert {"recipe": "soup", "station": "stove"} in [error["detail"] for error in errors]
keys = [(error["level"], error["rule"], error["message"]) for error in errors]
assert keys == sorted(keys)
def test_validate_json_is_what_python_m_json_tool_accepts(
capsys: pytest.CaptureFixture[str], tmp_path: Path
) -> None:
"""The documented pipe: `undercooked-levels validate --json | python -m json.tool`."""
assert run(["validate", "--dir", str(BAD_LEVELS), "--json"]) == EXIT_VIOLATIONS
raw = capsys.readouterr().out
formatted = subprocess.run(
[sys.executable, "-m", "json.tool"],
input=raw,
capture_output=True,
text=True,
timeout=60,
check=False,
)
assert formatted.returncode == 0, formatted.stderr
assert "SERVE_REACHABLE" in formatted.stdout
assert json.loads(formatted.stdout)["ok"] is False
assert tmp_path # nothing is written; the report only goes to stdout
def test_validate_names_a_missing_directory_and_exits_2(
capsys: pytest.CaptureFixture[str], tmp_path: Path
) -> None:
missing = tmp_path / "not-here"
assert run(["validate", "--dir", str(missing)]) == EXIT_UNUSABLE
err = capsys.readouterr().err
assert err.startswith("undercooked-levels:")
assert "not-here" in err
def test_validate_reports_a_broken_recipe_book_as_exit_2(
capsys: pytest.CaptureFixture[str], tmp_path: Path
) -> None:
directory = tmp_path / "levels"
directory.mkdir()
(directory / RECIPES_FILENAME).write_text("\tthis: [is not", encoding="utf-8")
assert run(["validate", "--dir", str(directory)]) == EXIT_UNUSABLE
assert "recipes.yaml" in capsys.readouterr().err
def test_validate_follows_the_levels_dir_from_the_environment(
capsys: pytest.CaptureFixture[str], monkeypatch, campaign_copy: Path
) -> None:
monkeypatch.setenv("UNDERCOOKED_LEVELS_DIR", str(campaign_copy))
assert run(["validate"]) == EXIT_OK
assert "02_double_shift" in capsys.readouterr().out
@pytest.mark.parametrize(
"argv",
[
["validate"],
["list"],
["axes"],
["show", "01_first_kitchen", "--grid"],
],
)
def test_every_subcommand_resolves_levels_from_the_checkout_not_from_cwd(
argv: list[str], capsys: pytest.CaptureFixture[str], tmp_path: Path, monkeypatch
) -> None:
"""No `--dir`, empty cwd, unset env: the built-in `levels` still finds campaign 02."""
monkeypatch.delenv("UNDERCOOKED_LEVELS_DIR", raising=False)
monkeypatch.delenv("UNDERCOOKED_RECIPES_PATH", raising=False)
monkeypatch.chdir(tmp_path)
assert run(argv) == EXIT_OK
assert "01_first_kitchen" in capsys.readouterr().out
def test_level_status_counts_warnings_of_a_warning_only_level() -> None:
bundle = load_content(SHIPPED_LEVELS, SHIPPED_RECIPES)
level = bundle.levels[0]
warning = LevelViolation(rule="TARGET_IMPLAUSIBLE", level_id=level.id, message="tight")
clean = _level_status(level, ValidationReport())
noisy = _level_status(level, ValidationReport(violations=(), warnings=(warning,)))
error = LevelViolation(rule="SERVE_REACHABLE", level_id=level.id, message="sealed")
broken = _level_status(level, ValidationReport(violations=(error,), warnings=(warning,)))
assert clean == f"{level.id:<28} {'ok':<24} {level.grid.width}x{level.grid.height}" + (
f", {len(level.stations)} stations"
)
assert "0 error(s)" not in clean
assert ", 1 warning(s)" in noisy
assert "1 error(s), 1 warning(s)" in broken
# --- list -----------------------------------------------------------------------
def test_list_prints_one_line_per_level_in_campaign_order(
capsys: pytest.CaptureFixture[str], shipped_bundle: ContentBundle
) -> None:
assert run(["list", "--dir", str(SHIPPED_LEVELS)]) == EXIT_OK
lines = capsys.readouterr().out.splitlines()
assert lines[0] == LIST_HEADER
assert len(lines) == 1 + len(shipped_bundle.levels)
for level, line in zip(shipped_bundle.levels, lines[1:], strict=True):
assert line.startswith(f"{level.order:>5} {level.id:<28}")
assert str(level.target_score) in line
assert f"{level.time_limit:>6.1f}" in line
for recipe_id in level.orders.pool:
assert recipe_id in line
def test_list_of_an_empty_campaign_is_only_the_header(
capsys: pytest.CaptureFixture[str], tmp_path: Path
) -> None:
empty = tmp_path / "empty"
empty.mkdir()
shutil.copy(SHIPPED_RECIPES, empty / RECIPES_FILENAME)
assert run(["list", "--dir", str(empty)]) == EXIT_OK
assert capsys.readouterr().out.splitlines() == [LIST_HEADER]
# --- show -----------------------------------------------------------------------
def test_show_prints_the_resolved_level(
capsys: pytest.CaptureFixture[str], shipped_bundle: ContentBundle
) -> None:
level = shipped_bundle.levels[1]
assert run(["show", level.id, "--dir", str(SHIPPED_LEVELS)]) == EXIT_OK
out = capsys.readouterr().out
assert out.startswith(f"{level.id} - {level.name}")
for label in (
"order",
"difficulty",
"time_limit",
"target_score",
"grid",
"spawns",
"stations",
"orders",
"limits",
"hazards",
"reachability",
"recipes",
"blurb",
"escalation_notes",
):
assert f"{label:<18}" in out
assert "spawn_shelf x" in out
assert "map (as authored)" not in out # --grid is opt-in
def test_show_grid_adds_the_authored_map_and_the_reachability_overlay(
capsys: pytest.CaptureFixture[str], shipped_bundle: ContentBundle
) -> None:
level = shipped_bundle.levels[0]
assert run(["show", level.id, "--grid", "--dir", str(SHIPPED_LEVELS)]) == EXIT_OK
out = capsys.readouterr().out
assert "map (as authored)" in out
for row in level.spec.grid:
assert f" {row}" in out
assert ". reachable" in out
assert "?" not in out.split("reachability")[1] # the shipped kitchen has no dead pockets
assert f"{level.grid.width}x{level.grid.height}" in out
def test_show_grid_marks_a_sealed_pocket_as_unreachable(
capsys: pytest.CaptureFixture[str]
) -> None:
assert run(["show", "99_broken_room", "--grid", "--dir", str(BAD_LEVELS)]) == EXIT_OK
out = capsys.readouterr().out
overlay = out.split("? walkable but cut off")[1]
assert "??" in overlay
assert "????#" in overlay # the walled-in servery row
assert "walkable tiles are reachable from the spawns" in overlay
def test_show_rejects_an_unknown_level_id(
capsys: pytest.CaptureFixture[str], shipped_bundle: ContentBundle
) -> None:
assert run(["show", "07_no_such_kitchen", "--dir", str(SHIPPED_LEVELS)]) == EXIT_UNUSABLE
err = capsys.readouterr().err
assert "no level '07_no_such_kitchen'" in err
assert "01_first_kitchen" in err # it says what does exist
# --- axes -----------------------------------------------------------------------
def test_axes_table_lists_every_axis_for_every_level_in_campaign_order(
capsys: pytest.CaptureFixture[str], shipped_bundle: ContentBundle
) -> None:
assert run(["axes", "--dir", str(SHIPPED_LEVELS)]) == EXIT_OK
lines = capsys.readouterr().out.splitlines()
header = lines[0]
assert header.startswith("LEVEL")
for name in AXIS_NAMES:
assert name in header
assert set(AXIS_NAMES) == set(escalation_axes(shipped_bundle.levels[0]))
assert len(lines) == 1 + len(shipped_bundle.levels)
for level, line in zip(shipped_bundle.levels, lines[1:], strict=True):
assert line.startswith(f"{level.id:<28}{level.order:>6}")
axes = escalation_axes(level)
for name in AXIS_NAMES:
assert f"{axes[name]:>20.4f}" in line
def test_axes_of_an_empty_campaign_is_only_the_header(
capsys: pytest.CaptureFixture[str], tmp_path: Path
) -> None:
empty = tmp_path / "empty"
empty.mkdir()
shutil.copy(SHIPPED_RECIPES, empty / RECIPES_FILENAME)
assert run(["axes", "--dir", str(empty)]) == EXIT_OK
assert len(capsys.readouterr().out.splitlines()) == 1
# --- new ------------------------------------------------------------------------
def test_new_writes_a_starter_level_and_validates_it(
capsys: pytest.CaptureFixture[str], campaign_copy: Path, shipped_bundle: ContentBundle
) -> None:
assert run(["new", "03_zero_cool", "--dir", str(campaign_copy)]) == EXIT_OK
out = capsys.readouterr().out
assert f"wrote {campaign_copy / '03_zero_cool.yaml'}" in out
assert "content OK: no violations" in out
written = campaign_copy / "03_zero_cool.yaml"
body = yaml.safe_load(written.read_text(encoding="utf-8"))
top_difficulty = max(level.difficulty for level in shipped_bundle.levels)
top_order = max(level.order for level in shipped_bundle.levels)
assert body["id"] == "03_zero_cool"
assert body["order"] == top_order + 1
assert body["difficulty"] == top_difficulty + 1
# It is a real level: it loads, and the campaign now carries three of them.
level = load_level_file(written)
assert level.name == "Zero Cool"
assert load_content(campaign_copy, SHIPPED_RECIPES).levels[-1].id == "03_zero_cool"
# ...and the scaffolded kitchen is self-consistent: every grid letter used is
# declared, and nothing declared is left unused.
assert set(body["tiles"]) == {cell for row in body["grid"] for cell in row}
def test_new_uses_the_requested_order(
capsys: pytest.CaptureFixture[str], campaign_copy: Path
) -> None:
assert run(["new", "07_late_shift", "--order", "7", "--dir", str(campaign_copy)]) == EXIT_OK
body = yaml.safe_load((campaign_copy / "07_late_shift.yaml").read_text(encoding="utf-8"))
assert body["order"] == 7
capsys.readouterr()
def test_new_refuses_to_overwrite_an_existing_level(
capsys: pytest.CaptureFixture[str], campaign_copy: Path
) -> None:
target = campaign_copy / "01_first_kitchen.yaml"
before = target.read_text(encoding="utf-8")
assert run(["new", "01_first_kitchen", "--dir", str(campaign_copy)]) == EXIT_UNUSABLE
err = capsys.readouterr().err
assert "already exists" in err
assert "refusing to overwrite" in err
assert target.read_text(encoding="utf-8") == before
@pytest.mark.parametrize("candidate", ["ZeroCool", "03 zero cool", "_private", "03-second"])
def test_new_rejects_an_id_that_is_not_a_filename_safe_slug(
candidate: str, capsys: pytest.CaptureFixture[str], campaign_copy: Path
) -> None:
assert run(["new", candidate, "--dir", str(campaign_copy)]) == EXIT_UNUSABLE
assert "slug" in capsys.readouterr().err
assert not (campaign_copy / f"{candidate}.yaml").exists()
def test_new_rejects_an_order_below_one(
capsys: pytest.CaptureFixture[str], campaign_copy: Path
) -> None:
assert run(["new", "03_probe", "--order", "0", "--dir", str(campaign_copy)]) == EXIT_UNUSABLE
assert "--order must be at least 1" in capsys.readouterr().err
def test_new_needs_a_readable_campaign(
capsys: pytest.CaptureFixture[str], tmp_path: Path
) -> None:
assert run(["new", "03_probe", "--dir", str(tmp_path / "nothing")]) == EXIT_UNUSABLE
assert "undercooked-levels:" in capsys.readouterr().err
@pytest.mark.parametrize(
"error",
[OSError("disk full"), OSError(28, "No space left on device")],
ids=["no-strerror", "with-strerror"],
)
def test_new_reports_a_write_failure(
error: OSError,
capsys: pytest.CaptureFixture[str],
campaign_copy: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
def refuse(self: Path, *args: object, **kwargs: object) -> int:
raise error
monkeypatch.setattr(Path, "write_text", refuse)
assert run(["new", "03_probe", "--dir", str(campaign_copy)]) == EXIT_UNUSABLE
err = capsys.readouterr().err
assert "cannot write" in err
assert (error.strerror or str(error)) in err
def test_new_reports_a_file_the_loader_rejects(
capsys: pytest.CaptureFixture[str], campaign_copy: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
def refuse(path: Path | str) -> object:
raise ContentError("the loader hated the scaffold")
monkeypatch.setattr(levels, "load_level_file", refuse)
assert run(["new", "03_probe", "--dir", str(campaign_copy)]) == EXIT_UNUSABLE
assert "the loader hated the scaffold" in capsys.readouterr().err
# --- the scaffold itself --------------------------------------------------------
def test_the_scaffold_is_built_from_the_recipe_book_not_a_template_kitchen() -> None:
book = RecipeBook.model_validate(
{
"recipes": {
"broth": {
"id": "broth",
"name": "Plain Broth",
"base_score": 20,
"ingredients": [
{"kind": "rice", "form": "raw"},
{"kind": "fish", "form": "raw"},
],
"steps": [
{"kind": "pick", "station": "spawn_shelf"},
{"kind": "wash", "station": "sink"},
{"kind": "cook", "station": "stove"},
{"kind": "serve", "station": "serve"},
],
}
}
}
)
text = render_starter_yaml(level_id="05_broth_house", order=5, difficulty=4, book=book)
body = yaml.safe_load(text)
assert body["id"] == "05_broth_house"
assert body["name"] == "Broth House"
assert body["order"] == 5
assert body["difficulty"] == 4
assert body["orders"]["pool"] == ["broth"]
stations = {spec["station"] for spec in body["tiles"].values() if "station" in spec}
items = {spec["item"] for spec in body["tiles"].values() if "item" in spec}
assert stations == {"spawn_shelf", "sink", "stove", "serve"} # no chop board: no chop step
assert items == {"rice", "fish"}
assert set(body["tiles"]) == {cell for row in body["grid"] for cell in row}
assert len({len(row) for row in body["grid"]}) == 1
def test_the_scaffold_of_the_shipped_book_shelves_that_books_first_dish() -> None:
book = load_content(SHIPPED_LEVELS, SHIPPED_RECIPES).recipe_book
first = book.ids()[0]
body = yaml.safe_load(
render_starter_yaml(level_id="05_probe", order=5, difficulty=1, book=book)
)
expected_items = {
ingredient.kind.value
for ingredient in book.get(first).ingredients
if str(ingredient.form) == "raw"
}
assert {spec["item"] for spec in body["tiles"].values() if "item" in spec} == expected_items
assert body["orders"]["pool"] == [first]
def test_the_scaffold_needs_something_to_cook() -> None:
with pytest.raises(_CliError):
render_starter_yaml(level_id="05_probe", order=1, difficulty=1, book=RecipeBook(recipes={}))
def test_the_scaffold_keeps_the_spawn_far_enough_from_the_window() -> None:
book = load_content(SHIPPED_LEVELS, SHIPPED_RECIPES).recipe_book
body = yaml.safe_load(
render_starter_yaml(level_id="05_probe", order=1, difficulty=5, book=book)
)
width = len(body["grid"][0])
assert body["grid"][0] == "#" * width == body["grid"][-1]
assert len(body["grid"]) == STARTER_HEIGHT
fixtures = body["grid"][4]
serve_x = fixtures.index(
next(char for char, spec in body["tiles"].items() if spec.get("station") == "serve")
)
assert fixtures[serve_x] != "." # the window really stands on the fixture row
for x, y in body["spawns"]:
assert abs(x - serve_x) + abs(y - 4) >= 3 # TRAVEL_TOO_SHORT's floor
def test_grid_letters_stay_unique_when_a_preferred_letter_is_taken() -> None:
free = {"#", "."}
assert _char_for("T", free) == "T" # an untouched preference wins
assert _char_for("T", free | {"T"}) not in free | {"T"} # taken: a spare instead
assert _char_for(None, free) not in free
used = set(free)
assigned = _assign_chars(
["tomato", "onion", "lettuce", "rice"],
{"tomato": "T", "onion": "T", "lettuce": "T", "rice": "R"},
used,
)
assert list(assigned) == ["tomato", "onion", "lettuce", "rice"]
assert assigned["tomato"] == "T" # first in line keeps its preferred letter
assert assigned["onion"] != "T" # the collisions fall back to spares
assert assigned["rice"] == "R"
assert len(set(assigned.values())) == 4
assert used == {"#", ".", *assigned.values()} # the caller's set is the ledger
# --- describe_level / overlay directly ------------------------------------------
MINI_BOOK = """\
recipes:
snack:
id: snack
name: Plain Snack
base_score: 20
ingredients:
- {kind: tomato, form: raw}
steps:
- {kind: pick, station: spawn_shelf}
- {kind: serve, station: serve}
"""
MINI_LEVEL = """\
id: 01_mini
name: Mini
order: 1
time_limit: 90
target_score: 40
grid:
- "#######"
- "#T....#"
- "#.#.#.#"
- "#....X#"
- "#######"
tiles:
"#": {kind: wall}
".": {kind: floor}
"T": {kind: floor, station: spawn_shelf, item: tomato}
"X": {kind: floor, station: serve}
spawns: [[1, 1]]
orders:
pool: [snack]
concurrent: 1
spawn_interval: [10, 14]
deadline: 40
"""
#: The same kitchen with a cupboard walled in at (3, 3) and (4, 3): walkable
#: floor no spawn can reach, which is exactly what the overlay exists to show.
POCKET_LEVEL = MINI_LEVEL.replace(
"""\
id: 01_mini
name: Mini""",
"""\
id: 02_pocket
name: Pocket""",
).replace(
"""\
- "#######"
- "#T....#"
- "#.#.#.#"
- "#....X#"
- "#######""",
"""\
- "########"
- "#T....X#"
- "#.####.#"
- "#.#BB#.#"
- "#.####.#"
- "########""",
).replace(
' "X": {kind: floor, station: serve}',
' "X": {kind: floor, station: serve}\n "B": {kind: floor}',
)
def test_describe_marks_missing_prose_as_none(tmp_path: Path) -> None:
directory = write_campaign(tmp_path, MINI_LEVEL, MINI_BOOK)
level = load_level_file(directory / "01_mini.yaml")
out = "\n".join(describe_level(level, load_recipe_book(directory / RECIPES_FILENAME)))
assert "snack: Plain Snack" in out # the prose is missing, the recipe is not
assert out.count("(none)") == 2 # blurb and escalation_notes say so out loud
def test_describe_reports_a_pool_recipe_the_book_does_not_define(tmp_path: Path) -> None:
directory = write_campaign(tmp_path, MINI_LEVEL, MINI_BOOK)
level = load_level_file(directory / "01_mini.yaml")
out = "\n".join(describe_level(level, RecipeBook(recipes={})))
assert "snack: MISSING from the recipe book" in out
def test_describe_counts_hazards(tmp_path: Path) -> None:
level_yaml = MINI_LEVEL.replace("hazards: []", "") + (
"hazards:\n - {type: spill, x: 4, y: 1}\n - {type: fire, x: 2, y: 1}\n"
)
directory = tmp_path / "haz"
directory.mkdir()
(directory / RECIPES_FILENAME).write_text(MINI_BOOK, encoding="utf-8")
(directory / "01_mini.yaml").write_text(level_yaml, encoding="utf-8")
level = load_level_file(directory / "01_mini.yaml")
lines = describe_level(level, load_recipe_book(directory / RECIPES_FILENAME))
hazards = next(line for line in lines if line.startswith("hazards"))
assert "fire x1" in hazards
assert "spill x1" in hazards
def test_describe_says_none_when_a_level_has_no_stations(tmp_path: Path) -> None:
"""A hand-built level with no stations (content forbids one: no serve window).
`LevelDef` requires exactly one serve tile, so the station-less `Level` can
only come from a hand-built dataclass — which is what a phase-03 caller
holding a partially-built level does.
"""
directory = write_campaign(tmp_path, MINI_LEVEL, MINI_BOOK)
level = load_level_file(directory / "01_mini.yaml")
assert level.stations # the authored level has some; the stripped one has none
lines = describe_level(replace(level, stations=()), RecipeBook(recipes={}))
empty = "stations".ljust(18) + "none"
assert next(line for line in lines if line.startswith("stations")) == empty
def test_reachability_overlay_only_uses_the_three_terrain_characters(tmp_path: Path) -> None:
directory = write_campaign(tmp_path, POCKET_LEVEL, MINI_BOOK)
level = load_level_file(directory / "01_mini.yaml")
overlay = reachability_overlay(level)
assert {cell for row in overlay for cell in row} == {".", "?", "#"}
# the walled-in pocket reads as `??`; everything else the cooks can walk to.
assert overlay[3] == "#.#??#.#"
assert sum(row.count("?") for row in overlay) == 2
# --- entry point ----------------------------------------------------------------
def test_no_subcommand_is_a_usage_error(capsys: pytest.CaptureFixture[str]) -> None:
assert run([]) == EXIT_UNUSABLE
err = capsys.readouterr().err
assert "usage: undercooked-levels" in err
assert "arguments are required: COMMAND" in err
def test_an_unknown_subcommand_is_a_usage_error(capsys: pytest.CaptureFixture[str]) -> None:
assert run(["scullery"]) == EXIT_UNUSABLE
err = capsys.readouterr().err
assert "usage: undercooked-levels" in err
assert "invalid choice: 'scullery'" in err
def test_an_unknown_flag_is_a_usage_error(capsys: pytest.CaptureFixture[str]) -> None:
assert run(["validate", "--nope"]) == EXIT_UNUSABLE
assert "unrecognized arguments" in capsys.readouterr().err
@pytest.mark.parametrize("argv", [["--help"], ["validate", "--help"], ["new", "-h"]])
def test_help_exits_cleanly(argv: list[str], capsys: pytest.CaptureFixture[str]) -> None:
assert run(argv) == EXIT_OK
out = capsys.readouterr().out
assert "usage: undercooked-levels" in out
def test_help_documents_the_subcommands_and_exit_codes(
capsys: pytest.CaptureFixture[str],
) -> None:
assert run(["--help"]) == EXIT_OK
out = capsys.readouterr().out
for command in ("validate", "list", "show", "new", "axes"):
assert command in out
assert "0 ok, 1 a level breaks a validation rule" in out
def test_the_parser_builds_with_a_handler_for_every_subcommand() -> None:
parser = levels.build_parser()
for command, expected in (
(["validate"], "validate"),
(["list"], "list"),
(["show", "x"], "show"),
(["new", "x"], "new"),
(["axes"], "axes"),
):
args = parser.parse_args(command)
assert args.command == expected
assert callable(args.handler)
assert args.dir is None or isinstance(args.dir, Path)
def test_console_script_is_declared_and_runs_as_a_module() -> None:
"""The real entry point: `[project.scripts]` -> this module's `main`."""
declared = tomllib.loads(PYPROJECT.read_text(encoding="utf-8"))["project"]["scripts"]
assert declared["undercooked-levels"] == "undercooked.tools.levels:main"
result = subprocess.run( # fixed argv, the repo's own interpreter
[
sys.executable,
"-m",
"undercooked.tools.levels",
"list",
],
capture_output=True,
text=True,
timeout=120,
check=False,
cwd=REPO_ROOT,
env={**os.environ, "UNDERCOOKED_LEVELS_DIR": str(SHIPPED_LEVELS)},
)
assert result.returncode == EXIT_OK, result.stderr
assert "01_first_kitchen" in result.stdout
assert "02_double_shift" in result.stdout
def test_exit_codes_are_the_documented_ones() -> None:
assert (EXIT_OK, EXIT_VIOLATIONS, EXIT_UNUSABLE) == (0, 1, 2)
+104
View File
@@ -0,0 +1,104 @@
"""docs/LEVELS.md must document exactly the fields the models have.
The doc is the content author's contract; the Pydantic models are its
executable form. This test parses every ``### <Model>`` section's *Field*
table (the first table whose header row starts with ``Field``, so the
HazardSpec placement table is not mistaken for a field list) and requires the
back-quoted field names to equal ``model_fields`` — no undocumented field,
no documented ghost. A planted-drift case proves the extraction can fail.
"""
from __future__ import annotations
from pathlib import Path
import pytest
from pydantic import BaseModel
from undercooked.sim.level import (
HazardSpec,
LevelDef,
OrdersSpec,
StationLimits,
TileSpec,
)
from undercooked.sim.recipe import Ingredient, Recipe, RecipeBook, Step
pytestmark = pytest.mark.unit
DOC_PATH = Path(__file__).resolve().parents[2] / "docs" / "LEVELS.md"
MODELS: tuple[type[BaseModel], ...] = (
Ingredient,
Step,
Recipe,
RecipeBook,
TileSpec,
OrdersSpec,
HazardSpec,
StationLimits,
LevelDef,
)
def _section(lines: list[str], model_name: str) -> list[str]:
"""Lines of the `### model_name` section, up to the next heading."""
heading = f"### {model_name}"
collected: list[str] = []
in_section = False
for line in lines:
if line.startswith("#"):
if in_section:
break
in_section = line.strip() == heading
continue
if in_section:
collected.append(line)
return collected
def _table_fields(section: list[str]) -> set[str]:
"""Back-quoted first-column entries of the section's `Field` table."""
start = None
for index, line in enumerate(section):
stripped = line.strip()
if stripped.startswith("|") and stripped.strip("|").split("|")[0].strip() == "Field":
start = index
break
if start is None:
return set()
fields: set[str] = set()
for line in section[start + 2 :]: # skip header + |---| separator
stripped = line.strip()
if not stripped.startswith("|"):
break
first = stripped.strip("|").split("|")[0].strip()
if len(first) > 2 and first.startswith("`") and first.endswith("`"):
fields.add(first[1:-1])
return fields
@pytest.fixture(scope="module")
def doc_lines() -> list[str]:
assert DOC_PATH.is_file(), f"{DOC_PATH} is missing"
return DOC_PATH.read_text(encoding="utf-8").splitlines()
@pytest.mark.parametrize("model", MODELS, ids=lambda model: model.__name__)
def test_every_model_has_an_exact_field_table(doc_lines: list[str], model: type[BaseModel]) -> None:
documented = _table_fields(_section(doc_lines, model.__name__))
assert documented, f"docs/LEVELS.md has no Field table for {model.__name__}"
assert documented == set(model.model_fields), (
f"docs/LEVELS.md vs {model.__name__}: "
f"undocumented={sorted(set(model.model_fields) - documented)}, "
f"not-in-model={sorted(documented - set(model.model_fields))}"
)
def test_the_comparison_catches_planted_drift(doc_lines: list[str]) -> None:
planted = [
line.replace("| `penalty` |", "| `reward` |") for line in doc_lines
]
documented = _table_fields(_section(planted, "OrdersSpec"))
assert "reward" in documented
assert documented != set(OrdersSpec.model_fields)
+34
View File
@@ -37,6 +37,7 @@ from undercooked.config import (
get_settings,
reset_settings_cache,
)
from undercooked.sim.recipe import ContentError
if TYPE_CHECKING:
from collections.abc import Iterator
@@ -214,6 +215,39 @@ def test_main_reports_an_invalid_environment(
assert "UNDERCOOKED_TICK_HZ=99" in capsys.readouterr().err
def test_main_exits_two_when_the_content_gate_refuses_startup(
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
"""Unusable levels are a startup error, not a traceback.
Content is read when uvicorn imports the app string, so `create_app`'s
`ContentError` surfaces out of `uvicorn.run` — and the CLI answers with exit 2,
the same code an unusable `UNDERCOOKED_PORT` gets. The named-rule report itself
is written by `create_app` before it raises, so this only adds the closing line.
"""
def refusing_run(_target: str, **_kwargs: Any) -> None:
raise ContentError("undercooked: level content failed validation:\nBOGUS_RULE: nope")
monkeypatch.setattr(uvicorn, "run", refusing_run)
assert main([]) == EXIT_CONFIG_ERROR
err = capsys.readouterr().err
assert "refusing to start" in err
assert "undercooked-levels validate" in err
assert "Traceback" not in err
def test_main_lets_any_other_serving_failure_propagate(monkeypatch: pytest.MonkeyPatch) -> None:
"""Only the content gate is translated: a real crash stays a real crash."""
def crashing_run(_target: str, **_kwargs: Any) -> None:
raise OSError("port in use")
monkeypatch.setattr(uvicorn, "run", crashing_run)
with pytest.raises(OSError, match="port in use"):
main([])
def test_unknown_flag_is_an_argument_error() -> None:
with pytest.raises(SystemExit) as excinfo:
main(["--nope"])
+311
View File
@@ -0,0 +1,311 @@
"""Unit tests for `undercooked.sim.recipe` (phase 02 task 02).
The recipe book is the content contract every later phase cooks against: strict
schemas (typos are load errors, not defaults), deterministic helpers, and a
`min_seconds_to_complete` solver bound the validator trusts. Every failing case
here is a YAML an author could plausibly write.
"""
from __future__ import annotations
from pathlib import Path
from typing import Any
import pytest
from pydantic import ValidationError
from undercooked.sim.model import ItemForm, ItemKind, StationKind
from undercooked.sim.recipe import (
HANDLING_S,
ContentError,
Ingredient,
Recipe,
RecipeBook,
Step,
describe_validation_error,
load_recipe_book,
read_yaml_mapping,
)
pytestmark = pytest.mark.unit
def soup_dict(**overrides: Any) -> dict[str, Any]:
"""A minimal valid soup recipe body; overrides replace top-level fields."""
body: dict[str, Any] = {
"id": "soup",
"name": "Tomato Soup",
"base_score": 40,
"ingredients": [
{"kind": "tomato", "form": "raw"},
{"kind": "onion", "form": "raw", "count": 2},
],
"steps": [
{"kind": "chop", "station": "chop_board", "seconds": 0.9},
{"kind": "cook", "station": "stove", "seconds": 5.0},
],
}
body.update(overrides)
return body
def book_dict(**overrides: Any) -> dict[str, Any]:
data: dict[str, Any] = {"recipes": {"soup": soup_dict()}}
data.update(overrides)
return data
def write_book(directory: Path, text: str) -> Path:
path = directory / "recipes.yaml"
path.write_text(text, encoding="utf-8")
return path
# --------------------------------------------------------------------- Ingredient / Step
def test_handling_constant_is_the_documented_bound() -> None:
assert HANDLING_S == 0.6
def test_ingredient_defaults_count_to_one() -> None:
ingredient = Ingredient(kind=ItemKind.TOMATO, form=ItemForm.RAW)
assert ingredient.count == 1
def test_ingredient_count_must_be_positive() -> None:
with pytest.raises(ValidationError, match="count"):
Ingredient(kind=ItemKind.TOMATO, form=ItemForm.RAW, count=0)
def test_ingredient_kinds_are_the_enum_not_arbitrary_strings() -> None:
with pytest.raises(ValidationError):
Ingredient.model_validate({"kind": "durian", "form": "raw"})
def test_step_defaults_to_zero_seconds() -> None:
step = Step(kind="pick", station=StationKind.SPAWN_SHELF)
assert step.seconds == 0.0
def test_step_kind_is_a_closed_vocabulary() -> None:
with pytest.raises(ValidationError, match="kind"):
Step.model_validate({"kind": "throw", "station": "chop_board"})
def test_step_seconds_cannot_be_negative() -> None:
with pytest.raises(ValidationError, match="seconds"):
Step(kind="chop", station=StationKind.CHOP_BOARD, seconds=-0.1)
def test_step_station_must_be_a_known_station_kind() -> None:
with pytest.raises(ValidationError, match="station"):
Step.model_validate({"kind": "cook", "station": "deep_fryer"})
# --------------------------------------------------------------------------- Recipe
def test_recipe_happy_path_carries_documented_defaults() -> None:
recipe = Recipe.model_validate(soup_dict())
assert recipe.requires_plating is True
assert recipe.tags == []
assert [ingredient.count for ingredient in recipe.ingredients] == [1, 2]
def test_recipe_rejects_unknown_keys() -> None:
with pytest.raises(ValidationError, match=r"extra_forbidden|Extra inputs"):
Recipe.model_validate(soup_dict(garnish="parsley"))
def test_recipe_requires_ingredients_and_steps() -> None:
with pytest.raises(ValidationError, match="ingredients"):
Recipe.model_validate(soup_dict(ingredients=[]))
with pytest.raises(ValidationError, match="steps"):
Recipe.model_validate(soup_dict(steps=[]))
def test_recipe_score_and_ids_are_sane() -> None:
with pytest.raises(ValidationError):
Recipe.model_validate(soup_dict(base_score=-1))
with pytest.raises(ValidationError):
Recipe.model_validate(soup_dict(id=""))
# ------------------------------------------------------------------------ RecipeBook
def test_book_reads_keys_ids_and_the_ingredient_catalog() -> None:
book = RecipeBook.model_validate(
book_dict(ingredients=["tomato", "onion", "plate"])
)
assert book.ids() == ("soup",)
assert book.get("soup").base_score == 40
assert book.ingredients == [ItemKind.TOMATO, ItemKind.ONION, ItemKind.PLATE]
def test_book_ids_follow_declaration_order() -> None:
book = RecipeBook.model_validate(
book_dict(recipes={"salad": soup_dict(id="salad"), "soup": soup_dict()})
)
assert book.ids() == ("salad", "soup")
def test_book_key_must_equal_the_recipe_id() -> None:
with pytest.raises(ValidationError, match="keys must equal"):
RecipeBook.model_validate(book_dict(recipes={"soup": soup_dict(id="chowder")}))
def test_get_unknown_recipe_names_the_known_ones() -> None:
book = RecipeBook.model_validate(book_dict())
with pytest.raises(KeyError, match="unknown recipe 'ghost'"):
book.get("ghost")
def test_required_station_kinds_for_a_two_step_recipe() -> None:
book = RecipeBook.model_validate(book_dict())
assert book.required_station_kinds("soup") == frozenset(
{StationKind.CHOP_BOARD, StationKind.STOVE}
)
def four_step_book() -> RecipeBook:
recipe = soup_dict(
steps=[
{"kind": "chop", "station": "chop_board", "seconds": 0.9},
{"kind": "cook", "station": "stove", "seconds": 4.0},
{"kind": "plate", "station": "prep", "seconds": 1.2},
{"kind": "serve", "station": "serve"},
]
)
return RecipeBook.model_validate(book_dict(recipes={"soup": recipe}))
def test_required_station_kinds_for_a_four_step_recipe() -> None:
assert four_step_book().required_station_kinds("soup") == frozenset(
{StationKind.CHOP_BOARD, StationKind.STOVE, StationKind.PREP, StationKind.SERVE}
)
def test_required_item_kinds_adds_a_plate_only_when_plating_is_required() -> None:
book = RecipeBook.model_validate(book_dict())
assert book.required_item_kinds("soup") == frozenset(
{ItemKind.TOMATO, ItemKind.ONION, ItemKind.PLATE}
)
unplated = RecipeBook.model_validate(
book_dict(recipes={"soup": soup_dict(requires_plating=False)})
)
assert unplated.required_item_kinds("soup") == frozenset({ItemKind.TOMATO, ItemKind.ONION})
def test_min_seconds_to_complete_is_steps_plus_one_handling_allowance() -> None:
book = RecipeBook.model_validate(book_dict())
assert book.min_seconds_to_complete("soup") == pytest.approx(0.9 + 5.0 + HANDLING_S)
def test_min_seconds_grows_with_more_steps() -> None:
minutes = four_step_book().min_seconds_to_complete("soup")
assert minutes == pytest.approx(0.9 + 4.0 + 1.2 + 0.0 + HANDLING_S)
@pytest.mark.parametrize(
"method",
["required_station_kinds", "required_item_kinds", "min_seconds_to_complete"],
)
def test_solver_helpers_reject_unknown_recipes(method: str) -> None:
book = RecipeBook.model_validate(book_dict())
with pytest.raises(KeyError, match="unknown recipe"):
getattr(book, method)("ghost")
# ------------------------------------------------------------------- load_recipe_book
def test_load_recipe_book_reads_a_real_yaml_file(tmp_path: Path) -> None:
path = write_book(
tmp_path,
"""
ingredients: [tomato, onion, plate]
recipes:
soup:
id: soup
name: Tomato Soup
base_score: 40
ingredients: [{kind: tomato, form: raw}, {kind: onion, form: raw, count: 2}]
steps:
- {kind: chop, station: chop_board, seconds: 0.9}
- {kind: cook, station: stove, seconds: 5}
""",
)
book = load_recipe_book(path)
assert book.ids() == ("soup",)
assert book.ingredients == [ItemKind.TOMATO, ItemKind.ONION, ItemKind.PLATE]
def test_load_recipe_book_reports_every_field_problem(tmp_path: Path) -> None:
path = write_book(
tmp_path,
"""
recipes:
soup:
id: soup
name: Soup
base_score: not-a-number
ingredients: [{kind: durian, form: raw}]
steps: [{kind: throw, station: chop_board}]
""",
)
with pytest.raises(ContentError) as excinfo:
load_recipe_book(path)
message = str(excinfo.value)
assert "base_score" in message
assert "ingredients.0.kind" in message
assert "steps.0.kind" in message
def test_load_recipe_book_rejects_unknown_top_level_keys(tmp_path: Path) -> None:
path = write_book(tmp_path, "recipes: {}\ndesserts: {}\n")
with pytest.raises(ContentError, match="desserts"):
load_recipe_book(path)
def test_load_recipe_book_missing_file_is_a_content_error(tmp_path: Path) -> None:
with pytest.raises(ContentError, match="cannot read content file"):
load_recipe_book(tmp_path / "nope.yaml")
def test_load_recipe_book_invalid_yaml_is_a_content_error(tmp_path: Path) -> None:
path = write_book(tmp_path, "recipes: [unclosed\n")
with pytest.raises(ContentError, match="invalid YAML"):
load_recipe_book(path)
def test_load_recipe_book_rejects_a_list_document(tmp_path: Path) -> None:
path = write_book(tmp_path, "- just\n- a\n- list\n")
with pytest.raises(ContentError, match="expected a YAML mapping"):
load_recipe_book(path)
def test_empty_file_reads_as_an_empty_mapping_then_fails_the_schema(tmp_path: Path) -> None:
path = write_book(tmp_path, "")
assert read_yaml_mapping(path) == {}
with pytest.raises(ContentError, match="recipes: Field required"):
load_recipe_book(path)
# ----------------------------------------------------------------- strictness plumbing
def test_assignment_revalidates_so_late_edits_cannot_corrupt_a_model() -> None:
ingredient = Ingredient(kind=ItemKind.TOMATO, form=ItemForm.RAW)
ingredient.count = 3
assert ingredient.count == 3
with pytest.raises(ValidationError):
ingredient.count = 0
def test_describe_validation_error_flattens_loc_paths(tmp_path: Path) -> None:
with pytest.raises(ValidationError) as excinfo:
RecipeBook.model_validate({})
described = describe_validation_error(excinfo.value, "book.yaml")
assert described.startswith("book.yaml: recipes: Field required")
+215
View File
@@ -0,0 +1,215 @@
"""Regression tests for the shipped campaign content (``levels/``, task 04).
These pin what campaign 02 ships, exactly as the phase task specifies: the
recipe book (ingredients + four named dishes with PLAN §5 step pipelines and
fixed base scores), the first two levels' tunables (pool, concurrency, spawn
interval, deadline, time limit, target), the blurb floor, campaign ordering,
and the level-1 → level-2 escalation contract (≥2 axes up). They read the
real files from ``levels/`` — not a copy — so editing shipped content that
a test pins fails here, in review, instead of at a player's startup.
Startup behaviour and the HTTP surface live in ``test_content_startup.py``
and ``tests/integration/test_content_api.py``; the generic loader and
validator have their own unit suites.
"""
from __future__ import annotations
from pathlib import Path
import pytest
from undercooked.sim.content import ContentBundle, load_content
from undercooked.sim.level import Level
from undercooked.sim.recipe import RecipeBook, load_recipe_book
from undercooked.sim.validation import escalation_axes, validate_bundle
REPO_ROOT = Path(__file__).resolve().parents[2]
LEVELS_DIR = REPO_ROOT / "levels"
RECIPES_PATH = LEVELS_DIR / "recipes.yaml"
LEVEL_FILES = sorted(
path for path in LEVELS_DIR.glob("*.yaml") if path.name != "recipes.yaml"
)
#: The campaign order this task ships (PLAN §8: soup tutorial, then salad).
SHIPPED_IDS = ["01_first_kitchen", "02_double_shift"]
#: PLAN §5 station pipelines as (interaction, station) pairs:
#: pick at the shelf -> chop at the board -> cook on the stove -> plate at
#: prep -> serve at the window; a dish skips the stages its recipe lacks.
EXPECTED_STEPS = {
"soup": [
("pick", "spawn_shelf"),
("chop", "chop_board"),
("cook", "stove"),
("plate", "prep"),
("serve", "serve"),
],
"salad": [
("pick", "spawn_shelf"),
("chop", "chop_board"),
("plate", "prep"),
("serve", "serve"),
],
"fried_rice": [
("pick", "spawn_shelf"),
("cook", "stove"),
("plate", "prep"),
("serve", "serve"),
],
"burger": [
("pick", "spawn_shelf"),
("chop", "chop_board"),
("cook", "stove"),
("plate", "prep"),
("serve", "serve"),
],
}
@pytest.fixture(scope="module")
def book() -> RecipeBook:
return load_recipe_book(RECIPES_PATH)
@pytest.fixture(scope="module")
def bundle() -> ContentBundle:
return load_content(LEVELS_DIR, RECIPES_PATH)
def level_of(bundle: ContentBundle, level_id: str) -> Level:
return bundle.by_id[level_id]
# --- the recipe book ---------------------------------------------------------
def test_recipe_book_ships_the_named_ingredient_catalog(book: RecipeBook) -> None:
assert set(book.ingredients) == {
"tomato",
"onion",
"lettuce",
"dough",
"patty",
"rice",
"fish",
"plate",
}
def test_recipe_book_ships_exactly_the_four_dishes(book: RecipeBook) -> None:
assert set(book.recipes) == {"soup", "salad", "fried_rice", "burger"}
@pytest.mark.parametrize(
("recipe_id", "base_score"),
[("soup", 40), ("salad", 30), ("fried_rice", 50), ("burger", 70)],
)
def test_dishes_carry_their_pinned_base_scores(
book: RecipeBook, recipe_id: str, base_score: int
) -> None:
assert book.get(recipe_id).base_score == base_score
@pytest.mark.parametrize("recipe_id", sorted(EXPECTED_STEPS))
def test_dishes_follow_the_plan_station_pipelines(
book: RecipeBook, recipe_id: str
) -> None:
recipe = book.get(recipe_id)
assert [(step.kind, step.station.value) for step in recipe.steps] == (
EXPECTED_STEPS[recipe_id]
)
@pytest.mark.parametrize("recipe_id", sorted(EXPECTED_STEPS))
def test_every_dish_consumes_catalogued_ingredients(
book: RecipeBook, recipe_id: str
) -> None:
recipe = book.get(recipe_id)
assert recipe.ingredients, recipe_id
assert {item.kind for item in recipe.ingredients} <= set(book.ingredients)
# --- the level files ----------------------------------------------------------
def test_every_yaml_file_in_levels_is_a_shipped_level(bundle: ContentBundle) -> None:
stems = {path.stem for path in LEVEL_FILES}
assert stems, "levels/ must ship at least one level"
assert stems == set(bundle.by_id)
def test_shipped_campaign_validates_without_warnings(bundle: ContentBundle) -> None:
report = validate_bundle(bundle)
assert report.ok, report.render()
assert not report.warnings, report.render()
def test_campaign_order_is_unique_and_starts_at_one(bundle: ContentBundle) -> None:
orders = [level.order for level in bundle.levels]
assert orders == list(range(1, len(bundle.levels) + 1))
def test_campaign_ships_the_first_two_levels(bundle: ContentBundle) -> None:
assert [level.id for level in bundle.levels] == SHIPPED_IDS
@pytest.mark.parametrize("level_id", SHIPPED_IDS)
def test_each_level_has_a_readable_premise(bundle: ContentBundle, level_id: str) -> None:
blurb = level_of(bundle, level_id).blurb
assert len(blurb) >= 20
@pytest.mark.parametrize("level_id", SHIPPED_IDS)
def test_difficulty_is_within_the_declared_range(
bundle: ContentBundle, level_id: str
) -> None:
assert 1 <= level_of(bundle, level_id).difficulty <= 10
@pytest.mark.parametrize("level_id", SHIPPED_IDS)
def test_pool_recipes_exist_in_the_book(
bundle: ContentBundle, book: RecipeBook, level_id: str
) -> None:
recipe_ids = level_of(bundle, level_id).orders.pool
assert recipe_ids
for recipe_id in recipe_ids:
assert recipe_id in book.recipes
# --- the two levels' pinned tunables (task 04) --------------------------------
def test_level_one_is_the_soup_tutorial(bundle: ContentBundle) -> None:
level = level_of(bundle, "01_first_kitchen")
assert level.order == 1
assert level.difficulty == 1
assert level.time_limit == 150
assert level.target_score == 45
assert level.orders.pool == ["soup"]
assert level.orders.concurrent == 1
assert tuple(level.orders.spawn_interval) == (16, 20)
assert level.orders.deadline == 60
assert level.hazards == []
def test_level_two_adds_salad_and_pressure(bundle: ContentBundle) -> None:
level = level_of(bundle, "02_double_shift")
assert level.order == 2
assert level.difficulty == 2
assert level.time_limit == 130
assert level.target_score == 80
assert level.orders.pool == ["soup", "salad"]
assert level.orders.concurrent == 2
assert tuple(level.orders.spawn_interval) == (12, 16)
assert level.orders.deadline == 52
assert level.hazards == []
def test_level_two_escalates_at_least_two_axes(bundle: ContentBundle) -> None:
before = escalation_axes(level_of(bundle, "01_first_kitchen"))
after = escalation_axes(level_of(bundle, "02_double_shift"))
moved_up = sorted(
name for name, value in after.items() if value > before[name] + 1e-9
)
assert len(moved_up) >= 2, f"only these axes moved: {moved_up}"