"""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)