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.
187 lines
5.9 KiB
Python
187 lines
5.9 KiB
Python
"""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
|