Files
undercooked/tests/unit/test_content_loader.py
T
ducoterra 3426264ffb 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.
2026-10-06 11:03:28 -04:00

282 lines
10 KiB
Python

"""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)