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.
292 lines
9.9 KiB
Python
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
|