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