Files
undercooked/tests/unit/test_main.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

292 lines
9.9 KiB
Python

"""Unit tests for the `undercooked` CLI (task 03): precedence, exit codes, wiring.
`_resolve` and `_uvicorn_log_config` are pure, so precedence is testable without
a socket. `main()` is tested with uvicorn's `run` replaced by a recorder — the
real server is started by the integration/phase criteria, not by a unit test.
"""
from __future__ import annotations
import copy
import os
import subprocess
import sys
from pathlib import Path
from typing import TYPE_CHECKING, Any
import pytest
import uvicorn
import uvicorn.config
from undercooked.__main__ import (
APP_IMPORT_STRING,
EXIT_CONFIG_ERROR,
EXIT_OK,
RunOptions,
_cli_overrides,
_resolve,
_uvicorn_log_config,
build_parser,
main,
)
from undercooked.config import (
ENV_KEYS,
ConfigError,
Settings,
apply_cli_overrides,
get_settings,
reset_settings_cache,
)
from undercooked.sim.recipe import ContentError
if TYPE_CHECKING:
from collections.abc import Iterator
pytestmark = pytest.mark.unit
REPO_ROOT = Path(__file__).resolve().parents[2]
# A string in an assertion, never a socket bind: the CLI flag is what is under test.
ALL_INTERFACES = "0.0.0.0" # noqa: S104 - a test assertion, not a socket bind
@pytest.fixture(autouse=True)
def pristine_env() -> Iterator[None]:
"""`main()` publishes CLI flags into `os.environ`; nothing may leak out."""
saved = dict(os.environ)
reset_settings_cache()
yield
os.environ.clear()
os.environ.update(saved)
reset_settings_cache()
@pytest.fixture
def uvicorn_calls(monkeypatch: pytest.MonkeyPatch) -> list[dict[str, Any]]:
"""Replace uvicorn's `run` with a recorder; returns the recorded invocations."""
calls: list[dict[str, Any]] = []
def fake_run(target: str, **kwargs: Any) -> None:
calls.append({"target": target, **kwargs})
monkeypatch.setattr(uvicorn, "run", fake_run)
return calls
@pytest.fixture
def bare_env(monkeypatch: pytest.MonkeyPatch) -> None:
"""No `UNDERCOOKED_*`/`DEBUGPY*` in the environment: defaults are observable."""
for key in list(os.environ):
if key.startswith(("UNDERCOOKED_", "DEBUGPY")):
monkeypatch.delenv(key, raising=False)
monkeypatch.setenv("PYTHON_DOTENV_DISABLED", "true")
def parse(argv: list[str]) -> Any:
"""Parse ``argv`` the way `main` would, without serving anything."""
return build_parser().parse_args(argv)
# ----------------------------------------------------------------------------- parser
def test_every_flag_defaults_to_unset() -> None:
args = parse([])
assert (args.host, args.port, args.reload, args.levels_dir) == (None, None, False, None)
def test_flags_are_parsed() -> None:
args = parse(
["--host", ALL_INTERFACES, "--port", "9000", "--reload", "--levels-dir", "kitchens"]
)
assert (args.host, args.port, args.reload) == (ALL_INTERFACES, 9000, True)
assert args.levels_dir == Path("kitchens")
def test_help_names_the_env_fallback() -> None:
text = build_parser().format_help()
assert "--levels-dir" in text
assert "UNDERCOOKED_PORT" in text
# ----------------------------------------------------------------------------- precedence
def test_cli_beats_environment_and_defaults() -> None:
settings = Settings(host="10.0.0.1", port=8123, levels_dir=Path("env-levels"))
options = _resolve(
parse(["--host", "127.0.0.1", "--port", "9999", "--levels-dir", "cli-levels"]), settings
)
assert options == RunOptions(
host="127.0.0.1", port=9999, reload=False, levels_dir=Path("cli-levels")
)
def test_settings_supply_everything_the_cli_left_unset() -> None:
settings = Settings(host=ALL_INTERFACES, port=8123, levels_dir=Path("env-levels"))
options = _resolve(parse([]), settings)
assert options == RunOptions(
host=ALL_INTERFACES, port=8123, reload=False, levels_dir=Path("env-levels")
)
def test_settings_default_to_the_documented_defaults_in_a_bare_environment(
bare_env: None,
) -> None:
options = _resolve(parse([]), get_settings())
assert options == RunOptions(
host="127.0.0.1", port=8000, reload=False, levels_dir=Path("levels")
)
def test_environment_beats_the_builtin_default(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("UNDERCOOKED_PORT", "8123")
monkeypatch.setenv("UNDERCOOKED_HOST", ALL_INTERFACES)
reset_settings_cache()
options = _resolve(parse([]), get_settings())
assert (options.host, options.port) == (ALL_INTERFACES, 8123)
def test_reload_is_a_flag_not_an_environment_variable() -> None:
assert _resolve(parse(["--reload"]), Settings()).reload is True
assert _resolve(parse([]), Settings()).reload is False
def test_cli_overrides_only_report_flags_that_were_given() -> None:
assert _cli_overrides(parse([])) == {}
assert _cli_overrides(parse(["--port", "9000", "--levels-dir", "x"])) == {
"port": 9000,
"levels_dir": Path("x"),
}
# ----------------------------------------------------------------------------- main()
def test_main_serves_the_app_string_with_resolved_options(
uvicorn_calls: list[dict[str, Any]], bare_env: None
) -> None:
assert main([]) == EXIT_OK
assert len(uvicorn_calls) == 1
call = uvicorn_calls[0]
assert call["target"] == APP_IMPORT_STRING # reload-capable "module:attribute" form
assert (call["host"], call["port"], call["reload"]) == ("127.0.0.1", 8000, False)
assert "undercooked" in call["log_config"]["loggers"]
def test_main_passes_cli_flags_to_uvicorn(
uvicorn_calls: list[dict[str, Any]], bare_env: None
) -> None:
assert main(["--host", ALL_INTERFACES, "--port", "9123", "--reload"]) == EXIT_OK
call = uvicorn_calls[0]
assert (call["host"], call["port"], call["reload"]) == (ALL_INTERFACES, 9123, True)
def test_main_publishes_cli_flags_to_the_environment(
uvicorn_calls: list[dict[str, Any]], bare_env: None
) -> None:
"""A uvicorn --reload child re-reads the environment, so flags must live there."""
assert main(["--levels-dir", "kitchens", "--port", "9124"]) == EXIT_OK
assert os.environ[ENV_KEYS["levels_dir"]] == "kitchens"
assert os.environ[ENV_KEYS["port"]] == "9124"
assert get_settings().levels_dir == Path("kitchens")
assert get_settings().port == 9124
def test_main_validates_cli_values_like_environment_values(
uvicorn_calls: list[dict[str, Any]], capsys: pytest.CaptureFixture[str]
) -> None:
assert main(["--port", "0"]) == EXIT_CONFIG_ERROR
assert uvicorn_calls == []
err = capsys.readouterr().err
assert err.strip().startswith("undercooked:")
assert "UNDERCOOKED_PORT" in err
assert err.count("\n") == 1 # a one-line message, not a traceback
def test_main_reports_an_invalid_environment(
uvicorn_calls: list[dict[str, Any]],
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
monkeypatch.setenv("UNDERCOOKED_TICK_HZ", "99")
reset_settings_cache()
assert main([]) == EXIT_CONFIG_ERROR
assert uvicorn_calls == []
assert "UNDERCOOKED_TICK_HZ=99" in capsys.readouterr().err
def test_main_exits_two_when_the_content_gate_refuses_startup(
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
"""Unusable levels are a startup error, not a traceback.
Content is read when uvicorn imports the app string, so `create_app`'s
`ContentError` surfaces out of `uvicorn.run` — and the CLI answers with exit 2,
the same code an unusable `UNDERCOOKED_PORT` gets. The named-rule report itself
is written by `create_app` before it raises, so this only adds the closing line.
"""
def refusing_run(_target: str, **_kwargs: Any) -> None:
raise ContentError("undercooked: level content failed validation:\nBOGUS_RULE: nope")
monkeypatch.setattr(uvicorn, "run", refusing_run)
assert main([]) == EXIT_CONFIG_ERROR
err = capsys.readouterr().err
assert "refusing to start" in err
assert "undercooked-levels validate" in err
assert "Traceback" not in err
def test_main_lets_any_other_serving_failure_propagate(monkeypatch: pytest.MonkeyPatch) -> None:
"""Only the content gate is translated: a real crash stays a real crash."""
def crashing_run(_target: str, **_kwargs: Any) -> None:
raise OSError("port in use")
monkeypatch.setattr(uvicorn, "run", crashing_run)
with pytest.raises(OSError, match="port in use"):
main([])
def test_unknown_flag_is_an_argument_error() -> None:
with pytest.raises(SystemExit) as excinfo:
main(["--nope"])
assert excinfo.value.code == EXIT_CONFIG_ERROR
def test_an_unknown_settings_field_is_a_config_error() -> None:
with pytest.raises(ConfigError, match="not a configuration field"):
apply_cli_overrides(**{"nonsense": 1})
# ----------------------------------------------------------------------------- logging
def test_uvicorn_log_config_adds_our_loggers_without_touching_uvicorns() -> None:
base = copy.deepcopy(uvicorn.config.LOGGING_CONFIG)
config = _uvicorn_log_config(base)
assert config is not base
assert set(base["loggers"]) == {"uvicorn", "uvicorn.error", "uvicorn.access"}
ours = config["loggers"]["undercooked"]
assert ours["handlers"] == ["default"]
assert ours["level"] == "INFO"
assert config["loggers"]["uvicorn"] is base["loggers"]["uvicorn"]
assert set(config["formatters"]) == set(base["formatters"]) # the handler exists
# ----------------------------------------------------------------------------- the console script
def test_python_m_undercooked_prints_usage_and_exits_zero() -> None:
result = subprocess.run( # fixed argv, the repo's own interpreter
[sys.executable, "-m", "undercooked", "--help"],
cwd=REPO_ROOT,
capture_output=True,
text=True,
check=False,
timeout=120,
)
assert result.returncode == EXIT_OK, result.stderr
assert "usage: undercooked" in result.stdout
assert "--reload" in result.stdout