"""Startup content gate (task 04): broken content refuses to start the app. ``create_app`` loads and validates the campaign once through ``load_validated_content``. These tests drive that function and the factory directly: the shipped content loads; a level whose serve window is sealed into a wall pocket raises ``ContentError`` and prints the named-rule report on stderr; a missing recipe book does the same; and a *warning* band target score logs a warning without refusing startup. """ from __future__ import annotations import logging import shutil import textwrap from pathlib import Path import pytest from undercooked.config import Settings from undercooked.server.app import create_app from undercooked.server.routes_content import ( RECIPES_FILENAME, load_validated_content, resolve_recipes_path, ) from undercooked.sim.recipe import ContentError REPO_ROOT = Path(__file__).resolve().parents[2] SHIPPED_LEVELS = REPO_ROOT / "levels" def make_settings(levels_dir: Path) -> Settings: return Settings(levels_dir=levels_dir, recipes_path=levels_dir / RECIPES_FILENAME) # --- path resolution ---------------------------------------------------------- def test_recipes_path_defaults_inside_the_levels_dir() -> None: settings = Settings(levels_dir=Path("/content/campaign")) assert settings.recipes_path is None # the shipped default is "derive it" assert resolve_recipes_path(settings) == Path("/content/campaign/recipes.yaml") def test_recipes_path_override_wins() -> None: settings = Settings( levels_dir=Path("/content/campaign"), recipes_path=Path("/other/book.yaml") ) assert resolve_recipes_path(settings) == Path("/other/book.yaml") # --- the happy path ------------------------------------------------------------ def test_startup_loads_the_shipped_bundle() -> None: application = create_app(Settings(levels_dir=SHIPPED_LEVELS)) loaded = application.state.content assert [level.id for level in loaded.levels] == [ "01_first_kitchen", "02_double_shift", ] assert "burger" in loaded.recipe_book.recipes # --- broken content refuses startup --------------------------------------------- def copy_shipped_content(tmp_path: Path) -> Path: """A writable copy of the shipped campaign the tests may vandalise.""" target = tmp_path / "levels" shutil.copytree(SHIPPED_LEVELS, target) return target def test_missing_recipe_book_refuses_startup_with_a_stderr_report( tmp_path: Path, capsys: pytest.CaptureFixture[str] ) -> None: capsys.readouterr() # discard anything earlier tests printed with pytest.raises(ContentError, match=r"recipes\.yaml"): load_validated_content(make_settings(tmp_path)) err = capsys.readouterr().err assert "content failed to load" in err assert "recipes.yaml" in err def test_unparsable_level_file_refuses_startup( tmp_path: Path, capsys: pytest.CaptureFixture[str] ) -> None: levels = copy_shipped_content(tmp_path) (levels / "03_broken.yaml").write_text("\tthis: [is not", encoding="utf-8") capsys.readouterr() with pytest.raises(ContentError, match=r"03_broken\.yaml"): create_app(make_settings(levels)) assert "03_broken.yaml" in capsys.readouterr().err def test_sealed_serve_window_refuses_startup_with_named_rule_report( tmp_path: Path, capsys: pytest.CaptureFixture[str] ) -> None: levels = copy_shipped_content(tmp_path) path = levels / "02_double_shift.yaml" text = path.read_text(encoding="utf-8") vandalism = text.replace('- "####=...=####"', '- "####=..#=####"', 1) vandalism = vandalism.replace('- "#....P.X....#"', '- "#....P#X#...#"') vandalism = vandalism.replace('- "#...........#"', '- "#......#....#"') assert vandalism != text, "the grid rows this test vandalises changed" path.write_text(vandalism, encoding="utf-8") capsys.readouterr() with pytest.raises(ContentError, match="SERVE_REACHABLE"): create_app(make_settings(levels)) err = capsys.readouterr().err assert "SERVE_REACHABLE" in err assert "02_double_shift" in err # --- warnings do not refuse startup ---------------------------------------------- WARN_BAND_CONTENT = """\ id: probe_level name: Probe order: 1 difficulty: 1 time_limit: 30 target_score: 40 blurb: A synthetic level that lands just inside the target-score warning band. 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: [probe] concurrent: 1 spawn_interval: [10, 14] deadline: 40 """ WARN_BAND_RECIPES = """\ recipes: probe: id: probe name: Probe Dish base_score: 10 ingredients: - {kind: tomato, form: raw} steps: - {kind: pick, station: spawn_shelf} - {kind: serve, station: serve} """ def write_warn_band_content(tmp_path: Path) -> Path: (tmp_path / RECIPES_FILENAME).write_text( textwrap.dedent(WARN_BAND_RECIPES), encoding="utf-8" ) (tmp_path / "probe_level.yaml").write_text( textwrap.dedent(WARN_BAND_CONTENT), encoding="utf-8" ) return tmp_path def test_warn_band_target_starts_but_logs_the_warning( tmp_path: Path, caplog: pytest.LogCaptureFixture ) -> None: levels = write_warn_band_content(tmp_path) with caplog.at_level(logging.WARNING, logger="undercooked.server.routes_content"): bundle = load_validated_content(make_settings(levels)) assert [level.id for level in bundle.levels] == ["probe_level"] messages = [record.getMessage() for record in caplog.records] assert any("TARGET_IMPLAUSIBLE" in message for message in messages), messages def test_warn_band_level_still_builds_an_app(tmp_path: Path) -> None: levels = write_warn_band_content(tmp_path) application = create_app(make_settings(levels)) assert application.state.content.by_id["probe_level"].target_score == 40