"""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()