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.
216 lines
6.9 KiB
Python
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}"
|