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
+839
View File
@@ -0,0 +1,839 @@
"""Unit tests for the ``undercooked-levels`` author CLI (phase 02 task 05).
The CLI is the author's half of the content contract, so these tests pin the
three things a human depends on:
* **the same code path as the server** — `validate` is `load_content` +
`validate_bundle`, so a green run here is a green `create_app()`;
* **the output contract** — one line per level, the named-rule report, a
`--json` document a script can parse, the `show --grid` reachability overlay
that makes a sealed pocket visible, and the `axes` table phase 11 will feed;
* **the exit codes** — 0 usable, 1 a rule is broken, 2 nothing usable to work
with (load failure, unknown id, refused overwrite, usage error).
Content is addressed explicitly with ``--dir`` (the session-wide
`UNDERCOOKED_LEVELS_DIR` fixture points at an empty campaign), plus one test
that deletes the variable and ``chdir``s to prove the documented commands still
resolve ``levels/`` from the checkout rather than from luck about cwd.
"""
from __future__ import annotations
import json
import os
import shutil
import subprocess
import sys
import tomllib
from collections.abc import Iterator, Sequence
from dataclasses import replace
from pathlib import Path
import pytest
import yaml
from undercooked.config import Settings, load_settings
from undercooked.server.routes_content import RECIPES_FILENAME
from undercooked.sim.content import ContentBundle, load_content, load_level_file
from undercooked.sim.recipe import ContentError, RecipeBook, load_recipe_book
from undercooked.sim.validation import (
AXIS_NAMES,
LevelViolation,
ValidationReport,
escalation_axes,
)
from undercooked.tools import levels
from undercooked.tools.levels import (
CHECKOUT_ROOT,
EXIT_OK,
EXIT_UNUSABLE,
EXIT_VIOLATIONS,
LIST_HEADER,
STARTER_HEIGHT,
_assign_chars,
_char_for,
_CliError,
_level_status,
describe_level,
main,
reachability_overlay,
render_starter_yaml,
resolve_content_paths,
)
pytestmark = pytest.mark.unit
REPO_ROOT = Path(__file__).resolve().parents[2]
SHIPPED_LEVELS = REPO_ROOT / "levels"
SHIPPED_RECIPES = SHIPPED_LEVELS / RECIPES_FILENAME
BAD_LEVELS = REPO_ROOT / "tests" / "fixtures" / "levels" / "_bad"
PYPROJECT = REPO_ROOT / "pyproject.toml"
def run(argv: Sequence[str]) -> int:
"""The console script's entry point, in-process (same target as the wrapper)."""
return main(list(argv))
@pytest.fixture()
def shipped_bundle() -> ContentBundle:
return load_content(SHIPPED_LEVELS, SHIPPED_RECIPES)
@pytest.fixture()
def campaign_copy(tmp_path: Path) -> Iterator[Path]:
"""A writable copy of the shipped campaign that `new` may extend."""
target = tmp_path / "campaign"
shutil.copytree(SHIPPED_LEVELS, target)
yield target
def write_campaign(tmp_path: Path, level_yaml: str, book_yaml: str) -> Path:
"""A throwaway campaign directory built from two YAML documents."""
directory = tmp_path / "mini"
directory.mkdir(parents=True, exist_ok=True)
(directory / RECIPES_FILENAME).write_text(book_yaml, encoding="utf-8")
(directory / "01_mini.yaml").write_text(level_yaml, encoding="utf-8")
return directory
# --- path resolution -----------------------------------------------------------
def test_absolute_flags_win_over_everything() -> None:
levels_dir, recipes = resolve_content_paths(
Settings(levels_dir=SHIPPED_LEVELS, recipes_path=SHIPPED_RECIPES),
Path("/content/levels"),
Path("/content/book.yaml"),
)
assert levels_dir == Path("/content/levels")
assert recipes == Path("/content/book.yaml")
def test_the_recipe_book_defaults_to_the_one_inside_the_levels_dir() -> None:
levels_dir, recipes = resolve_content_paths(Settings(levels_dir=SHIPPED_LEVELS))
assert levels_dir == SHIPPED_LEVELS
assert recipes == SHIPPED_RECIPES
def test_the_recipe_book_flag_beats_the_environment(tmp_path: Path, monkeypatch) -> None:
env_book = tmp_path / "env-book.yaml"
monkeypatch.setenv("UNDERCOOKED_RECIPES_PATH", str(env_book))
_, from_env = resolve_content_paths(load_settings())
assert from_env == env_book
_, from_flag = resolve_content_paths(load_settings(), None, SHIPPED_RECIPES)
assert from_flag == SHIPPED_RECIPES
def test_a_relative_path_that_exists_here_is_used_here(tmp_path: Path, monkeypatch) -> None:
base = tmp_path.resolve()
(base / "levels").mkdir()
monkeypatch.chdir(base)
levels_dir, recipes = resolve_content_paths(Settings(levels_dir=Path("levels")))
assert levels_dir == base / "levels"
assert recipes == base / "levels" / RECIPES_FILENAME
def test_a_relative_path_with_nothing_local_anchors_to_the_checkout(
tmp_path: Path, monkeypatch
) -> None:
"""The documented `undercooked-levels validate` works from any directory."""
monkeypatch.chdir(tmp_path)
levels_dir, recipes = resolve_content_paths(Settings(levels_dir=Path("levels")))
assert levels_dir == CHECKOUT_ROOT / "levels"
assert recipes == CHECKOUT_ROOT / "levels" / RECIPES_FILENAME
assert levels_dir.is_dir()
def test_a_path_that_exists_nowhere_is_reported_as_it_was_typed(
tmp_path: Path, monkeypatch
) -> None:
monkeypatch.chdir(tmp_path)
levels_dir, recipes = resolve_content_paths(
Settings(levels_dir=Path("nope/levels"), recipes_path=Path("nope/book.yaml"))
)
assert levels_dir == Path("nope/levels")
assert recipes == Path("nope/book.yaml")
def test_a_home_relative_path_is_expanded() -> None:
levels_dir, _ = resolve_content_paths(Settings(levels_dir=Path("~/levels")))
assert levels_dir == Path.home() / "levels"
# --- validate -------------------------------------------------------------------
def test_validate_is_green_on_the_shipped_campaign_and_prints_one_line_per_level(
capsys: pytest.CaptureFixture[str], shipped_bundle: ContentBundle
) -> None:
assert run(["validate", "--dir", str(SHIPPED_LEVELS)]) == EXIT_OK
lines = capsys.readouterr().out.splitlines()
assert lines[-1] == "content OK: no violations"
assert len(lines) == len(shipped_bundle.levels) + 1
for level, line in zip(shipped_bundle.levels, lines[:-1], strict=True):
assert line.startswith(level.id)
assert "ok" in line
assert f"{level.grid.width}x{level.grid.height}" in line
def test_validate_exits_1_and_names_every_rule_it_hit(
capsys: pytest.CaptureFixture[str]
) -> None:
assert run(["validate", "--dir", str(BAD_LEVELS)]) == EXIT_VIOLATIONS
out = capsys.readouterr().out
assert "99_broken_room" in out
assert "4 error(s)" in out
for rule in ("SERVE_REACHABLE", "RECIPE_STATION_MISSING", "TARGET_IMPLAUSIBLE"):
assert rule in out
def test_validate_json_is_machine_readable_when_green(
capsys: pytest.CaptureFixture[str], shipped_bundle: ContentBundle
) -> None:
assert run(["validate", "--dir", str(SHIPPED_LEVELS), "--json"]) == EXIT_OK
payload = json.loads(capsys.readouterr().out)
assert payload["ok"] is True
assert payload["errors"] == []
assert payload["warnings"] == []
assert payload["counts"] == {"errors": 0, "warnings": 0}
assert [level["id"] for level in payload["levels"]] == [
level.id for level in shipped_bundle.levels
]
def test_validate_json_carries_the_rule_hits_and_their_detail(
capsys: pytest.CaptureFixture[str]
) -> None:
assert run(["validate", "--dir", str(BAD_LEVELS), "--json"]) == EXIT_VIOLATIONS
payload = json.loads(capsys.readouterr().out)
errors = payload["errors"]
assert payload["ok"] is False
assert payload["counts"] == {"errors": len(errors), "warnings": len(payload["warnings"])}
assert {error["rule"] for error in errors} >= {
"SERVE_REACHABLE",
"RECIPE_STATION_MISSING",
"TARGET_IMPLAUSIBLE",
}
assert {error["level"] for error in errors} == {"99_broken_room"}
assert {"recipe": "soup", "station": "stove"} in [error["detail"] for error in errors]
keys = [(error["level"], error["rule"], error["message"]) for error in errors]
assert keys == sorted(keys)
def test_validate_json_is_what_python_m_json_tool_accepts(
capsys: pytest.CaptureFixture[str], tmp_path: Path
) -> None:
"""The documented pipe: `undercooked-levels validate --json | python -m json.tool`."""
assert run(["validate", "--dir", str(BAD_LEVELS), "--json"]) == EXIT_VIOLATIONS
raw = capsys.readouterr().out
formatted = subprocess.run(
[sys.executable, "-m", "json.tool"],
input=raw,
capture_output=True,
text=True,
timeout=60,
check=False,
)
assert formatted.returncode == 0, formatted.stderr
assert "SERVE_REACHABLE" in formatted.stdout
assert json.loads(formatted.stdout)["ok"] is False
assert tmp_path # nothing is written; the report only goes to stdout
def test_validate_names_a_missing_directory_and_exits_2(
capsys: pytest.CaptureFixture[str], tmp_path: Path
) -> None:
missing = tmp_path / "not-here"
assert run(["validate", "--dir", str(missing)]) == EXIT_UNUSABLE
err = capsys.readouterr().err
assert err.startswith("undercooked-levels:")
assert "not-here" in err
def test_validate_reports_a_broken_recipe_book_as_exit_2(
capsys: pytest.CaptureFixture[str], tmp_path: Path
) -> None:
directory = tmp_path / "levels"
directory.mkdir()
(directory / RECIPES_FILENAME).write_text("\tthis: [is not", encoding="utf-8")
assert run(["validate", "--dir", str(directory)]) == EXIT_UNUSABLE
assert "recipes.yaml" in capsys.readouterr().err
def test_validate_follows_the_levels_dir_from_the_environment(
capsys: pytest.CaptureFixture[str], monkeypatch, campaign_copy: Path
) -> None:
monkeypatch.setenv("UNDERCOOKED_LEVELS_DIR", str(campaign_copy))
assert run(["validate"]) == EXIT_OK
assert "02_double_shift" in capsys.readouterr().out
@pytest.mark.parametrize(
"argv",
[
["validate"],
["list"],
["axes"],
["show", "01_first_kitchen", "--grid"],
],
)
def test_every_subcommand_resolves_levels_from_the_checkout_not_from_cwd(
argv: list[str], capsys: pytest.CaptureFixture[str], tmp_path: Path, monkeypatch
) -> None:
"""No `--dir`, empty cwd, unset env: the built-in `levels` still finds campaign 02."""
monkeypatch.delenv("UNDERCOOKED_LEVELS_DIR", raising=False)
monkeypatch.delenv("UNDERCOOKED_RECIPES_PATH", raising=False)
monkeypatch.chdir(tmp_path)
assert run(argv) == EXIT_OK
assert "01_first_kitchen" in capsys.readouterr().out
def test_level_status_counts_warnings_of_a_warning_only_level() -> None:
bundle = load_content(SHIPPED_LEVELS, SHIPPED_RECIPES)
level = bundle.levels[0]
warning = LevelViolation(rule="TARGET_IMPLAUSIBLE", level_id=level.id, message="tight")
clean = _level_status(level, ValidationReport())
noisy = _level_status(level, ValidationReport(violations=(), warnings=(warning,)))
error = LevelViolation(rule="SERVE_REACHABLE", level_id=level.id, message="sealed")
broken = _level_status(level, ValidationReport(violations=(error,), warnings=(warning,)))
assert clean == f"{level.id:<28} {'ok':<24} {level.grid.width}x{level.grid.height}" + (
f", {len(level.stations)} stations"
)
assert "0 error(s)" not in clean
assert ", 1 warning(s)" in noisy
assert "1 error(s), 1 warning(s)" in broken
# --- list -----------------------------------------------------------------------
def test_list_prints_one_line_per_level_in_campaign_order(
capsys: pytest.CaptureFixture[str], shipped_bundle: ContentBundle
) -> None:
assert run(["list", "--dir", str(SHIPPED_LEVELS)]) == EXIT_OK
lines = capsys.readouterr().out.splitlines()
assert lines[0] == LIST_HEADER
assert len(lines) == 1 + len(shipped_bundle.levels)
for level, line in zip(shipped_bundle.levels, lines[1:], strict=True):
assert line.startswith(f"{level.order:>5} {level.id:<28}")
assert str(level.target_score) in line
assert f"{level.time_limit:>6.1f}" in line
for recipe_id in level.orders.pool:
assert recipe_id in line
def test_list_of_an_empty_campaign_is_only_the_header(
capsys: pytest.CaptureFixture[str], tmp_path: Path
) -> None:
empty = tmp_path / "empty"
empty.mkdir()
shutil.copy(SHIPPED_RECIPES, empty / RECIPES_FILENAME)
assert run(["list", "--dir", str(empty)]) == EXIT_OK
assert capsys.readouterr().out.splitlines() == [LIST_HEADER]
# --- show -----------------------------------------------------------------------
def test_show_prints_the_resolved_level(
capsys: pytest.CaptureFixture[str], shipped_bundle: ContentBundle
) -> None:
level = shipped_bundle.levels[1]
assert run(["show", level.id, "--dir", str(SHIPPED_LEVELS)]) == EXIT_OK
out = capsys.readouterr().out
assert out.startswith(f"{level.id} - {level.name}")
for label in (
"order",
"difficulty",
"time_limit",
"target_score",
"grid",
"spawns",
"stations",
"orders",
"limits",
"hazards",
"reachability",
"recipes",
"blurb",
"escalation_notes",
):
assert f"{label:<18}" in out
assert "spawn_shelf x" in out
assert "map (as authored)" not in out # --grid is opt-in
def test_show_grid_adds_the_authored_map_and_the_reachability_overlay(
capsys: pytest.CaptureFixture[str], shipped_bundle: ContentBundle
) -> None:
level = shipped_bundle.levels[0]
assert run(["show", level.id, "--grid", "--dir", str(SHIPPED_LEVELS)]) == EXIT_OK
out = capsys.readouterr().out
assert "map (as authored)" in out
for row in level.spec.grid:
assert f" {row}" in out
assert ". reachable" in out
assert "?" not in out.split("reachability")[1] # the shipped kitchen has no dead pockets
assert f"{level.grid.width}x{level.grid.height}" in out
def test_show_grid_marks_a_sealed_pocket_as_unreachable(
capsys: pytest.CaptureFixture[str]
) -> None:
assert run(["show", "99_broken_room", "--grid", "--dir", str(BAD_LEVELS)]) == EXIT_OK
out = capsys.readouterr().out
overlay = out.split("? walkable but cut off")[1]
assert "??" in overlay
assert "????#" in overlay # the walled-in servery row
assert "walkable tiles are reachable from the spawns" in overlay
def test_show_rejects_an_unknown_level_id(
capsys: pytest.CaptureFixture[str], shipped_bundle: ContentBundle
) -> None:
assert run(["show", "07_no_such_kitchen", "--dir", str(SHIPPED_LEVELS)]) == EXIT_UNUSABLE
err = capsys.readouterr().err
assert "no level '07_no_such_kitchen'" in err
assert "01_first_kitchen" in err # it says what does exist
# --- axes -----------------------------------------------------------------------
def test_axes_table_lists_every_axis_for_every_level_in_campaign_order(
capsys: pytest.CaptureFixture[str], shipped_bundle: ContentBundle
) -> None:
assert run(["axes", "--dir", str(SHIPPED_LEVELS)]) == EXIT_OK
lines = capsys.readouterr().out.splitlines()
header = lines[0]
assert header.startswith("LEVEL")
for name in AXIS_NAMES:
assert name in header
assert set(AXIS_NAMES) == set(escalation_axes(shipped_bundle.levels[0]))
assert len(lines) == 1 + len(shipped_bundle.levels)
for level, line in zip(shipped_bundle.levels, lines[1:], strict=True):
assert line.startswith(f"{level.id:<28}{level.order:>6}")
axes = escalation_axes(level)
for name in AXIS_NAMES:
assert f"{axes[name]:>20.4f}" in line
def test_axes_of_an_empty_campaign_is_only_the_header(
capsys: pytest.CaptureFixture[str], tmp_path: Path
) -> None:
empty = tmp_path / "empty"
empty.mkdir()
shutil.copy(SHIPPED_RECIPES, empty / RECIPES_FILENAME)
assert run(["axes", "--dir", str(empty)]) == EXIT_OK
assert len(capsys.readouterr().out.splitlines()) == 1
# --- new ------------------------------------------------------------------------
def test_new_writes_a_starter_level_and_validates_it(
capsys: pytest.CaptureFixture[str], campaign_copy: Path, shipped_bundle: ContentBundle
) -> None:
assert run(["new", "03_zero_cool", "--dir", str(campaign_copy)]) == EXIT_OK
out = capsys.readouterr().out
assert f"wrote {campaign_copy / '03_zero_cool.yaml'}" in out
assert "content OK: no violations" in out
written = campaign_copy / "03_zero_cool.yaml"
body = yaml.safe_load(written.read_text(encoding="utf-8"))
top_difficulty = max(level.difficulty for level in shipped_bundle.levels)
top_order = max(level.order for level in shipped_bundle.levels)
assert body["id"] == "03_zero_cool"
assert body["order"] == top_order + 1
assert body["difficulty"] == top_difficulty + 1
# It is a real level: it loads, and the campaign now carries three of them.
level = load_level_file(written)
assert level.name == "Zero Cool"
assert load_content(campaign_copy, SHIPPED_RECIPES).levels[-1].id == "03_zero_cool"
# ...and the scaffolded kitchen is self-consistent: every grid letter used is
# declared, and nothing declared is left unused.
assert set(body["tiles"]) == {cell for row in body["grid"] for cell in row}
def test_new_uses_the_requested_order(
capsys: pytest.CaptureFixture[str], campaign_copy: Path
) -> None:
assert run(["new", "07_late_shift", "--order", "7", "--dir", str(campaign_copy)]) == EXIT_OK
body = yaml.safe_load((campaign_copy / "07_late_shift.yaml").read_text(encoding="utf-8"))
assert body["order"] == 7
capsys.readouterr()
def test_new_refuses_to_overwrite_an_existing_level(
capsys: pytest.CaptureFixture[str], campaign_copy: Path
) -> None:
target = campaign_copy / "01_first_kitchen.yaml"
before = target.read_text(encoding="utf-8")
assert run(["new", "01_first_kitchen", "--dir", str(campaign_copy)]) == EXIT_UNUSABLE
err = capsys.readouterr().err
assert "already exists" in err
assert "refusing to overwrite" in err
assert target.read_text(encoding="utf-8") == before
@pytest.mark.parametrize("candidate", ["ZeroCool", "03 zero cool", "_private", "03-second"])
def test_new_rejects_an_id_that_is_not_a_filename_safe_slug(
candidate: str, capsys: pytest.CaptureFixture[str], campaign_copy: Path
) -> None:
assert run(["new", candidate, "--dir", str(campaign_copy)]) == EXIT_UNUSABLE
assert "slug" in capsys.readouterr().err
assert not (campaign_copy / f"{candidate}.yaml").exists()
def test_new_rejects_an_order_below_one(
capsys: pytest.CaptureFixture[str], campaign_copy: Path
) -> None:
assert run(["new", "03_probe", "--order", "0", "--dir", str(campaign_copy)]) == EXIT_UNUSABLE
assert "--order must be at least 1" in capsys.readouterr().err
def test_new_needs_a_readable_campaign(
capsys: pytest.CaptureFixture[str], tmp_path: Path
) -> None:
assert run(["new", "03_probe", "--dir", str(tmp_path / "nothing")]) == EXIT_UNUSABLE
assert "undercooked-levels:" in capsys.readouterr().err
@pytest.mark.parametrize(
"error",
[OSError("disk full"), OSError(28, "No space left on device")],
ids=["no-strerror", "with-strerror"],
)
def test_new_reports_a_write_failure(
error: OSError,
capsys: pytest.CaptureFixture[str],
campaign_copy: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
def refuse(self: Path, *args: object, **kwargs: object) -> int:
raise error
monkeypatch.setattr(Path, "write_text", refuse)
assert run(["new", "03_probe", "--dir", str(campaign_copy)]) == EXIT_UNUSABLE
err = capsys.readouterr().err
assert "cannot write" in err
assert (error.strerror or str(error)) in err
def test_new_reports_a_file_the_loader_rejects(
capsys: pytest.CaptureFixture[str], campaign_copy: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
def refuse(path: Path | str) -> object:
raise ContentError("the loader hated the scaffold")
monkeypatch.setattr(levels, "load_level_file", refuse)
assert run(["new", "03_probe", "--dir", str(campaign_copy)]) == EXIT_UNUSABLE
assert "the loader hated the scaffold" in capsys.readouterr().err
# --- the scaffold itself --------------------------------------------------------
def test_the_scaffold_is_built_from_the_recipe_book_not_a_template_kitchen() -> None:
book = RecipeBook.model_validate(
{
"recipes": {
"broth": {
"id": "broth",
"name": "Plain Broth",
"base_score": 20,
"ingredients": [
{"kind": "rice", "form": "raw"},
{"kind": "fish", "form": "raw"},
],
"steps": [
{"kind": "pick", "station": "spawn_shelf"},
{"kind": "wash", "station": "sink"},
{"kind": "cook", "station": "stove"},
{"kind": "serve", "station": "serve"},
],
}
}
}
)
text = render_starter_yaml(level_id="05_broth_house", order=5, difficulty=4, book=book)
body = yaml.safe_load(text)
assert body["id"] == "05_broth_house"
assert body["name"] == "Broth House"
assert body["order"] == 5
assert body["difficulty"] == 4
assert body["orders"]["pool"] == ["broth"]
stations = {spec["station"] for spec in body["tiles"].values() if "station" in spec}
items = {spec["item"] for spec in body["tiles"].values() if "item" in spec}
assert stations == {"spawn_shelf", "sink", "stove", "serve"} # no chop board: no chop step
assert items == {"rice", "fish"}
assert set(body["tiles"]) == {cell for row in body["grid"] for cell in row}
assert len({len(row) for row in body["grid"]}) == 1
def test_the_scaffold_of_the_shipped_book_shelves_that_books_first_dish() -> None:
book = load_content(SHIPPED_LEVELS, SHIPPED_RECIPES).recipe_book
first = book.ids()[0]
body = yaml.safe_load(
render_starter_yaml(level_id="05_probe", order=5, difficulty=1, book=book)
)
expected_items = {
ingredient.kind.value
for ingredient in book.get(first).ingredients
if str(ingredient.form) == "raw"
}
assert {spec["item"] for spec in body["tiles"].values() if "item" in spec} == expected_items
assert body["orders"]["pool"] == [first]
def test_the_scaffold_needs_something_to_cook() -> None:
with pytest.raises(_CliError):
render_starter_yaml(level_id="05_probe", order=1, difficulty=1, book=RecipeBook(recipes={}))
def test_the_scaffold_keeps_the_spawn_far_enough_from_the_window() -> None:
book = load_content(SHIPPED_LEVELS, SHIPPED_RECIPES).recipe_book
body = yaml.safe_load(
render_starter_yaml(level_id="05_probe", order=1, difficulty=5, book=book)
)
width = len(body["grid"][0])
assert body["grid"][0] == "#" * width == body["grid"][-1]
assert len(body["grid"]) == STARTER_HEIGHT
fixtures = body["grid"][4]
serve_x = fixtures.index(
next(char for char, spec in body["tiles"].items() if spec.get("station") == "serve")
)
assert fixtures[serve_x] != "." # the window really stands on the fixture row
for x, y in body["spawns"]:
assert abs(x - serve_x) + abs(y - 4) >= 3 # TRAVEL_TOO_SHORT's floor
def test_grid_letters_stay_unique_when_a_preferred_letter_is_taken() -> None:
free = {"#", "."}
assert _char_for("T", free) == "T" # an untouched preference wins
assert _char_for("T", free | {"T"}) not in free | {"T"} # taken: a spare instead
assert _char_for(None, free) not in free
used = set(free)
assigned = _assign_chars(
["tomato", "onion", "lettuce", "rice"],
{"tomato": "T", "onion": "T", "lettuce": "T", "rice": "R"},
used,
)
assert list(assigned) == ["tomato", "onion", "lettuce", "rice"]
assert assigned["tomato"] == "T" # first in line keeps its preferred letter
assert assigned["onion"] != "T" # the collisions fall back to spares
assert assigned["rice"] == "R"
assert len(set(assigned.values())) == 4
assert used == {"#", ".", *assigned.values()} # the caller's set is the ledger
# --- describe_level / overlay directly ------------------------------------------
MINI_BOOK = """\
recipes:
snack:
id: snack
name: Plain Snack
base_score: 20
ingredients:
- {kind: tomato, form: raw}
steps:
- {kind: pick, station: spawn_shelf}
- {kind: serve, station: serve}
"""
MINI_LEVEL = """\
id: 01_mini
name: Mini
order: 1
time_limit: 90
target_score: 40
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: [snack]
concurrent: 1
spawn_interval: [10, 14]
deadline: 40
"""
#: The same kitchen with a cupboard walled in at (3, 3) and (4, 3): walkable
#: floor no spawn can reach, which is exactly what the overlay exists to show.
POCKET_LEVEL = MINI_LEVEL.replace(
"""\
id: 01_mini
name: Mini""",
"""\
id: 02_pocket
name: Pocket""",
).replace(
"""\
- "#######"
- "#T....#"
- "#.#.#.#"
- "#....X#"
- "#######""",
"""\
- "########"
- "#T....X#"
- "#.####.#"
- "#.#BB#.#"
- "#.####.#"
- "########""",
).replace(
' "X": {kind: floor, station: serve}',
' "X": {kind: floor, station: serve}\n "B": {kind: floor}',
)
def test_describe_marks_missing_prose_as_none(tmp_path: Path) -> None:
directory = write_campaign(tmp_path, MINI_LEVEL, MINI_BOOK)
level = load_level_file(directory / "01_mini.yaml")
out = "\n".join(describe_level(level, load_recipe_book(directory / RECIPES_FILENAME)))
assert "snack: Plain Snack" in out # the prose is missing, the recipe is not
assert out.count("(none)") == 2 # blurb and escalation_notes say so out loud
def test_describe_reports_a_pool_recipe_the_book_does_not_define(tmp_path: Path) -> None:
directory = write_campaign(tmp_path, MINI_LEVEL, MINI_BOOK)
level = load_level_file(directory / "01_mini.yaml")
out = "\n".join(describe_level(level, RecipeBook(recipes={})))
assert "snack: MISSING from the recipe book" in out
def test_describe_counts_hazards(tmp_path: Path) -> None:
level_yaml = MINI_LEVEL.replace("hazards: []", "") + (
"hazards:\n - {type: spill, x: 4, y: 1}\n - {type: fire, x: 2, y: 1}\n"
)
directory = tmp_path / "haz"
directory.mkdir()
(directory / RECIPES_FILENAME).write_text(MINI_BOOK, encoding="utf-8")
(directory / "01_mini.yaml").write_text(level_yaml, encoding="utf-8")
level = load_level_file(directory / "01_mini.yaml")
lines = describe_level(level, load_recipe_book(directory / RECIPES_FILENAME))
hazards = next(line for line in lines if line.startswith("hazards"))
assert "fire x1" in hazards
assert "spill x1" in hazards
def test_describe_says_none_when_a_level_has_no_stations(tmp_path: Path) -> None:
"""A hand-built level with no stations (content forbids one: no serve window).
`LevelDef` requires exactly one serve tile, so the station-less `Level` can
only come from a hand-built dataclass — which is what a phase-03 caller
holding a partially-built level does.
"""
directory = write_campaign(tmp_path, MINI_LEVEL, MINI_BOOK)
level = load_level_file(directory / "01_mini.yaml")
assert level.stations # the authored level has some; the stripped one has none
lines = describe_level(replace(level, stations=()), RecipeBook(recipes={}))
empty = "stations".ljust(18) + "none"
assert next(line for line in lines if line.startswith("stations")) == empty
def test_reachability_overlay_only_uses_the_three_terrain_characters(tmp_path: Path) -> None:
directory = write_campaign(tmp_path, POCKET_LEVEL, MINI_BOOK)
level = load_level_file(directory / "01_mini.yaml")
overlay = reachability_overlay(level)
assert {cell for row in overlay for cell in row} == {".", "?", "#"}
# the walled-in pocket reads as `??`; everything else the cooks can walk to.
assert overlay[3] == "#.#??#.#"
assert sum(row.count("?") for row in overlay) == 2
# --- entry point ----------------------------------------------------------------
def test_no_subcommand_is_a_usage_error(capsys: pytest.CaptureFixture[str]) -> None:
assert run([]) == EXIT_UNUSABLE
err = capsys.readouterr().err
assert "usage: undercooked-levels" in err
assert "arguments are required: COMMAND" in err
def test_an_unknown_subcommand_is_a_usage_error(capsys: pytest.CaptureFixture[str]) -> None:
assert run(["scullery"]) == EXIT_UNUSABLE
err = capsys.readouterr().err
assert "usage: undercooked-levels" in err
assert "invalid choice: 'scullery'" in err
def test_an_unknown_flag_is_a_usage_error(capsys: pytest.CaptureFixture[str]) -> None:
assert run(["validate", "--nope"]) == EXIT_UNUSABLE
assert "unrecognized arguments" in capsys.readouterr().err
@pytest.mark.parametrize("argv", [["--help"], ["validate", "--help"], ["new", "-h"]])
def test_help_exits_cleanly(argv: list[str], capsys: pytest.CaptureFixture[str]) -> None:
assert run(argv) == EXIT_OK
out = capsys.readouterr().out
assert "usage: undercooked-levels" in out
def test_help_documents_the_subcommands_and_exit_codes(
capsys: pytest.CaptureFixture[str],
) -> None:
assert run(["--help"]) == EXIT_OK
out = capsys.readouterr().out
for command in ("validate", "list", "show", "new", "axes"):
assert command in out
assert "0 ok, 1 a level breaks a validation rule" in out
def test_the_parser_builds_with_a_handler_for_every_subcommand() -> None:
parser = levels.build_parser()
for command, expected in (
(["validate"], "validate"),
(["list"], "list"),
(["show", "x"], "show"),
(["new", "x"], "new"),
(["axes"], "axes"),
):
args = parser.parse_args(command)
assert args.command == expected
assert callable(args.handler)
assert args.dir is None or isinstance(args.dir, Path)
def test_console_script_is_declared_and_runs_as_a_module() -> None:
"""The real entry point: `[project.scripts]` -> this module's `main`."""
declared = tomllib.loads(PYPROJECT.read_text(encoding="utf-8"))["project"]["scripts"]
assert declared["undercooked-levels"] == "undercooked.tools.levels:main"
result = subprocess.run( # fixed argv, the repo's own interpreter
[
sys.executable,
"-m",
"undercooked.tools.levels",
"list",
],
capture_output=True,
text=True,
timeout=120,
check=False,
cwd=REPO_ROOT,
env={**os.environ, "UNDERCOOKED_LEVELS_DIR": str(SHIPPED_LEVELS)},
)
assert result.returncode == EXIT_OK, result.stderr
assert "01_first_kitchen" in result.stdout
assert "02_double_shift" in result.stdout
def test_exit_codes_are_the_documented_ones() -> None:
assert (EXIT_OK, EXIT_VIOLATIONS, EXIT_UNUSABLE) == (0, 1, 2)