screen-locker/screen_locker/tests/test_workout_app.py
Krzysztof kuhy Rudnicki 006ef037f2 Supervise the workout app as the lock surface, on a deadline that holds
The supervisor hands the X grab to the Flutter app only once the app has
printed WORKOUT_LOCK: ready, so the screen is covered continuously and a
window that never started is never handed the machine.

Enforcing that handshake needs a timeout that actually fires. readline() on
a pipe blocks until a newline arrives, so polling the clock around it cannot
bound the wait: against a real subprocess a 2s deadline took 30s, and the
hang was then misreported as "exited before signalling ready" -- the wrong
diagnostic for the wrong failure. The wait is now select-based and reads
whatever bytes are available, since a half-written line would otherwise
block inside readline() past the deadline just as surely. Reads accumulate
before matching, because a chunk boundary can fall inside the marker and
dropping it would strand a healthy app at the lock screen.

Verified against real pipes, not fakes: a silent app and a partial line both
time out on schedule, the ready line is still detected, and a marker split
across two reads is reassembled.

Split into _workout_ready for the 250-line cap. The grab handoff and the
process hooks are injected as frozen dataclasses so both directions must be
supplied at the call site and a test that fakes the process also fakes the
wait.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DfJC1Wu1SEgJ6X49k5Jqtg
2026-08-22 11:52:02 +02:00

230 lines
7.9 KiB
Python

"""Tests for supervising the desktop workout app as the lock surface.
The invariant every test here defends: the X grab is released ONLY once the
app has signalled that its own fullscreen window is up. Releasing it to an app
that never started would leave the machine unlocked with the obligation unmet.
"""
from __future__ import annotations
from pathlib import Path
from typing import TYPE_CHECKING
from screen_locker._workout_app import (
READY_MARKER,
GrabHandoff,
ProcessHooks,
WorkoutAppResult,
launch_workout_app,
workout_app_binary,
)
if TYPE_CHECKING:
from collections.abc import Sequence
class _FakeStdout:
"""A pipe that yields fixed lines, then EOF."""
def __init__(self, lines: Sequence[str]) -> None:
self._it = iter(lines)
def readline(self) -> str:
return next(self._it, "")
class _FakeChild:
"""A stand-in for the Flutter process."""
def __init__(self, lines: Sequence[str], *, returncode: int = 0) -> None:
self.stdout = _FakeStdout(lines)
self.returncode = returncode
self.terminated = False
self.waited = False
def terminate(self) -> None:
self.terminated = True
def wait(self) -> int:
self.waited = True
return self.returncode
class _Recorder:
"""Records grab release/reacquire in call order."""
def __init__(self) -> None:
self.calls: list[str] = []
def release(self) -> None:
self.calls.append("release")
def reacquire(self) -> None:
self.calls.append("reacquire")
def _always_readable(
rlist: object, _w: object, _x: object, _t: float
) -> tuple[object, list[object], list[object]]:
"""Stand in for select(): the fake pipe is always ready to read."""
return rlist, [], []
def _run(
lines: Sequence[str],
recorder: _Recorder,
*,
child: _FakeChild | None = None,
**kwargs: object,
) -> object:
kid = child if child is not None else _FakeChild(lines)
return launch_workout_app(
GrabHandoff(release=recorder.release, reacquire=recorder.reacquire),
binary=Path("/nonexistent/workout_app"),
hooks=ProcessHooks(popen=lambda *_a, **_k: kid, selector=_always_readable),
**kwargs,
)
class TestLaunchWorkoutApp:
"""The handoff, and every way it can fail safely."""
def test_releases_the_grab_once_the_app_is_ready(self) -> None:
recorder = _Recorder()
result = _run([f"{READY_MARKER}\n"], recorder)
assert result.launched
assert recorder.calls == ["release", "reacquire"]
def test_tolerates_output_before_the_ready_line(self) -> None:
"""Flutter prints Mesa/Impeller noise before anything of ours."""
recorder = _Recorder()
result = _run(["MESA-EGL: warning\n", f"{READY_MARKER}\n"], recorder)
assert result.launched
assert "release" in recorder.calls
def test_never_releases_when_the_app_dies_before_ready(self) -> None:
"""The invariant: a dead app must not be handed the screen."""
recorder = _Recorder()
result = _run([], recorder)
assert not result.launched
assert "release" not in recorder.calls
assert "exited before signalling ready" in result.reason
def test_terminates_an_app_that_never_signals_ready(self) -> None:
recorder = _Recorder()
child = _FakeChild([])
_run([], recorder, child=child)
assert child.terminated
def test_reports_a_hang_differently_from_a_crash(self) -> None:
"""A slow first sync and a broken build need different fixes."""
recorder = _Recorder()
# Never EOFs and never readies: readline keeps returning noise.
child = _FakeChild(["noise\n"] * 10_000)
result = _run([], recorder, child=child, ready_timeout=0.0)
assert not result.launched
assert "never signalled ready" in result.reason
assert "release" not in recorder.calls
def test_times_out_on_an_app_that_starts_but_prints_nothing(self) -> None:
"""The regression: a silent live app must not hold the lock forever.
``readline()`` on a real pipe blocks, so the timeout can only be
enforced by waiting on the pipe rather than polling the clock.
"""
recorder = _Recorder()
child = _FakeChild([])
def _never_readable(
_r: object, _w: object, _x: object, _t: float
) -> tuple[list[object], list[object], list[object]]:
return [], [], []
result = launch_workout_app(
GrabHandoff(release=recorder.release, reacquire=recorder.reacquire),
binary=Path("/nonexistent/workout_app"),
hooks=ProcessHooks(popen=lambda *_a, **_k: child, selector=_never_readable),
ready_timeout=5.0,
)
assert not result.launched
assert "never signalled ready" in result.reason
assert "release" not in recorder.calls
assert child.terminated
def test_reacquires_the_grab_even_when_the_app_crashes(self) -> None:
"""However the app exits, something must hold the screen after."""
recorder = _Recorder()
child = _FakeChild([f"{READY_MARKER}\n"], returncode=-15)
result = _run([], recorder, child=child)
assert recorder.calls == ["release", "reacquire"]
assert "-15" in result.reason
def test_waits_for_the_app_to_exit(self) -> None:
recorder = _Recorder()
child = _FakeChild([f"{READY_MARKER}\n"])
_run([], recorder, child=child)
assert child.waited
def test_treats_a_missing_stdout_pipe_as_a_dead_app(self) -> None:
"""No pipe means no ready line can ever arrive -- never release."""
recorder = _Recorder()
child = _FakeChild([])
child.stdout = None
result = _run([], recorder, child=child)
assert not result.launched
assert "release" not in recorder.calls
assert child.terminated
def test_says_so_when_the_app_is_not_built(self) -> None:
recorder = _Recorder()
result = launch_workout_app(
GrabHandoff(release=recorder.release, reacquire=recorder.reacquire),
resolve_binary=lambda: None,
hooks=ProcessHooks(
popen=lambda *_a, **_k: _FakeChild([]), selector=_always_readable
),
)
assert not result.launched
assert "not built" in result.reason
assert recorder.calls == []
class TestWorkoutAppBinary:
"""Locating the built bundle."""
def test_returns_none_when_nothing_is_built(self, tmp_path: Path) -> None:
assert workout_app_binary(tmp_path) is None
def test_defaults_to_the_real_repo_root(self) -> None:
"""The no-argument call is what the lock screen actually uses."""
found = workout_app_binary()
assert found is None or found.name == "workout_app"
def test_prefers_release_over_debug(self, tmp_path: Path) -> None:
base = tmp_path / "stronglift_replacement/workout_app/build/linux/x64"
for flavour in ("debug", "release"):
binary = base / flavour / "bundle" / "workout_app"
binary.parent.mkdir(parents=True)
binary.touch()
assert workout_app_binary(tmp_path).parent.parent.name == "release"
def test_falls_back_to_debug(self, tmp_path: Path) -> None:
binary = (
tmp_path
/ "stronglift_replacement/workout_app/build/linux/x64/debug/bundle"
/ "workout_app"
)
binary.parent.mkdir(parents=True)
binary.touch()
assert workout_app_binary(tmp_path) == binary
class TestWorkoutAppResult:
"""The result carries a reason a human can act on."""
def test_repr_shows_the_outcome_and_reason(self) -> None:
"""Logged on failure paths, so it must name why, not just False."""
result = WorkoutAppResult(launched=False, reason="not built")
assert "launched=False" in repr(result)
assert "not built" in repr(result)