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.
840 lines
31 KiB
Python
840 lines
31 KiB
Python
"""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)
|