Files
undercooked/tests/unit/test_content_not_hardcoded.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

235 lines
9.1 KiB
Python

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