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.
282 lines
10 KiB
Python
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)
|