screen-locker/screen_locker/tests/test_workout_session.py
Krzysztof kuhy Rudnicki aa3e290391 Document every test, split files at the cap
Real one-line docstrings stating what each test pins down. Files that
would pass 250 lines moved whole classes into _part2 siblings
(sick_panel, earned, poke_server, shutdown_reading,
manual_workout_dialog).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWYdwRAtMg7ki6TKxuU
2026-11-01 21:11:34 +01:00

249 lines
9.1 KiB
Python

"""Tests for running the workout app without freezing the lock's event loop.
The invariant these defend: no phase blocks. Every wait is an ``after``
callback, because a blocked event loop makes the lock's own escapes inert --
which is what once forced a hard reboot out of a "safe" demo.
"""
from __future__ import annotations
import os
from pathlib import Path
from typing import TYPE_CHECKING
import unittest.mock
from screen_locker._workout_app import GrabHandoff, ProcessHooks
from screen_locker._workout_ready import READY_MARKER
from screen_locker._workout_session import WorkoutSession, _read_available
if TYPE_CHECKING:
from collections.abc import Callable
class _FakeAfter:
"""A drainable stand-in for ``root.after``."""
def __init__(self) -> None:
self.queue: list[Callable[[], None]] = []
def __call__(self, _ms: int, callback: Callable[[], None]) -> object:
self.queue.append(callback)
return object()
def drain(self, limit: int = 5000) -> None:
"""Run queued callbacks in order until none are left or the limit is hit."""
for _ in range(limit):
if not self.queue:
return
self.queue.pop(0)()
class _FakeStdout:
def __init__(self, chunks: list[str]) -> None:
self._chunks = chunks
def readline(self) -> str:
"""Return the next canned chunk, or an empty string when exhausted."""
return self._chunks.pop(0) if self._chunks else ""
class _FakeChild:
def __init__(self, chunks: list[str], *, exits_after: int = 1) -> None:
self.stdout = _FakeStdout(chunks)
self.returncode: int | None = None
self.terminated = False
self._polls = 0
self._exits_after = exits_after
def poll(self) -> int | None:
"""Report the exit code once the configured number of polls has passed."""
self._polls += 1
if self._polls >= self._exits_after:
self.returncode = 0
return 0
return None
def terminate(self) -> None:
"""Record that the child was terminated and set a SIGTERM exit code."""
self.terminated = True
self.returncode = -15
def _session(child: _FakeChild, after: _FakeAfter, calls: list[str]):
return WorkoutSession(
GrabHandoff(
release=lambda: calls.append("release"),
reacquire=lambda: calls.append("reacquire"),
),
after=after,
on_status=lambda text: calls.append(f"status:{text[:20]}"),
binary=Path("/nonexistent/workout_app"),
hooks=ProcessHooks(
popen=lambda *_a, **_k: child,
selector=lambda r, _w, _x, _t: (r, [], []),
),
)
class TestWorkoutSession:
"""Each phase hands control back to the event loop."""
def test_releases_the_grab_only_after_the_ready_line(self) -> None:
"""The grab is released only after the ready marker, and before it is reacquired."""
after, calls = _FakeAfter(), []
child = _FakeChild([f"{READY_MARKER}\n"], exits_after=2)
_session(child, after, calls).start()
assert "release" not in calls # nothing released synchronously
after.drain()
assert "release" in calls
assert calls.index("release") < calls.index("reacquire")
def test_never_releases_when_the_app_dies_before_ready(self) -> None:
"""If the app dies before signalling ready the grab is never released."""
after, calls = _FakeAfter(), []
_session(_FakeChild([]), after, calls).start()
after.drain()
assert "release" not in calls
def test_reacquires_when_the_app_exits(self) -> None:
"""When the app exits the grab is reacquired and a closed status is shown."""
after, calls = _FakeAfter(), []
child = _FakeChild([f"{READY_MARKER}\n"], exits_after=3)
_session(child, after, calls).start()
after.drain()
assert calls[-2] == "reacquire"
assert "Workout app closed" in calls[-1]
def test_abort_terminates_the_app_and_restores_the_lock(self) -> None:
"""The escape hatch: usable at any point, from outside X."""
after, calls = _FakeAfter(), []
child = _FakeChild([f"{READY_MARKER}\n"], exits_after=99)
session = _session(child, after, calls)
session.start()
for _ in range(3):
if after.queue:
after.queue.pop(0)()
session.abort()
assert child.terminated
assert "reacquire" in calls
def test_abort_before_release_does_not_reacquire(self) -> None:
"""Nothing was lent, so there is nothing to take back."""
after, calls = _FakeAfter(), []
session = _session(_FakeChild([], exits_after=99), after, calls)
session.start()
session.abort()
assert "reacquire" not in calls
def test_times_out_without_ever_blocking(self) -> None:
"""A silent app is terminated after the retry budget without releasing the grab."""
after, calls = _FakeAfter(), []
child = _FakeChild(["noise\n"] * 10, exits_after=99)
session = _session(child, after, calls)
session._remaining = 3
session.start()
after.drain()
assert child.terminated
assert "release" not in calls
def test_says_so_when_the_app_is_not_built(self) -> None:
"""A missing workout binary is reported in the status text."""
after, calls = _FakeAfter(), []
session = WorkoutSession(
GrabHandoff(release=lambda: None, reacquire=lambda: None),
after=after,
on_status=calls.append,
hooks=ProcessHooks(popen=lambda *_a, **_k: None),
)
# Force the unbuilt case regardless of what this machine has built.
session._binary = None
monkey = "screen_locker._workout_session.workout_app_binary"
with unittest.mock.patch(monkey, return_value=None):
session.start()
assert any("not built" in c for c in calls)
def test_handles_a_missing_stdout_pipe(self) -> None:
"""A child without a stdout pipe never causes the grab to be released."""
after, calls = _FakeAfter(), []
child = _FakeChild([])
child.stdout = None
_session(child, after, calls).start()
after.drain()
assert "release" not in calls
class TestSessionEdges:
"""Paths a fake pipe or a happy path does not reach."""
def test_keeps_waiting_when_the_pipe_has_nothing_yet(self) -> None:
"""A quiet app is not a dead app; poll again, never block."""
after, calls = _FakeAfter(), []
child = _FakeChild([f"{READY_MARKER}\n"], exits_after=99)
session = WorkoutSession(
GrabHandoff(
release=lambda: calls.append("release"),
reacquire=lambda: calls.append("reacquire"),
),
after=after,
on_status=lambda _t: None,
binary=Path("/nonexistent/workout_app"),
hooks=ProcessHooks(
popen=lambda *_a, **_k: child,
# Never readable: the app is up but silent.
selector=lambda _r, _w, _x, _t: ([], [], []),
),
)
session._remaining = 2
session.start()
after.drain()
assert "release" not in calls
assert child.terminated
def test_abort_with_no_child_is_harmless(self) -> None:
"""Aborting a never-started session just reports the app as closed."""
after, calls = _FakeAfter(), []
session = _session(_FakeChild([]), after, calls)
session.abort() # never started
assert len(calls) == 1
assert "Workout app closed" in calls[0]
def test_reads_a_real_pipe_without_waiting_for_a_newline(self) -> None:
"""The production path: a text stream wrapping a binary buffer."""
read_fd, write_fd = os.pipe()
with os.fdopen(read_fd, "r") as reader, os.fdopen(write_fd, "w") as writer:
writer.write("WORKOUT_")
writer.flush()
assert _read_available(reader) == "WORKOUT_"
class TestDemoEscapeFlag:
"""The flag that arms the app's own escape hatch."""
def _argv_for(self, *, demo_escape: bool) -> list[str]:
seen: list[list[str]] = []
def _popen(argv: list[str], **_kwargs: object) -> _FakeChild:
seen.append(argv)
return _FakeChild([])
WorkoutSession(
GrabHandoff(release=lambda: None, reacquire=lambda: None),
after=_FakeAfter(),
on_status=lambda _t: None,
binary=Path("/nonexistent/workout_app"),
hooks=ProcessHooks(popen=_popen, demo_escape=demo_escape),
).start()
return seen[0]
def test_production_never_arms_the_hatch(self) -> None:
"""The default must not hand the user a way out of a real lock."""
assert "--demo-escape" not in self._argv_for(demo_escape=False)
def test_demo_arms_the_hatch(self) -> None:
"""With demo_escape on, the app is launched with --demo-escape in lock mode."""
argv = self._argv_for(demo_escape=True)
assert argv[-1] == "--demo-escape"
assert "--lock-mode" in argv