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:
@@ -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
|
||||
Reference in New Issue
Block a user