"""Unit tests for `undercooked.sim.content` (phase 02 task 02). The loader is the "no hard-coded content lists" rule (A8) in executable form: a directory of YAML in, a campaign-ordered `ContentBundle` out. These tests pin discovery (recursion, `_`-drafts, the recipe book's own name), the `(order, id)` sort, and the promise that *every* file-level problem — bad YAML, schema violations, unknown order-pool recipes, duplicate ids — is reported in one `ContentError`, not one per run. """ from __future__ import annotations from pathlib import Path from typing import Any import pytest import yaml from undercooked.sim.content import ( LEVEL_FILE_SUFFIXES, ContentBundle, ContentError, load_content, load_level_file, ) from undercooked.sim.grid import GridError from undercooked.sim.level import Level, LevelDef from undercooked.sim.model import ItemKind from undercooked.sim.recipe import RecipeBook pytestmark = pytest.mark.unit BOOK_YAML = """ ingredients: [tomato, onion] recipes: soup: id: soup name: Tomato Soup base_score: 40 ingredients: [{kind: tomato, form: raw}, {kind: onion, form: raw}] steps: - {kind: chop, station: chop_board, seconds: 0.9} - {kind: cook, station: stove, seconds: 5} """ def level_body(level_id: str, order: int, **overrides: Any) -> dict[str, Any]: body: dict[str, Any] = { "id": level_id, "name": level_id.replace("_", " ").title(), "order": order, "time_limit": 150, "target_score": 45, "grid": ["#####", "#S..#", "#.C.#", "#..V#", "#####"], "tiles": { ".": {"kind": "floor"}, "#": {"kind": "wall"}, "S": {"kind": "floor", "station": "spawn_shelf", "item": "tomato"}, "C": {"kind": "floor", "station": "chop_board"}, "V": {"kind": "floor", "station": "serve"}, }, "spawns": [[1, 1]], "orders": { "pool": ["soup"], "concurrent": 1, "spawn_interval": [16, 20], "deadline": 60, }, } body.update(overrides) return body def write_yaml(directory: Path, name: str, body: dict[str, Any]) -> Path: path = directory / name path.parent.mkdir(parents=True, exist_ok=True) path.write_text(yaml.safe_dump(body, sort_keys=False), encoding="utf-8") return path def make_content_dir(tmp_path: Path, *levels: tuple[str, int]) -> tuple[Path, Path]: """A content dir with a recipe book and the named (filename, order) levels.""" levels_dir = tmp_path / "levels" levels_dir.mkdir() recipes_path = levels_dir / "recipes.yaml" recipes_path.write_text(BOOK_YAML, encoding="utf-8") for name, order in levels: write_yaml(levels_dir, name, level_body(Path(name).stem, order)) return levels_dir, recipes_path # ---------------------------------------------------------------------------- happy path def test_loads_the_book_and_every_level_file(tmp_path: Path) -> None: levels_dir, recipes_path = make_content_dir(tmp_path, ("01_a.yaml", 1), ("02_b.yaml", 2)) bundle = load_content(levels_dir, recipes_path) assert bundle.recipe_book.ids() == ("soup",) assert [level.id for level in bundle.levels] == ["01_a", "02_b"] assert set(bundle.by_id) == {"01_a", "02_b"} assert bundle.by_id["02_b"].order == 2 def test_levels_order_by_order_then_id_not_by_filename(tmp_path: Path) -> None: levels_dir, recipes_path = make_content_dir( tmp_path, ("zzz_first.yaml", 1), ("01_second.yaml", 2), ("aaa_tie_b.yaml", 1) ) write_yaml(levels_dir, "aab_tie.yaml", level_body("aab_tie", 1)) bundle = load_content(levels_dir, recipes_path) assert [level.id for level in bundle.levels] == [ "aaa_tie_b", "aab_tie", "zzz_first", "01_second", ] def test_an_empty_levels_dir_is_a_legal_empty_campaign(tmp_path: Path) -> None: levels_dir, recipes_path = make_content_dir(tmp_path) bundle = load_content(levels_dir, recipes_path) assert bundle.levels == () assert dict(bundle.by_id) == {} def test_paths_may_be_plain_strings(tmp_path: Path) -> None: levels_dir, recipes_path = make_content_dir(tmp_path, ("01_a.yaml", 1)) bundle = load_content(str(levels_dir), str(recipes_path)) assert [level.id for level in bundle.levels] == ["01_a"] # --------------------------------------------------------------------------- discovery def test_discovery_recurses_into_subdirectories(tmp_path: Path) -> None: levels_dir, recipes_path = make_content_dir(tmp_path, ("01_a.yaml", 1)) write_yaml(levels_dir / "campaign" / "deep", "02_b.yaml", level_body("02_b", 2)) bundle = load_content(levels_dir, recipes_path) assert [level.id for level in bundle.levels] == ["01_a", "02_b"] def test_underscore_prefixed_files_are_ignored_even_when_broken(tmp_path: Path) -> None: levels_dir, recipes_path = make_content_dir(tmp_path, ("01_a.yaml", 1)) (levels_dir / "_draft.yaml").write_text("grid: [unclosed\n", encoding="utf-8") (levels_dir / "_wip").mkdir() write_yaml(levels_dir / "_wip", "scratch.yaml", {"id": "nothing"}) bundle = load_content(levels_dir, recipes_path) assert [level.id for level in bundle.levels] == ["01_a"] def test_recipes_yaml_in_the_levels_dir_is_not_a_level(tmp_path: Path) -> None: levels_dir, recipes_path = make_content_dir(tmp_path) # recipes.yaml itself would not validate as a level; loading must not even try. bundle = load_content(levels_dir, recipes_path) assert bundle.levels == () def test_non_yaml_files_are_ignored(tmp_path: Path) -> None: levels_dir, recipes_path = make_content_dir(tmp_path, ("01_a.yaml", 1)) (levels_dir / "notes.md").write_text("# ideas\n- more soup\n", encoding="utf-8") (levels_dir / "level.json").write_text("{not yaml at all", encoding="utf-8") bundle = load_content(levels_dir, recipes_path) assert [level.id for level in bundle.levels] == ["01_a"] def test_suffix_set_covers_yaml_and_yml(tmp_path: Path) -> None: assert set(LEVEL_FILE_SUFFIXES) == {".yaml", ".yml"} levels_dir, recipes_path = make_content_dir(tmp_path) (levels_dir / "02_b.yml").write_text( yaml.safe_dump(level_body("02_b", 2), sort_keys=False), encoding="utf-8" ) bundle = load_content(levels_dir, recipes_path) assert [level.id for level in bundle.levels] == ["02_b"] # ---------------------------------------------------------------- error aggregation def test_unknown_order_pool_recipe_is_a_load_error(tmp_path: Path) -> None: levels_dir, recipes_path = make_content_dir(tmp_path) write_yaml(levels_dir, "01_a.yaml", level_body("01_a", 1, orders={ "pool": ["soup", "ghost"], "concurrent": 1, "spawn_interval": [16, 20], "deadline": 60, })) with pytest.raises(ContentError, match="unknown recipes: ghost"): load_content(levels_dir, recipes_path) def test_duplicate_level_ids_are_refused(tmp_path: Path) -> None: levels_dir, recipes_path = make_content_dir(tmp_path, ("01_a.yaml", 1)) write_yaml(levels_dir, "90_copy.yaml", level_body("01_a", 5)) with pytest.raises(ContentError, match="duplicate level ids: 01_a"): load_content(levels_dir, recipes_path) def test_missing_levels_directory_is_reported(tmp_path: Path) -> None: _, recipes_path = make_content_dir(tmp_path) with pytest.raises(ContentError, match="does not exist"): load_content(tmp_path / "nowhere", recipes_path) def test_missing_recipe_book_is_reported(tmp_path: Path) -> None: levels_dir, _ = make_content_dir(tmp_path) with pytest.raises(ContentError, match="cannot read content file"): load_content(levels_dir, tmp_path / "no" / "recipes.yaml") def test_all_file_errors_survive_to_one_content_error(tmp_path: Path) -> None: levels_dir, recipes_path = make_content_dir(tmp_path) (levels_dir / "bad_one.yaml").write_text("grid: [unclosed\n", encoding="utf-8") write_yaml(levels_dir, "bad_two.yaml", {"id": "x"}) # mostly-missing schema write_yaml(levels_dir, "bad_three.yaml", level_body("ghost", 1, orders={ "pool": ["phantom"], "concurrent": 1, "spawn_interval": [16, 20], "deadline": 60, })) with pytest.raises(ContentError) as excinfo: load_content(levels_dir, recipes_path) message = str(excinfo.value) assert "bad_one.yaml" in message assert "invalid YAML" in message assert "bad_two.yaml" in message assert "Field required" in message assert "bad_three.yaml" in message assert "phantom" in message # every broken file opens its own issue block assert f"{levels_dir / 'bad_one.yaml'}: " in message assert f"{levels_dir / 'bad_two.yaml'}: " in message assert f"{levels_dir / 'bad_three.yaml'}: " in message # ------------------------------------------------------------------ load_level_file def test_load_level_file_returns_a_built_level(tmp_path: Path) -> None: path = write_yaml(tmp_path, "01_a.yaml", level_body("01_a", 1)) level = load_level_file(path) assert isinstance(level, Level) assert level.station_items == {"spawn_shelf_0": ItemKind.TOMATO} assert level.grid.width == 5 def test_load_level_file_names_the_file_on_schema_errors(tmp_path: Path) -> None: path = write_yaml(tmp_path, "broken.yaml", level_body("x", 1, grid=["###", "#..#"])) with pytest.raises(ContentError, match=r"broken.yaml.*ragged grid"): load_level_file(path) def test_load_level_file_wraps_geometry_failures(tmp_path: Path, monkeypatch: Any) -> None: def explode(_spec: Any) -> Level: raise GridError("planted geometry failure") monkeypatch.setattr(Level, "build", staticmethod(explode)) path = write_yaml(tmp_path, "01_a.yaml", level_body("01_a", 1)) with pytest.raises(ContentError, match=r"01_a\.yaml: planted geometry failure"): load_level_file(path) # --------------------------------------------------------------------- ContentBundle def empty_book() -> RecipeBook: return RecipeBook.model_validate({"recipes": {}}) def built_level(level_id: str, order: int) -> Level: return Level.build(LevelDef.model_validate(level_body(level_id, order))) def test_bundle_by_id_is_derived_and_can_be_read_back() -> None: bundle = ContentBundle(recipe_book=empty_book(), levels=(built_level("a", 2),)) assert list(bundle.by_id) == ["a"] assert bundle.by_id["a"].order == 2 def test_a_hand_built_bundle_cannot_smuggle_duplicate_ids() -> None: levels = (built_level("a", 1), built_level("a", 2)) with pytest.raises(ContentError, match="duplicate level ids: a"): ContentBundle(recipe_book=empty_book(), levels=levels)