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,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)
|
||||
Reference in New Issue
Block a user