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.
165 lines
5.8 KiB
Python
165 lines
5.8 KiB
Python
"""Shared pytest configuration and fixtures for the Undercooked test suite.
|
|
|
|
`live_server` is the one fixture that binds a real socket (an ephemeral loopback
|
|
port) because the Playwright e2e tests need a URL a browser can load; everything
|
|
else stays in-process.
|
|
|
|
Fixtures that depend on modules created by later phase-01 tasks
|
|
(`undercooked.server.app`) import them lazily via `importlib`, so this conftest
|
|
keeps working before those modules exist.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import importlib
|
|
import os
|
|
import threading
|
|
import time
|
|
from collections.abc import Callable, Iterator
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import httpx
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
|
|
TESTS_DIR = Path(__file__).resolve().parent
|
|
FIXTURE_LEVELS_DIR = TESTS_DIR / "fixtures" / "levels"
|
|
LIVE_HOST = "127.0.0.1" # loopback only: tests never listen on a routable interface
|
|
|
|
|
|
|
|
def _as_env_value(value: object) -> str:
|
|
"""Render a Python override value the way `load_settings` would read it."""
|
|
if isinstance(value, bool):
|
|
return "1" if value else "0"
|
|
if isinstance(value, str | Path):
|
|
return str(value)
|
|
if isinstance(value, list | tuple):
|
|
return ",".join(str(item) for item in value)
|
|
return str(value)
|
|
|
|
|
|
@pytest.fixture(scope="session", autouse=True)
|
|
def isolated_levels_dir() -> Iterator[None]:
|
|
"""Point `UNDERCOOKED_LEVELS_DIR` at this level-free fixture dir for every test.
|
|
|
|
No test may read the real `levels/` content implicitly. Since phase 02
|
|
task 04 `create_app()` also needs a recipe book, and the dir carries a
|
|
committed minimal `recipes.yaml` (zero levels — a legal campaign) so
|
|
fixture-isolated apps still boot; tests that need real levels build one
|
|
under `tmp_path` or point explicitly at `levels/`.
|
|
"""
|
|
FIXTURE_LEVELS_DIR.mkdir(parents=True, exist_ok=True)
|
|
previous = os.environ.get("UNDERCOOKED_LEVELS_DIR")
|
|
os.environ["UNDERCOOKED_LEVELS_DIR"] = str(FIXTURE_LEVELS_DIR)
|
|
yield
|
|
if previous is None:
|
|
os.environ.pop("UNDERCOOKED_LEVELS_DIR", None)
|
|
else:
|
|
os.environ["UNDERCOOKED_LEVELS_DIR"] = previous
|
|
|
|
|
|
def _wait_for_health(url: str, thread: threading.Thread) -> None:
|
|
"""Poll `/healthz` until it answers 200, failing fast if the server thread died."""
|
|
timeout = float(os.environ.get("UNDERCOOKED_TEST_SOCKET_TIMEOUT", "30"))
|
|
deadline = time.monotonic() + timeout
|
|
probe = httpx.Client(timeout=1.0, trust_env=False)
|
|
try:
|
|
while time.monotonic() < deadline:
|
|
try:
|
|
if probe.get(f"{url}/healthz").status_code == 200:
|
|
return
|
|
except httpx.HTTPError:
|
|
pass
|
|
thread.join(timeout=0.1)
|
|
if not thread.is_alive():
|
|
raise RuntimeError(f"live server on {url} died during startup")
|
|
finally:
|
|
probe.close()
|
|
raise RuntimeError(f"live server on {url} never became healthy")
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
def live_server() -> Iterator[str]:
|
|
"""Base URL of a real uvicorn server, started once for the session.
|
|
|
|
The Playwright e2e tests need an address a browser can actually load, which
|
|
the in-process `TestClient` cannot provide. uvicorn binds the socket before
|
|
the serving thread starts, so the ephemeral port is known exactly (no log
|
|
scraping, no collisions between concurrent runs), and shutdown is requested
|
|
through `should_exit` so the lifespan stops cleanly.
|
|
"""
|
|
uvicorn = importlib.import_module("uvicorn")
|
|
app_module = importlib.import_module("undercooked.server.app")
|
|
|
|
config = uvicorn.Config(
|
|
app_module.create_app(),
|
|
host=LIVE_HOST,
|
|
port=0,
|
|
log_level="warning",
|
|
lifespan="on",
|
|
)
|
|
sock = config.bind_socket()
|
|
url = f"http://{LIVE_HOST}:{sock.getsockname()[1]}"
|
|
server = uvicorn.Server(config)
|
|
thread = threading.Thread(
|
|
target=server.run, args=([sock],), name="undercooked-live", daemon=True
|
|
)
|
|
thread.start()
|
|
_wait_for_health(url, thread)
|
|
try:
|
|
yield url
|
|
finally:
|
|
server.should_exit = True
|
|
thread.join(timeout=15)
|
|
if thread.is_alive(): # pragma: no cover - only if uvicorn stops cooperating
|
|
pytest.fail("live server did not shut down within 15 s")
|
|
|
|
|
|
@pytest.fixture
|
|
def random_seed() -> int:
|
|
"""Deterministic seed for sim/replay tests (determinism is a contract)."""
|
|
return 0x5EED_0401
|
|
|
|
|
|
@pytest.fixture
|
|
def make_settings() -> Callable[..., Any]:
|
|
"""Build `undercooked.config.Settings` from field-name overrides.
|
|
|
|
Overrides are translated into their env vars using the authoritative
|
|
`config.ENV_KEYS` contract (field name -> `UNDERCOOKED_*` / `DEBUGPY*`), and
|
|
`load_settings(env)` is called with a copy of the process environment that
|
|
has those vars stripped, so ambient env and `.env` files cannot leak into
|
|
tests.
|
|
"""
|
|
|
|
def _make(**overrides: object) -> Any:
|
|
config = importlib.import_module("undercooked.config")
|
|
env: dict[str, str] = {
|
|
key: value
|
|
for key, value in os.environ.items()
|
|
if not key.startswith(("UNDERCOOKED_", "DEBUGPY"))
|
|
}
|
|
for name, value in overrides.items():
|
|
if name not in config.ENV_KEYS:
|
|
pytest.fail(f"unknown settings field override: {name!r}")
|
|
env[config.ENV_KEYS[name]] = _as_env_value(value)
|
|
return config.load_settings(env)
|
|
|
|
return _make
|
|
|
|
|
|
@pytest.fixture
|
|
def app() -> Any:
|
|
"""Fresh FastAPI application built through `undercooked.server.app.create_app()`."""
|
|
module = importlib.import_module("undercooked.server.app")
|
|
return module.create_app()
|
|
|
|
|
|
@pytest.fixture
|
|
def client(app: Any) -> Iterator[TestClient]:
|
|
"""`TestClient` bound to the `app` fixture, running startup/shutdown."""
|
|
with TestClient(app) as test_client:
|
|
yield test_client
|