mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 21:27:13 +01:00
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
249 lines
9.1 KiB
Python
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
|