Files
undercooked/tests/unit/test_shipped_content.py
ducoterra 3426264ffb 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.
2026-10-06 11:03:28 -04:00

216 lines
6.9 KiB
Python

"""Regression tests for the shipped campaign content (``levels/``, task 04).
These pin what campaign 02 ships, exactly as the phase task specifies: the
recipe book (ingredients + four named dishes with PLAN §5 step pipelines and
fixed base scores), the first two levels' tunables (pool, concurrency, spawn
interval, deadline, time limit, target), the blurb floor, campaign ordering,
and the level-1 → level-2 escalation contract (≥2 axes up). They read the
real files from ``levels/`` — not a copy — so editing shipped content that
a test pins fails here, in review, instead of at a player's startup.
Startup behaviour and the HTTP surface live in ``test_content_startup.py``
and ``tests/integration/test_content_api.py``; the generic loader and
validator have their own unit suites.
"""
from __future__ import annotations
from pathlib import Path
import pytest
from undercooked.sim.content import ContentBundle, load_content
from undercooked.sim.level import Level
from undercooked.sim.recipe import RecipeBook, load_recipe_book
from undercooked.sim.validation import escalation_axes, validate_bundle
REPO_ROOT = Path(__file__).resolve().parents[2]
LEVELS_DIR = REPO_ROOT / "levels"
RECIPES_PATH = LEVELS_DIR / "recipes.yaml"
LEVEL_FILES = sorted(
path for path in LEVELS_DIR.glob("*.yaml") if path.name != "recipes.yaml"
)
#: The campaign order this task ships (PLAN §8: soup tutorial, then salad).
SHIPPED_IDS = ["01_first_kitchen", "02_double_shift"]
#: PLAN §5 station pipelines as (interaction, station) pairs:
#: pick at the shelf -> chop at the board -> cook on the stove -> plate at
#: prep -> serve at the window; a dish skips the stages its recipe lacks.
EXPECTED_STEPS = {
"soup": [
("pick", "spawn_shelf"),
("chop", "chop_board"),
("cook", "stove"),
("plate", "prep"),
("serve", "serve"),
],
"salad": [
("pick", "spawn_shelf"),
("chop", "chop_board"),
("plate", "prep"),
("serve", "serve"),
],
"fried_rice": [
("pick", "spawn_shelf"),
("cook", "stove"),
("plate", "prep"),
("serve", "serve"),
],
"burger": [
("pick", "spawn_shelf"),
("chop", "chop_board"),
("cook", "stove"),
("plate", "prep"),
("serve", "serve"),
],
}
@pytest.fixture(scope="module")
def book() -> RecipeBook:
return load_recipe_book(RECIPES_PATH)
@pytest.fixture(scope="module")
def bundle() -> ContentBundle:
return load_content(LEVELS_DIR, RECIPES_PATH)
def level_of(bundle: ContentBundle, level_id: str) -> Level:
return bundle.by_id[level_id]
# --- the recipe book ---------------------------------------------------------
def test_recipe_book_ships_the_named_ingredient_catalog(book: RecipeBook) -> None:
assert set(book.ingredients) == {
"tomato",
"onion",
"lettuce",
"dough",
"patty",
"rice",
"fish",
"plate",
}
def test_recipe_book_ships_exactly_the_four_dishes(book: RecipeBook) -> None:
assert set(book.recipes) == {"soup", "salad", "fried_rice", "burger"}
@pytest.mark.parametrize(
("recipe_id", "base_score"),
[("soup", 40), ("salad", 30), ("fried_rice", 50), ("burger", 70)],
)
def test_dishes_carry_their_pinned_base_scores(
book: RecipeBook, recipe_id: str, base_score: int
) -> None:
assert book.get(recipe_id).base_score == base_score
@pytest.mark.parametrize("recipe_id", sorted(EXPECTED_STEPS))
def test_dishes_follow_the_plan_station_pipelines(
book: RecipeBook, recipe_id: str
) -> None:
recipe = book.get(recipe_id)
assert [(step.kind, step.station.value) for step in recipe.steps] == (
EXPECTED_STEPS[recipe_id]
)
@pytest.mark.parametrize("recipe_id", sorted(EXPECTED_STEPS))
def test_every_dish_consumes_catalogued_ingredients(
book: RecipeBook, recipe_id: str
) -> None:
recipe = book.get(recipe_id)
assert recipe.ingredients, recipe_id
assert {item.kind for item in recipe.ingredients} <= set(book.ingredients)
# --- the level files ----------------------------------------------------------
def test_every_yaml_file_in_levels_is_a_shipped_level(bundle: ContentBundle) -> None:
stems = {path.stem for path in LEVEL_FILES}
assert stems, "levels/ must ship at least one level"
assert stems == set(bundle.by_id)
def test_shipped_campaign_validates_without_warnings(bundle: ContentBundle) -> None:
report = validate_bundle(bundle)
assert report.ok, report.render()
assert not report.warnings, report.render()
def test_campaign_order_is_unique_and_starts_at_one(bundle: ContentBundle) -> None:
orders = [level.order for level in bundle.levels]
assert orders == list(range(1, len(bundle.levels) + 1))
def test_campaign_ships_the_first_two_levels(bundle: ContentBundle) -> None:
assert [level.id for level in bundle.levels] == SHIPPED_IDS
@pytest.mark.parametrize("level_id", SHIPPED_IDS)
def test_each_level_has_a_readable_premise(bundle: ContentBundle, level_id: str) -> None:
blurb = level_of(bundle, level_id).blurb
assert len(blurb) >= 20
@pytest.mark.parametrize("level_id", SHIPPED_IDS)
def test_difficulty_is_within_the_declared_range(
bundle: ContentBundle, level_id: str
) -> None:
assert 1 <= level_of(bundle, level_id).difficulty <= 10
@pytest.mark.parametrize("level_id", SHIPPED_IDS)
def test_pool_recipes_exist_in_the_book(
bundle: ContentBundle, book: RecipeBook, level_id: str
) -> None:
recipe_ids = level_of(bundle, level_id).orders.pool
assert recipe_ids
for recipe_id in recipe_ids:
assert recipe_id in book.recipes
# --- the two levels' pinned tunables (task 04) --------------------------------
def test_level_one_is_the_soup_tutorial(bundle: ContentBundle) -> None:
level = level_of(bundle, "01_first_kitchen")
assert level.order == 1
assert level.difficulty == 1
assert level.time_limit == 150
assert level.target_score == 45
assert level.orders.pool == ["soup"]
assert level.orders.concurrent == 1
assert tuple(level.orders.spawn_interval) == (16, 20)
assert level.orders.deadline == 60
assert level.hazards == []
def test_level_two_adds_salad_and_pressure(bundle: ContentBundle) -> None:
level = level_of(bundle, "02_double_shift")
assert level.order == 2
assert level.difficulty == 2
assert level.time_limit == 130
assert level.target_score == 80
assert level.orders.pool == ["soup", "salad"]
assert level.orders.concurrent == 2
assert tuple(level.orders.spawn_interval) == (12, 16)
assert level.orders.deadline == 52
assert level.hazards == []
def test_level_two_escalates_at_least_two_axes(bundle: ContentBundle) -> None:
before = escalation_axes(level_of(bundle, "01_first_kitchen"))
after = escalation_axes(level_of(bundle, "02_double_shift"))
moved_up = sorted(
name for name, value in after.items() if value > before[name] + 1e-9
)
assert len(moved_up) >= 2, f"only these axes moved: {moved_up}"