Migrate wake_alarm to the shared gatelock backend

Third and final leg of the diet_guard -> screen-locker -> wake_alarm
gatelock extraction. WakeAlarm now composes gatelock.GateRoot +
LockWindow(mode="soft") instead of driving tk.Tk() directly, and moves
hardware teardown into on_close() so it runs on every exit path
(including SIGTERM), closing the prior gap where killing the process
left fans maxed. _state.py's HMAC import moves to gatelock.log_integrity,
the last call site of python_pkg.shared.log_integrity, which is deleted.

Manually verified on the real display: fullscreen overlay renders,
xdotool-typed input reaches the dismiss-code Entry (mode="soft" keeps
the existing typing-focus behavior), and SIGTERM exits within ~0.4s
while restoring hardware state. Full repo suite: 949 passed, 100%
branch coverage.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A7vbgtFfZmfxJtN5DdtJky
This commit is contained in:
Krzysztof kuhy Rudnicki 2026-06-22 07:33:49 +02:00
parent 12fe41049f
commit e0ecd58c1d
10 changed files with 311 additions and 329 deletions

View File

@ -0,0 +1,19 @@
{
"title": "Migrate wake_alarm to the shared gatelock backend",
"objective": "Replace wake_alarm's hand-rolled tk.Tk()/fullscreen/topmost setup and its log_integrity import with the shared `gatelock` package, the third and final leg of the diet_guard -> screen-locker -> wake_alarm migration. WakeAlarm now owns a gatelock.LockWindow (mode=\"soft\": topmost+fullscreen only, no overrideredirect/grab/VT-disable, matching today's behavior) via composition and implements LockWindowHooks, instead of driving tk.Tk() directly. Success: the dismiss-challenge window and beep/fan/audio/plug behavior are unchanged for the end user, demo mode manually verified on the real X11 display including typed keyboard input into the dismiss-code Entry, hardware teardown confirmed to run on SIGTERM (not just a clean dismiss) by closing the gap where killing the process previously left fans maxed, all existing wake_alarm tests adapted, and the whole monorepo stays at 100% branch coverage.",
"acceptance_criteria": [
"WakeAlarm no longer calls tk.Tk()/geometry/attributes directly in __init__; it composes gatelock.GateRoot + gatelock.LockWindow with LockConfig(mode=\"soft\")",
"Hardware teardown (fans, audio, display, plug) moved from a destroyed _close() method into an on_close() LockWindowHooks callback, so it runs on every LockWindow.close() exit path including SIGTERM/SIGINT",
"_state.py imports HMAC functions from gatelock.log_integrity instead of python_pkg.shared.log_integrity (last remaining call site); python_pkg/shared/log_integrity.py and its test are deleted",
"testsAndMisc/meta/requirements.txt already pins gatelock@v0.1.0 from the diet_guard migration; no change needed",
"python_pkg/wake_alarm test suite passes with 100% branch coverage, with tk.Tk()-construction assertions updated to the gatelock LockWindow mechanics, run()/close() tests rewritten to assert delegation (not invoke the real LockWindow.run(), which installs real SIGTERM/SIGINT handlers), and new tests added for on_focus_ready/on_callback_error/on_close",
"_alarm.py and its test files stay under the repo's 500-line limit (new gatelock-hook tests split into test_alarm_part4.py)",
"demo-mode alarm manually run on the real X11 display: fullscreen WAKE UP overlay renders, xdotool-typed input reaches the dismiss-code Entry (confirming mode=\"soft\" keeps the old typing-focus behavior), and SIGTERM exits the process within ~0.4s while restoring hardware state",
"ruff, mypy, and pylint (10/10) all pass on the changed wake_alarm files"
],
"out_of_scope": [
"Hard-lock (overrideredirect + grab) for wake_alarm -- deferred per the plan: no watchdog exists yet for a clean, silent lock failure, so a typing-focus edge case at 6am is the worst place to discover one",
"Verification against a real scheduled alarm trigger (only --demo --trigger-now was run this session); the plan flags this as required before the migration is considered fully done, since a silent failure to lock would look identical to a correctly-skipped non-alarm day"
],
"verifier": "pytest (full repo suite) --cov --cov-branch --cov-fail-under=100; pre-commit run --files <changed wake_alarm files>; manual run of `python -m python_pkg.wake_alarm._alarm --demo --trigger-now` with xdotool keystroke injection and a real SIGTERM"
}

View File

@ -0,0 +1,42 @@
{
"intent": "Migrate wake_alarm to the shared gatelock backend (mode=\"soft\"), the third and final leg of the diet_guard -> screen-locker -> wake_alarm gatelock extraction plan.",
"scope": [
"python_pkg/wake_alarm/_alarm.py",
"python_pkg/wake_alarm/_state.py",
"python_pkg/wake_alarm/tests/test_alarm.py, test_alarm_part2.py, test_alarm_part3.py, test_alarm_part4.py",
"python_pkg/shared/log_integrity.py and its test (deleted, last call site migrated)",
"Non-goal: hard-lock (overrideredirect+grab) for wake_alarm, deferred per plan"
],
"changes": [
"WakeAlarm now owns a gatelock.LockWindow (mode=\"soft\") instead of driving tk.Tk() fullscreen/topmost setup directly",
"Added on_focus_ready/on_callback_error/on_close LockWindowHooks; hardware teardown (fans/audio/display/plug) moved into on_close so it runs on every exit path including SIGTERM, not just a clean dismiss",
"_state.py HMAC import swapped from python_pkg.shared.log_integrity to gatelock.log_integrity (last remaining call site); deleted the now-superseded shared module and its test",
"Updated wake_alarm test fixtures to mock gatelock.GateRoot alongside tk; rewrote run()/close() tests to assert delegation rather than invoking the real LockWindow.run() (avoids registering real SIGTERM/SIGINT handlers in the test process)",
"Split new gatelock-hook tests into test_alarm_part4.py to keep _alarm.py and its test files under the repo's 500-line limit"
],
"verification": [
{
"command": "python -m python_pkg.wake_alarm._alarm --demo --trigger-now (manual, real X11 display)",
"result": "pass",
"evidence": "Fullscreen WAKE UP overlay rendered; xdotool type into the dismiss-code Entry showed typed characters, confirming keyboard focus still reaches the Entry under mode=\"soft\" (no overrideredirect/grab) exactly as before"
},
{
"command": "kill -TERM <pid> against a running demo alarm",
"result": "pass",
"evidence": "Process exited within ~0.4s (within the 250ms gatelock keepalive tick), confirming on_close (hardware restore) runs on SIGTERM, not just normal dismiss"
},
{
"command": "python -m pytest (full repo suite)",
"result": "pass",
"evidence": "949 passed; TOTAL coverage 100.00% (statements and branches); file-length check green after splitting test_alarm_part4.py"
}
],
"risks": [
"wake_alarm has no watchdog against a clean, silent failure to lock (only Restart=on-failure, which doesn't catch a no-op exit) -- per the plan, this migration is not considered fully done until verified against a real scheduled alarm trigger, not just --demo",
"Manual verification runs wrote test data into the real python_pkg/wake_alarm/wake_state.json; caught and reverted before commit (git checkout --) so no test pollution lands in the repo or affects real wake-state history"
],
"rollback": [
"git revert this commit; gatelock pin in meta/requirements.txt is unaffected (diet_guard and screen-locker keep using it)",
"After rollback, confirm `python -m pytest python_pkg/wake_alarm/ python_pkg/shared/` is green and wake_alarm's systemd service starts cleanly"
]
}

View File

@ -1,80 +0,0 @@
"""HMAC-based integrity checking for signed state entries."""
from __future__ import annotations
import hashlib
import hmac
import json
import logging
from pathlib import Path
import secrets
_logger = logging.getLogger(__name__)
# HMAC key for signing state entries (root-owned, 0600)
HMAC_KEY_FILE = Path("/etc/workout-locker/hmac.key")
def _load_hmac_key() -> bytes | None:
"""Load HMAC key from the root-owned key file.
Returns the key bytes, or None if the file cannot be read.
"""
try:
return HMAC_KEY_FILE.read_bytes().strip()
except OSError:
_logger.warning("Cannot read HMAC key from %s", HMAC_KEY_FILE)
return None
def _generate_hmac_key() -> bytes | None:
"""Generate a new HMAC key and write it to the key file.
The key file must be writable (requires root or setup script).
Returns the new key bytes, or None on failure.
"""
key = secrets.token_bytes(32)
try:
HMAC_KEY_FILE.parent.mkdir(parents=True, exist_ok=True)
HMAC_KEY_FILE.write_bytes(key)
except OSError:
_logger.warning("Cannot write HMAC key to %s", HMAC_KEY_FILE)
return None
return key
def compute_entry_hmac(entry_data: dict[str, object]) -> str | None:
"""Compute HMAC-SHA256 for a state entry.
Args:
entry_data: The entry dict (without the 'hmac' field).
Returns:
Hex-encoded HMAC string, or None if the key is unavailable.
"""
key = _load_hmac_key()
if key is None:
return None
payload = json.dumps(entry_data, sort_keys=True, separators=(",", ":"))
return hmac.new(key, payload.encode(), hashlib.sha256).hexdigest()
def verify_entry_hmac(entry: dict[str, object]) -> bool:
"""Verify HMAC signature of a state entry.
Args:
entry: The full entry dict including the 'hmac' field.
Returns:
True if the HMAC is valid, False if invalid or key unavailable.
"""
stored_hmac = entry.get("hmac")
if not isinstance(stored_hmac, str):
return False
key = _load_hmac_key()
if key is None:
return False
entry_without_hmac = {k: v for k, v in entry.items() if k != "hmac"}
payload = json.dumps(entry_without_hmac, sort_keys=True, separators=(",", ":"))
expected = hmac.new(key, payload.encode(), hashlib.sha256).hexdigest()
return hmac.compare_digest(stored_hmac, expected)

View File

@ -1,152 +0,0 @@
"""Tests for shared log_integrity HMAC signing and verification."""
from __future__ import annotations
import hashlib
import hmac
import json
from typing import TYPE_CHECKING
from unittest.mock import patch
from python_pkg.shared.log_integrity import (
_generate_hmac_key,
_load_hmac_key,
compute_entry_hmac,
verify_entry_hmac,
)
if TYPE_CHECKING:
from pathlib import Path
class TestLoadHmacKey:
"""Tests for _load_hmac_key."""
def test_loads_key_from_file(self, tmp_path: Path) -> None:
"""Test loading HMAC key from existing file."""
key_file = tmp_path / "hmac.key"
key_file.write_bytes(b"secret_key_bytes")
with patch(
"python_pkg.shared.log_integrity.HMAC_KEY_FILE",
key_file,
):
result = _load_hmac_key()
assert result == b"secret_key_bytes"
def test_returns_none_on_missing_file(self, tmp_path: Path) -> None:
"""Test returns None when key file doesn't exist."""
key_file = tmp_path / "nonexistent.key"
with patch(
"python_pkg.shared.log_integrity.HMAC_KEY_FILE",
key_file,
):
result = _load_hmac_key()
assert result is None
class TestGenerateHmacKey:
"""Tests for _generate_hmac_key."""
def test_generates_and_writes_key(self, tmp_path: Path) -> None:
"""Test key generation creates file with 32-byte key."""
key_file = tmp_path / "subdir" / "hmac.key"
with patch(
"python_pkg.shared.log_integrity.HMAC_KEY_FILE",
key_file,
):
result = _generate_hmac_key()
assert result is not None
assert len(result) == 32
assert key_file.read_bytes() == result
def test_returns_none_on_write_failure(self) -> None:
"""Test returns None when file cannot be written."""
with patch(
"python_pkg.shared.log_integrity.HMAC_KEY_FILE",
) as mock_path:
mock_path.parent.mkdir.side_effect = OSError("permission denied")
result = _generate_hmac_key()
assert result is None
class TestComputeEntryHmac:
"""Tests for compute_entry_hmac."""
def test_computes_hmac_for_entry(self, tmp_path: Path) -> None:
"""Test HMAC computation produces valid hex string."""
key_file = tmp_path / "hmac.key"
key = b"test_key_12345"
key_file.write_bytes(key)
entry = {"timestamp": "2025-01-01T00:00:00", "workout_data": {"type": "test"}}
with patch(
"python_pkg.shared.log_integrity.HMAC_KEY_FILE",
key_file,
):
result = compute_entry_hmac(entry)
assert result is not None
# Verify manually
payload = json.dumps(entry, sort_keys=True, separators=(",", ":"))
expected = hmac.new(key, payload.encode(), hashlib.sha256).hexdigest()
assert result == expected
def test_returns_none_when_no_key(self, tmp_path: Path) -> None:
"""Test returns None when key file is missing."""
key_file = tmp_path / "nonexistent.key"
with patch(
"python_pkg.shared.log_integrity.HMAC_KEY_FILE",
key_file,
):
result = compute_entry_hmac({"data": "test"})
assert result is None
class TestVerifyEntryHmac:
"""Tests for verify_entry_hmac."""
def test_valid_hmac(self, tmp_path: Path) -> None:
"""Test verification passes with correct HMAC."""
key_file = tmp_path / "hmac.key"
key = b"verification_key"
key_file.write_bytes(key)
entry_data = {"timestamp": "2025-01-01", "workout_data": {"type": "test"}}
payload = json.dumps(entry_data, sort_keys=True, separators=(",", ":"))
correct_hmac = hmac.new(key, payload.encode(), hashlib.sha256).hexdigest()
entry = {**entry_data, "hmac": correct_hmac}
with patch(
"python_pkg.shared.log_integrity.HMAC_KEY_FILE",
key_file,
):
assert verify_entry_hmac(entry) is True
def test_invalid_hmac(self, tmp_path: Path) -> None:
"""Test verification fails with wrong HMAC."""
key_file = tmp_path / "hmac.key"
key_file.write_bytes(b"verification_key")
entry = {"timestamp": "2025-01-01", "hmac": "wrong_hmac_value"}
with patch(
"python_pkg.shared.log_integrity.HMAC_KEY_FILE",
key_file,
):
assert verify_entry_hmac(entry) is False
def test_missing_hmac_field(self) -> None:
"""Test verification fails when entry has no hmac field."""
entry: dict[str, object] = {"timestamp": "2025-01-01"}
assert verify_entry_hmac(entry) is False
def test_non_string_hmac_field(self) -> None:
"""Test verification fails when hmac field is not a string."""
entry: dict[str, object] = {"timestamp": "2025-01-01", "hmac": 12345}
assert verify_entry_hmac(entry) is False
def test_missing_key_file(self, tmp_path: Path) -> None:
"""Test verification fails when key file doesn't exist."""
key_file = tmp_path / "nonexistent.key"
entry = {"timestamp": "2025-01-01", "hmac": "some_hmac"}
with patch(
"python_pkg.shared.log_integrity.HMAC_KEY_FILE",
key_file,
):
assert verify_entry_hmac(entry) is False

View File

@ -9,6 +9,7 @@ workout-free day via HMAC-signed wake state.
from __future__ import annotations
import argparse
import contextlib
from dataclasses import dataclass
from datetime import datetime, timezone
import logging
@ -17,6 +18,8 @@ import threading
import time
import tkinter as tk
from gatelock import GateRoot, LockConfig, LockWindow
from python_pkg.shared.logging_setup import configure_logging
from python_pkg.wake_alarm._alarm_display import _restore_display, _wake_display
from python_pkg.wake_alarm._audio import (
@ -108,29 +111,16 @@ class WakeAlarm:
self.demo_mode = demo_mode
self.dismissed = False
self._stop_beep = threading.Event()
self._beep_thread: threading.Thread | None = None
self._alarm_start: float = time.monotonic()
self._active = True
self.root = tk.Tk()
self.root = GateRoot()
self.root.on_callback_error = self.on_callback_error
self.root.title("Wake Alarm" + (" [DEMO]" if demo_mode else ""))
self.root.configure(bg="#1a1a1a")
# Always hijack the full screen — demo_mode only controls timers.
# NOTE: we intentionally do NOT call overrideredirect(True): on X11 it
# removes WM management and the Entry widget can't receive keyboard
# focus, so the user can't type the dismiss code. -fullscreen +
# -topmost is enough to take over the screen while staying typeable.
screen_w = self.root.winfo_screenwidth()
screen_h = self.root.winfo_screenheight()
fullscreen = True
self.root.geometry(f"{screen_w}x{screen_h}+0+0")
self.root.attributes("-fullscreen", fullscreen)
self.root.attributes("-topmost", fullscreen)
self.root.lift()
self.root.focus_force()
self.root.update_idletasks()
# mode="soft": no watchdog exists yet for a clean, silent lock
# failure, so hard-lock (overrideredirect + grab) is deferred.
self._lock = LockWindow(self.root, LockConfig(mode="soft"), hooks=self)
self._lock.setup()
self._progress = _AlarmProgress(current_challenge=_make_challenge())
self._view = self._build_ui()
@ -141,10 +131,31 @@ class WakeAlarm:
self._schedule_skip_window_close()
self._start_beep_thread()
self._hardware = _AlarmHardware(
fan_state=_max_fans(),
audio_restore=_activate_alarm_audio(),
fan_state=_max_fans(), audio_restore=_activate_alarm_audio()
)
self._start_screen_flash()
self._lock.grab_input()
def on_focus_ready(self) -> None:
"""Put keyboard focus on the dismiss-code entry once mapped."""
with contextlib.suppress(tk.TclError):
self._view.entry.focus_force()
def on_callback_error(self) -> None:
"""Surface an unexpected callback error without dropping the alarm."""
with contextlib.suppress(tk.TclError):
self._view.status_label.configure(
text="Something went wrong — try again.",
)
self._view.entry.focus_force()
def on_close(self) -> None:
"""Restore fans/audio/display/plug; runs on every exit path, even SIGTERM."""
self._stop_beep.set()
_restore_fans(active=self._hardware.fan_state)
_restore_alarm_audio(self._hardware.audio_restore)
_restore_display()
turn_off_plug()
def _build_ui(self) -> _AlarmView:
"""Build the dismiss-challenge UI and return its widgets as a view."""
@ -316,16 +327,7 @@ class WakeAlarm:
bg="#1a1a1a",
).pack(pady=30)
self.root.after(3000, self._close)
def _close(self) -> None:
"""Close the alarm window."""
self._stop_beep.set()
_restore_fans(active=self._hardware.fan_state)
_restore_alarm_audio(self._hardware.audio_restore)
_restore_display()
turn_off_plug()
self.root.destroy()
self.root.after(3000, self._lock.close)
def _schedule_code_refresh(self) -> None:
"""Replace the current challenge periodically.
@ -389,11 +391,8 @@ class WakeAlarm:
def _start_beep_thread(self) -> None:
"""Start the background beep escalation thread."""
self._beep_thread = threading.Thread(
target=self._beep_loop,
daemon=True,
)
self._beep_thread.start()
thread = threading.Thread(target=self._beep_loop, daemon=True)
thread.start()
def _start_screen_flash(self) -> None:
"""Start flashing the screen background to attract attention."""
@ -427,8 +426,8 @@ class WakeAlarm:
self._stop_beep.wait(LOUD_TOGGLE_INTERVAL)
def run(self) -> None:
"""Start the alarm main loop."""
self.root.mainloop()
"""Start the alarm main loop, restoring hardware on every exit path."""
self._lock.run()
def _should_run_alarm() -> bool:

View File

@ -6,10 +6,11 @@ from datetime import datetime, timezone
import json
import logging
from python_pkg.shared.log_integrity import (
from gatelock.log_integrity import (
compute_entry_hmac,
verify_entry_hmac,
)
from python_pkg.wake_alarm._constants import WAKE_STATE_FILE, WORKOUT_LOG_FILE
_logger = logging.getLogger(__name__)

View File

@ -59,7 +59,13 @@ def _make_mock_tk() -> MagicMock:
def _block_real_tk() -> Generator[MagicMock]:
"""Prevent any real Tk windows in tests."""
mock = _make_mock_tk()
with patch("python_pkg.wake_alarm._alarm.tk", mock):
with (
patch("python_pkg.wake_alarm._alarm.tk", mock),
patch(
"python_pkg.wake_alarm._alarm.GateRoot",
return_value=mock.Tk.return_value,
),
):
yield mock
@ -67,7 +73,13 @@ def _block_real_tk() -> Generator[MagicMock]:
def mock_tk_module() -> Generator[MagicMock]:
"""Provide explicit access to the mocked tk module."""
mock = _make_mock_tk()
with patch("python_pkg.wake_alarm._alarm.tk", mock):
with (
patch("python_pkg.wake_alarm._alarm.tk", mock),
patch(
"python_pkg.wake_alarm._alarm.GateRoot",
return_value=mock.Tk.return_value,
),
):
yield mock

View File

@ -40,7 +40,13 @@ def _make_mock_tk() -> MagicMock:
def _block_real_tk() -> Generator[MagicMock]:
"""Prevent any real Tk windows in tests."""
mock = _make_mock_tk()
with patch("python_pkg.wake_alarm._alarm.tk", mock):
with (
patch("python_pkg.wake_alarm._alarm.tk", mock),
patch(
"python_pkg.wake_alarm._alarm.GateRoot",
return_value=mock.Tk.return_value,
),
):
yield mock
@ -66,7 +72,13 @@ def _block_extra_devices() -> Generator[MagicMock]:
def mock_tk_module() -> Generator[MagicMock]:
"""Provide explicit access to the mocked tk module."""
mock = _make_mock_tk()
with patch("python_pkg.wake_alarm._alarm.tk", mock):
with (
patch("python_pkg.wake_alarm._alarm.tk", mock),
patch(
"python_pkg.wake_alarm._alarm.GateRoot",
return_value=mock.Tk.return_value,
),
):
yield mock
@ -87,15 +99,15 @@ class TestWakeAlarmInit:
assert alarm.demo_mode is True
assert alarm.dismissed is False
mock_root = mock_tk_module.Tk.return_value
# We deliberately drop overrideredirect (X11 focus bug); fullscreen+topmost
# are what take over the screen now.
# LockConfig(mode="soft") never sets overrideredirect (X11 focus bug);
# fullscreen+topmost are what take over the screen now.
mock_root.overrideredirect.assert_not_called()
fs_calls = [
c
for c in mock_root.attributes.call_args_list
if c.args and c.args[0] == "-fullscreen"
if c.kwargs.get("fullscreen") is True
]
assert fs_calls, "-fullscreen attribute must be set"
assert fs_calls, "fullscreen attribute must be set"
alarm._stop_beep.set() # Stop beep thread
def test_production_mode_fullscreen(
@ -110,9 +122,9 @@ class TestWakeAlarmInit:
fs_calls = [
c
for c in mock_root.attributes.call_args_list
if c.args and c.args[0] == "-fullscreen"
if c.kwargs.get("fullscreen") is True
]
assert fs_calls, "-fullscreen attribute must be set"
assert fs_calls, "fullscreen attribute must be set"
alarm._stop_beep.set()
@ -370,48 +382,6 @@ class TestBeepLoop:
alarm._stop_beep.set()
class TestClose:
"""Tests for the alarm close path."""
def test_close_stops_beep_and_destroys(
self,
mock_tk_module: MagicMock,
) -> None:
"""_close sets stop event and destroys root."""
del mock_tk_module
alarm = WakeAlarm(demo_mode=True)
alarm._close()
assert alarm._stop_beep.is_set()
alarm.root.destroy.assert_called()
def test_close_restores_fans(
self,
mock_tk_module: MagicMock,
) -> None:
"""_close calls _restore_fans with the saved fan state."""
del mock_tk_module
alarm = WakeAlarm(demo_mode=True)
alarm._hardware.fan_state = True
with patch("python_pkg.wake_alarm._alarm._restore_fans") as mock_restore:
alarm._close()
mock_restore.assert_called_once_with(active=True)
def test_close_restores_audio(
self,
mock_tk_module: MagicMock,
) -> None:
"""_close restores the default sink captured at activation."""
del mock_tk_module
alarm = WakeAlarm(demo_mode=True)
alarm._hardware.audio_restore = "jbl_sink"
with patch(
"python_pkg.wake_alarm._alarm._restore_alarm_audio",
) as mock_restore:
alarm._close()
mock_restore.assert_called_once_with("jbl_sink")
alarm._stop_beep.set()
class TestScreenFlash:
"""Tests for _start_screen_flash and _flash_step."""

View File

@ -39,7 +39,13 @@ def _make_mock_tk() -> MagicMock:
def _block_real_tk() -> Generator[MagicMock]:
"""Prevent any real Tk windows in tests."""
mock = _make_mock_tk()
with patch("python_pkg.wake_alarm._alarm.tk", mock):
with (
patch("python_pkg.wake_alarm._alarm.tk", mock),
patch(
"python_pkg.wake_alarm._alarm.GateRoot",
return_value=mock.Tk.return_value,
),
):
yield mock
@ -65,7 +71,13 @@ def _block_extra_devices() -> Generator[MagicMock]:
def mock_tk_module() -> Generator[MagicMock]:
"""Provide explicit access to the mocked tk module."""
mock = _make_mock_tk()
with patch("python_pkg.wake_alarm._alarm.tk", mock):
with (
patch("python_pkg.wake_alarm._alarm.tk", mock),
patch(
"python_pkg.wake_alarm._alarm.GateRoot",
return_value=mock.Tk.return_value,
),
):
yield mock
@ -135,14 +147,20 @@ class TestBeepLoopPhases:
class TestRunMethod:
"""Tests for the run() method."""
def test_run_calls_mainloop(
def test_run_delegates_to_lock(
self,
mock_tk_module: MagicMock,
) -> None:
"""run() calls root.mainloop()."""
"""run() hands off to the owned LockWindow.
Asserts delegation rather than calling the real LockWindow.run(),
which installs real SIGTERM/SIGINT handlers in the test process.
"""
del mock_tk_module
alarm = WakeAlarm(demo_mode=True)
with patch.object(alarm._lock, "run") as mock_run:
alarm.run()
alarm.root.mainloop.assert_called_once()
mock_run.assert_called_once_with()
alarm._stop_beep.set()

View File

@ -0,0 +1,153 @@
"""Tests for WakeAlarm's gatelock hooks: on_focus_ready, on_callback_error, on_close."""
from __future__ import annotations
import tkinter as tk
from typing import TYPE_CHECKING
from unittest.mock import MagicMock, patch
import pytest
if TYPE_CHECKING:
from collections.abc import Generator
from python_pkg.wake_alarm._alarm import WakeAlarm
# ---------------------------------------------------------------------------
# Helpers (duplicated from part 1 so this file is self-contained)
# ---------------------------------------------------------------------------
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.return_value = MagicMock()
mock.Label.return_value = MagicMock()
mock.Entry.return_value = MagicMock()
mock.TclError = tk.TclError
mock.END = tk.END
return mock
@pytest.fixture(autouse=True)
def _block_real_tk() -> Generator[MagicMock]:
"""Prevent any real Tk windows in tests."""
mock = _make_mock_tk()
with (
patch("python_pkg.wake_alarm._alarm.tk", mock),
patch(
"python_pkg.wake_alarm._alarm.GateRoot",
return_value=mock.Tk.return_value,
),
):
yield mock
@pytest.fixture(autouse=True)
def _block_extra_devices() -> Generator[MagicMock]:
"""Prevent real subprocess.Popen calls for extra ALSA devices."""
with (
patch("python_pkg.wake_alarm._alarm._play_on_extra_devices") as mock,
patch("python_pkg.wake_alarm._alarm._max_fans", return_value=False),
patch("python_pkg.wake_alarm._alarm._restore_fans"),
patch("python_pkg.wake_alarm._alarm._set_max_brightness"),
patch("python_pkg.wake_alarm._alarm._wake_display"),
patch("python_pkg.wake_alarm._alarm._warn_if_no_real_sink"),
patch("python_pkg.wake_alarm._alarm._activate_alarm_audio", return_value=None),
patch("python_pkg.wake_alarm._alarm._restore_alarm_audio"),
patch("python_pkg.wake_alarm._alarm.turn_on_plug"),
patch("python_pkg.wake_alarm._alarm.turn_off_plug"),
):
yield mock
@pytest.fixture
def mock_tk_module() -> Generator[MagicMock]:
"""Provide explicit access to the mocked tk module."""
mock = _make_mock_tk()
with (
patch("python_pkg.wake_alarm._alarm.tk", mock),
patch(
"python_pkg.wake_alarm._alarm.GateRoot",
return_value=mock.Tk.return_value,
),
):
yield mock
class TestGatelockHooks:
"""Tests for the LockWindowHooks callbacks (on_focus_ready/on_callback_error)."""
def test_on_focus_ready_focuses_entry(
self,
mock_tk_module: MagicMock,
) -> None:
"""on_focus_ready forces focus onto the dismiss-code entry."""
del mock_tk_module
alarm = WakeAlarm(demo_mode=True)
alarm._view.entry.focus_force.reset_mock()
alarm.on_focus_ready()
alarm._view.entry.focus_force.assert_called_once()
alarm._stop_beep.set()
def test_on_callback_error_surfaces_and_refocuses(
self,
mock_tk_module: MagicMock,
) -> None:
"""on_callback_error shows a message and refocuses the entry."""
del mock_tk_module
alarm = WakeAlarm(demo_mode=True)
alarm._view.entry.focus_force.reset_mock()
alarm.on_callback_error()
alarm._view.status_label.configure.assert_called_with(
text="Something went wrong — try again.",
)
alarm._view.entry.focus_force.assert_called_once()
alarm._stop_beep.set()
class TestClose:
"""Tests for the alarm's gatelock close path (LockWindow.close/on_close)."""
def test_lock_close_stops_beep_and_destroys(
self,
mock_tk_module: MagicMock,
) -> None:
"""LockWindow.close() runs on_close (stop event) and destroys root."""
del mock_tk_module
alarm = WakeAlarm(demo_mode=True)
alarm._lock.close()
assert alarm._stop_beep.is_set()
alarm.root.destroy.assert_called()
def test_on_close_restores_fans(
self,
mock_tk_module: MagicMock,
) -> None:
"""on_close calls _restore_fans with the saved fan state."""
del mock_tk_module
alarm = WakeAlarm(demo_mode=True)
alarm._hardware.fan_state = True
with patch("python_pkg.wake_alarm._alarm._restore_fans") as mock_restore:
alarm.on_close()
mock_restore.assert_called_once_with(active=True)
alarm._stop_beep.set()
def test_on_close_restores_audio(
self,
mock_tk_module: MagicMock,
) -> None:
"""on_close restores the default sink captured at activation."""
del mock_tk_module
alarm = WakeAlarm(demo_mode=True)
alarm._hardware.audio_restore = "jbl_sink"
with patch(
"python_pkg.wake_alarm._alarm._restore_alarm_audio",
) as mock_restore:
alarm.on_close()
mock_restore.assert_called_once_with("jbl_sink")
alarm._stop_beep.set()