mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 20:07:16 +01:00
The locker was not running at all. workout-locker.service crash-looped ~1977
times in a single boot with ModuleNotFoundError while remaining `enabled`, and
has no successful start in the retained journal. A locker that cannot start is
a locker that does not lock.
Root cause: commit 9b420a9 removed the unit's WorkingDirectory and
PYTHONPATH=/opt/screen-locker on the (correct in principle) grounds that
"screen_locker is now pip-installed" -- but nothing guaranteed that install.
There was no installer, /opt/screen-locker no longer exists, and the package
was absent from every Python version's user site-packages. The unit lost its
fallback and gained a hard dependency on a manual step nobody re-ran.
install.sh fixes that as a script rather than a reminder, and *gates*: it fails
if the production interpreter cannot import screen_locker, because an installer
that reports success while the service still cannot start is precisely the
failure above. It also re-asserts an editable gatelock from ~/utils AFTER the
package install and verifies it resolves there -- pyproject pins gatelock to a
git tag, so a plain install silently replaces a local editable gatelock with a
frozen wheel, which was observed removing ScrollableSurface and taking
diet_guard down with it.
The keyboard work, all of it Tk defaults rather than local mistakes:
- Focus was invisible. Tk ships highlightcolor="#000000" -- a black ring on
#211D1B -- and relief="flat" removes the depth cue too, so a keyboard user
had no indication of where they were. Now set in the _ui_widgets factory, so
a new call site cannot omit it.
- Enter did nothing. X11 Tk binds <space> on Button and never <Return>.
- tk.Text trapped Tab (inserts a literal tab, refocuses itself) and no-opped
Shift-Tab; the only exits are the undocumented Ctrl+Tab / Ctrl+Shift+Tab.
- The lock and retry screens started with NOTHING focused. on_focus_ready was
a documented no-op arguing "buttons only, nothing to focus" -- exactly
backwards: buttons are what needs focusing when there is no other window on
screen and a global grab is held.
- The heat-skip modal grabbed input fullscreen with no focus, no ring, no
Return and no Escape. It now focuses the *declining* option, so a blind
Return keeps the workout rather than silently skipping it.
The manual-workout form's scroll viewport was pointer-only in three ways at
once: tk.Canvas has no class key bindings, so ::tk::FocusOK rejected it as a
focus stop; nothing bound wheel, PageUp/PageDown or arrows, leaving only a
draggable thumb; and canvas clipping does not unmap children, so below-the-fold
fields stayed in the tab ring while Tk never scrolled to follow focus -- Tab led
to fields the user could neither see nor reveal. Its height was also a magic
0.7*toplevel above ~230-290px of fixed chrome, i.e. satisfied at 1080p and
violated at 768p, where SUBMIT/BACK were pushed off the bottom.
All of that is gone by deletion: the surface container is now a
gatelock.ScrollableSurface, so the inner viewport is redundant. build_surface
and _ensure_container both use it, which fixes the same symmetric-clipping
problem for EVERY screen rather than one at a time; center_when_fits keeps
short screens looking exactly as they did.
Re-arming after a repaint hooks into clear_container(), which all ~19 repaint
sites already call. "Remember to call settle after painting" is a rule that
would eventually be missed, and on a lock a missed re-arm means unreachable
content.
The aux windows hardcoded geometry("600x400") and ("700x450") while displaying
more than that -- the relaxed-day prompt's own button row requests 862px inside
a 700px window, sheared at both edges. That pre-dates this work by a wide
margin (Tk's default focus ring accounts for 4px of the 162px overflow). They
now size to content, floored at the old values and clamped to the display.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
303 lines
9.6 KiB
Python
303 lines
9.6 KiB
Python
"""Shared fixtures and helpers for screen_locker tests.
|
|
|
|
Safety:
|
|
``_block_real_tk_and_exit`` (autouse) replaces the **entire** ``tk`` module
|
|
reference inside ``screen_lock`` with a MagicMock, replaces ``GateRoot``
|
|
with a callable returning that same mock root, and stubs ``sys.exit`` — no
|
|
test can create a real Tk root, go fullscreen, or grab input, even one
|
|
that forgets to request the explicit ``mock_tk`` fixture.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from contextlib import ExitStack
|
|
from datetime import datetime, timezone
|
|
import json
|
|
import tkinter as tk
|
|
from typing import TYPE_CHECKING
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from screen_locker.tests._gatelock_fixtures import (
|
|
FAKE_OUTPUTS,
|
|
TWO_OUTPUTS,
|
|
_hermetic_gatelock,
|
|
dual_output,
|
|
)
|
|
from screen_locker.tests._locker_factories import (
|
|
_make_locker,
|
|
create_locker,
|
|
create_locker_early_bird,
|
|
create_locker_relaxed_day,
|
|
)
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Generator, Iterator
|
|
from pathlib import Path
|
|
|
|
|
|
# Every module that imports ``tkinter as tk`` and calls it directly. The UI is
|
|
# split across these, so each ``tk`` must be patched — so no test touches a real
|
|
# display and a ``mock_tk`` holder sees widgets made on that same mock.
|
|
_TK_MODULES = (
|
|
"screen_locker.screen_lock",
|
|
"screen_locker._sick_dialog",
|
|
"screen_locker._manual_workout_dialog",
|
|
"screen_locker._manual_workout_widgets",
|
|
"screen_locker._ui_widgets",
|
|
"screen_locker._window_setup",
|
|
"screen_locker.status_view",
|
|
"screen_locker._heat_skip",
|
|
"screen_locker._surface_group",
|
|
# The scroll viewport the lock surfaces are built from lives in gatelock
|
|
# and imports tkinter independently, so it needs the same mock -- otherwise
|
|
# container.first is a *real* tk.Frame whose winfo_children() cannot be
|
|
# stubbed, and every surface assertion breaks.
|
|
"gatelock._scrollable",
|
|
)
|
|
__all__ = [
|
|
"FAKE_OUTPUTS",
|
|
"TWO_OUTPUTS",
|
|
"_hermetic_gatelock",
|
|
"_make_locker",
|
|
"create_locker",
|
|
"create_locker_early_bird",
|
|
"create_locker_relaxed_day",
|
|
"dual_output",
|
|
]
|
|
|
|
_VT_SHUTIL = "gatelock._vt.shutil"
|
|
_VT_SUBPROCESS = "gatelock._vt.subprocess"
|
|
|
|
|
|
def _make_mock_tk() -> MagicMock:
|
|
"""Build a MagicMock that stands in for the ``tkinter`` module."""
|
|
mock = MagicMock()
|
|
mock_root = MagicMock()
|
|
mock_root.winfo_screenwidth.return_value = 1920
|
|
mock_root.winfo_screenheight.return_value = 1080
|
|
mock.Tk.return_value = mock_root
|
|
|
|
mock_frame = MagicMock()
|
|
mock_frame.winfo_children.return_value = []
|
|
mock.Frame.return_value = mock_frame
|
|
|
|
# Keep real TclError so ``except tk.TclError`` still works.
|
|
mock.TclError = tk.TclError
|
|
return mock
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _block_real_tk_and_exit() -> Iterator[None]:
|
|
"""Replace the whole ``tk`` module and ``sys.exit`` for every test.
|
|
|
|
Patching the entire module (not just ``tk.Tk``) ensures that
|
|
**nothing** in tkinter can touch the real display server.
|
|
"""
|
|
mock = _make_mock_tk()
|
|
|
|
with ExitStack() as stack:
|
|
for module in _TK_MODULES:
|
|
stack.enter_context(patch(f"{module}.tk", mock))
|
|
stack.enter_context(
|
|
patch(
|
|
"screen_locker.screen_lock.GateRoot",
|
|
return_value=mock.Tk.return_value,
|
|
)
|
|
)
|
|
stack.enter_context(patch("screen_locker.screen_lock.sys.exit"))
|
|
yield
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def mock_subprocess_run() -> Generator[MagicMock]:
|
|
"""Block real subprocess calls (e.g. setxkbmap) for every test.
|
|
|
|
Also exposed as a named fixture so individual tests can assert
|
|
on the calls made (e.g. VT switching tests).
|
|
|
|
``shutil.which`` is mocked to return a stable fake path so tests work
|
|
regardless of whether setxkbmap is installed on the host machine.
|
|
"""
|
|
with (
|
|
patch(f"{_VT_SHUTIL}.which", return_value="/usr/bin/setxkbmap"),
|
|
patch(f"{_VT_SUBPROCESS}.run") as mock,
|
|
):
|
|
yield mock
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _block_real_network() -> Iterator[None]:
|
|
"""Block real subnet probes and wttr.in calls for every test.
|
|
|
|
Otherwise phone verification / StatusWindow's temperature fetch would
|
|
reach the real network. Tests needing a real probe patch it locally.
|
|
"""
|
|
targets = (
|
|
"screen_locker._phone_verification.socket.create_connection",
|
|
"screen_locker._temperature.urllib.request.urlopen",
|
|
)
|
|
with ExitStack() as stack:
|
|
for target in targets:
|
|
stack.enter_context(patch(target, side_effect=OSError("blocked")))
|
|
yield
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _isolate_sick_history(tmp_path: Path) -> Iterator[None]:
|
|
"""Redirect SICK_HISTORY_FILE to tmp_path so tests cannot touch real state."""
|
|
target = tmp_path / "sick_history.json"
|
|
with (
|
|
patch(
|
|
"screen_locker._sick_tracker.SICK_HISTORY_FILE",
|
|
target,
|
|
),
|
|
patch(
|
|
"screen_locker._constants.SICK_HISTORY_FILE",
|
|
target,
|
|
),
|
|
):
|
|
yield
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _isolate_early_bird_pending(tmp_path: Path) -> Iterator[None]:
|
|
"""Redirect EARLY_BIRD_PENDING_FILE to tmp_path so tests use a clean file."""
|
|
target = tmp_path / "early_bird_pending.json"
|
|
with (
|
|
patch(
|
|
"screen_locker._early_bird.EARLY_BIRD_PENDING_FILE",
|
|
target,
|
|
),
|
|
patch(
|
|
"screen_locker._constants.EARLY_BIRD_PENDING_FILE",
|
|
target,
|
|
),
|
|
):
|
|
yield
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _isolate_extra_benefits(tmp_path: Path) -> Iterator[None]:
|
|
"""Redirect EXTRA_BENEFITS_FILE to tmp_path so tests cannot touch real state.
|
|
|
|
Bound by value into several modules at import time, so every bound name
|
|
needs patching individually — not just the ``_constants`` source.
|
|
"""
|
|
target = tmp_path / "extra_benefits_state.json"
|
|
with (
|
|
patch("screen_locker._constants.EXTRA_BENEFITS_FILE", target),
|
|
patch("screen_locker.screen_lock.EXTRA_BENEFITS_FILE", target),
|
|
patch("screen_locker._early_bird.EXTRA_BENEFITS_FILE", target),
|
|
patch("screen_locker._status.EXTRA_BENEFITS_FILE", target),
|
|
):
|
|
yield
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _isolate_shutdown_base(tmp_path: Path) -> Iterator[None]:
|
|
"""Redirect SHUTDOWN_BASE_FILE to tmp_path so tests cannot touch real state.
|
|
|
|
Pre-seeded with today's date so reset_to_base_if_new_day() is a no-op by
|
|
default (matching the real file's steady state) -- tests that want to
|
|
exercise the actual reset path patch reset_to_base_if_new_day directly,
|
|
same as the rest of the suite already does.
|
|
"""
|
|
target = tmp_path / "shutdown_base.json"
|
|
today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d")
|
|
target.write_text(
|
|
json.dumps(
|
|
{"base_mon_wed_hour": 21, "base_thu_sun_hour": 21, "last_reset_date": today}
|
|
)
|
|
)
|
|
with (
|
|
patch("screen_locker._constants.SHUTDOWN_BASE_FILE", target),
|
|
patch("screen_locker.screen_lock.SHUTDOWN_BASE_FILE", target),
|
|
):
|
|
yield
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _isolate_sick_day_state(tmp_path: Path) -> Iterator[None]:
|
|
"""Redirect SICK_DAY_STATE_FILE to tmp_path so tests cannot touch real state."""
|
|
target = tmp_path / "sick_day_state.json"
|
|
with (
|
|
patch("screen_locker._constants.SICK_DAY_STATE_FILE", target),
|
|
patch("screen_locker.screen_lock.SICK_DAY_STATE_FILE", target),
|
|
):
|
|
yield
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _isolate_scheduled_skips(tmp_path: Path) -> Iterator[None]:
|
|
"""Redirect SCHEDULED_SKIPS_FILE to tmp_path so tests use a clean file."""
|
|
target = tmp_path / "scheduled_skips.json"
|
|
with patch(
|
|
"screen_locker._log_mixin.SCHEDULED_SKIPS_FILE",
|
|
target,
|
|
):
|
|
yield
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _mock_weekly_logic() -> Iterator[None]:
|
|
"""Default to Fri-Mon enforcement with weekly minimum not yet met.
|
|
|
|
Without this, tests that run on a Tue/Wed/Thu would hit the relaxed-day
|
|
branch instead of the full-lock path that existing tests expect.
|
|
Setting has_weekly_minimum=False ensures the full lock is shown
|
|
(weekly quota not reached → enforce).
|
|
"""
|
|
with (
|
|
patch(
|
|
"screen_locker.screen_lock.is_relaxed_day",
|
|
return_value=False,
|
|
),
|
|
patch(
|
|
"screen_locker.screen_lock.has_weekly_minimum",
|
|
return_value=False,
|
|
),
|
|
):
|
|
yield
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_tk() -> Generator[MagicMock]:
|
|
"""Mock the ``tkinter`` module across every UI module for display-free tests.
|
|
|
|
Patches the same single mock into all ``_TK_MODULES`` so assertions on
|
|
``mock_tk.Button`` capture widgets created by any of the split UI mixins
|
|
(``_ui_widgets``, ``_sick_dialog``, ...), not just ``screen_lock``.
|
|
"""
|
|
mock = _make_mock_tk()
|
|
with ExitStack() as stack:
|
|
for module in _TK_MODULES:
|
|
stack.enter_context(patch(f"{module}.tk", mock))
|
|
stack.enter_context(
|
|
patch(
|
|
"screen_locker.screen_lock.GateRoot",
|
|
return_value=mock.Tk.return_value,
|
|
)
|
|
)
|
|
yield mock
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_sys_exit() -> Generator[MagicMock]:
|
|
"""Mock sys.exit to prevent test termination."""
|
|
with patch("screen_locker.screen_lock.sys.exit") as mock:
|
|
yield mock
|
|
|
|
|
|
@pytest.fixture
|
|
def _mock_sys_exit(mock_sys_exit: MagicMock) -> MagicMock:
|
|
"""Alias for mock_sys_exit when the return value is unused."""
|
|
return mock_sys_exit
|
|
|
|
|
|
@pytest.fixture
|
|
def temp_log_file(tmp_path: Path) -> Path:
|
|
"""Create a temporary log file path."""
|
|
return tmp_path / "workout_log.json"
|