Files
undercooked/tests/conftest.py
T
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

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