All green. Final report:
**Phase 05 final verification pass — complete, 100% green.**
**Verified (all 5 tasks already in `complete/`):**
- `RoomSupervisor` import, `POST /api/rooms` → 5-char Crockford code (`VBE8Y`), `GET /api/rooms/{code}` → seat list, join/session/404 flows — all live over HTTP
- `docs/ROOMS.md` present with FSM diagram, crash-isolation and "What is lost on restart" sections
- Phase tests all pass: lifecycle 4-seat `WON`→`LOBBY` (7 tests), crash isolation, GC/idle via `FakeClock` (no sleeps), FSM, tick cadence, supervisor, sessions
**Results:**
- `uv run pytest` → **1506 passed, 0 failed**; coverage **98.89%** (>90% floor; `room.py` 99%, `rooms.py` 97%, `sessions.py` 99% — all ≥95% per-module gate)
- `uv run ruff check .` → clean; `uv run pyright` → 0 errors; `uv run undercooked-levels validate` → content OK; server boots, `/healthz` 200, client at `/` 200
- e2e US-01 suite re-run after fixes: 10 passed
**Documentation — inconsistencies found and fixed:**
- `README.md`: status now lists phases 01–05 complete, next pending `06_realtime_websocket_protocol`; dropped "(phase 05, in progress)"; 3 stale "Not implemented until phase 05." references corrected
- `app/static/js/app.js` + `tests/e2e/test_us01_landing_surface.py`: placeholder string updated in lockstep to "Not implemented until phase 07." (phase 07 task 02 wires the buttons), comments updated to reflect phase-05 completion
- `AGENTS.md`: reviewed in full — §6 already documents all phase-05 modules accurately; no changes needed
**Decisions:** no code behavior changes beyond the user-visible placeholder string; no live-infrastructure touch (only a local loopback server, stopped).
**Next pending phase:** `06_realtime_websocket_protocol`.
119 lines
4.7 KiB
Python
119 lines
4.7 KiB
Python
"""Integration (grep-style): the lifecycle modules' time discipline
|
|
(phase 05, task 05).
|
|
|
|
Complements `tests/unit/test_server_clock_discipline.py` (which already
|
|
greps *all* of `server/` for `time` imports and wall-clock calls): this
|
|
pins the task file's rules on the lifecycle modules themselves —
|
|
`room.py`, `rooms.py`, `sessions.py` — so the lifecycle stays
|
|
deterministically testable:
|
|
|
|
* **no direct wall-clock call** — `time.time()` / `time.monotonic()`
|
|
(all timing goes through the injected `Clock`; `server/clock.py` is
|
|
the only module that may import `time` — the unit guard above),
|
|
* **no `import time` in the lifecycle modules** — the `Clock` is the
|
|
seam,
|
|
* **no `asyncio.sleep(` with a non-zero literal** — the cadence comes
|
|
from the deadline computed on the injected `Clock` (`Room.tick_loop`,
|
|
the supervisor's `gc_loop`), and the loop's sleep is injectable
|
|
(`Room(sleep=...)`, `RoomSupervisor(sleep=...)`), so a test drives
|
|
the whole lifecycle with a `FakeClock` and never sleeps. A
|
|
`asyncio.sleep(0.05)` literal would reintroduce a real-time
|
|
dependency on the exact path this test guards. (A *named* argument —
|
|
the injected sleep's seconds — and `asyncio.sleep(0)` are legal.)
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
pytestmark = pytest.mark.integration
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
|
SERVER_DIR = REPO_ROOT / "src" / "undercooked" / "server"
|
|
|
|
#: The lifecycle modules the task file names (`room*.py` → `room.py`,
|
|
#: plus `rooms.py` and `sessions.py`).
|
|
LIFECYCLE_MODULES = ("room.py", "rooms.py", "sessions.py")
|
|
|
|
#: The wall-clock calls the contract forbids in server code.
|
|
WALL_CLOCK_CALLS = ("time.monotonic()", "time.time()")
|
|
|
|
#: `time` may be imported by exactly one module (the unit guard checks
|
|
#: that it is `clock.py`).
|
|
TIME_IMPORT = re.compile(r"^\s*(?:import time\b|from time import\b)", re.MULTILINE)
|
|
|
|
#: An `asyncio.sleep(` whose argument is a literal (number or identifier).
|
|
SLEEP_CALL = re.compile(r"asyncio\.sleep\(\s*([A-Za-z_][A-Za-z0-9_]*|[\d.]+)\s*\)")
|
|
|
|
#: A (possibly exponential, signed) floating-point or integer literal.
|
|
NUMERIC_LITERAL = re.compile(r"-?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?")
|
|
|
|
|
|
def lifecycle_modules() -> list[Path]:
|
|
paths = []
|
|
for name in LIFECYCLE_MODULES:
|
|
path = SERVER_DIR / name
|
|
assert path.is_file(), f"the lifecycle module vanished: {name}"
|
|
paths.append(path)
|
|
return paths
|
|
|
|
|
|
def test_no_wall_clock_call_lives_in_the_lifecycle_modules() -> None:
|
|
offenders = [
|
|
f"{path.name}: {literal}"
|
|
for path in lifecycle_modules()
|
|
for literal in WALL_CLOCK_CALLS
|
|
if literal in path.read_text(encoding="utf-8")
|
|
]
|
|
assert offenders == [], "wall-clock calls leaked into the lifecycle:\n " + "\n ".join(
|
|
offenders
|
|
)
|
|
|
|
|
|
def test_no_time_import_lives_in_the_lifecycle_modules() -> None:
|
|
offenders = [
|
|
path.name
|
|
for path in lifecycle_modules()
|
|
if TIME_IMPORT.search(path.read_text(encoding="utf-8"))
|
|
]
|
|
assert offenders == [], "the lifecycle may only read time through the Clock: " + ", ".join(
|
|
offenders
|
|
)
|
|
|
|
|
|
def test_no_asyncio_sleep_with_a_nonzero_literal() -> None:
|
|
offenders = []
|
|
for path in lifecycle_modules():
|
|
for match in SLEEP_CALL.finditer(path.read_text(encoding="utf-8")):
|
|
literal = match.group(1)
|
|
# A named argument (the injected sleep's seconds) is legal;
|
|
# only a numeric literal is a wall-time dependency.
|
|
if not NUMERIC_LITERAL.fullmatch(literal):
|
|
continue
|
|
if float(literal) > 0:
|
|
offenders.append(f"{path.name}: asyncio.sleep({literal})")
|
|
assert offenders == [], (
|
|
"a non-zero asyncio.sleep literal would tie the lifecycle to real "
|
|
"time (drive it with a FakeClock instead):\n " + "\n ".join(offenders)
|
|
)
|
|
|
|
|
|
def test_the_guard_is_not_vacuous() -> None:
|
|
"""A guard that never fires is no guard: prove each check detects a
|
|
planted violation, and that the modules it greps exist."""
|
|
assert WALL_CLOCK_CALLS[0] in "x = time.monotonic()\n"
|
|
assert WALL_CLOCK_CALLS[1] in "y = time.time()\n"
|
|
assert TIME_IMPORT.search("import time\n") is not None
|
|
assert TIME_IMPORT.search("from time import monotonic\n") is not None
|
|
# The sleep guard: a non-zero literal fires, zero and named arguments do not.
|
|
assert SLEEP_CALL.search("asyncio.sleep(0.05)\n") is not None
|
|
assert SLEEP_CALL.search("asyncio.sleep(seconds)\n") is not None
|
|
match = SLEEP_CALL.search("asyncio.sleep(0.05)\n")
|
|
assert match is not None
|
|
assert NUMERIC_LITERAL.fullmatch(match.group(1))
|
|
assert float(match.group(1)) > 0
|
|
assert lifecycle_modules()
|