diff --git a/docs/superpowers/contracts/wake-alarm-gatelock-migration-2026-06-22.json b/docs/superpowers/contracts/wake-alarm-gatelock-migration-2026-06-22.json new file mode 100644 index 0000000..8e8e09a --- /dev/null +++ b/docs/superpowers/contracts/wake-alarm-gatelock-migration-2026-06-22.json @@ -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 ; manual run of `python -m python_pkg.wake_alarm._alarm --demo --trigger-now` with xdotool keystroke injection and a real SIGTERM" +} diff --git a/docs/superpowers/evidence/wake-alarm-gatelock-migration-2026-06-22.json b/docs/superpowers/evidence/wake-alarm-gatelock-migration-2026-06-22.json new file mode 100644 index 0000000..ba9cbdc --- /dev/null +++ b/docs/superpowers/evidence/wake-alarm-gatelock-migration-2026-06-22.json @@ -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 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" + ] +} diff --git a/python_pkg/shared/log_integrity.py b/python_pkg/shared/log_integrity.py deleted file mode 100644 index 762c702..0000000 --- a/python_pkg/shared/log_integrity.py +++ /dev/null @@ -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) diff --git a/python_pkg/shared/tests/test_log_integrity.py b/python_pkg/shared/tests/test_log_integrity.py deleted file mode 100644 index 1f83dbb..0000000 --- a/python_pkg/shared/tests/test_log_integrity.py +++ /dev/null @@ -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 diff --git a/python_pkg/wake_alarm/_alarm.py b/python_pkg/wake_alarm/_alarm.py index 0fb21e7..c74df62 100644 --- a/python_pkg/wake_alarm/_alarm.py +++ b/python_pkg/wake_alarm/_alarm.py @@ -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: diff --git a/python_pkg/wake_alarm/_state.py b/python_pkg/wake_alarm/_state.py index 1ad4f3c..0066e90 100644 --- a/python_pkg/wake_alarm/_state.py +++ b/python_pkg/wake_alarm/_state.py @@ -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__) diff --git a/python_pkg/wake_alarm/tests/test_alarm.py b/python_pkg/wake_alarm/tests/test_alarm.py index cee6576..a63ecd3 100644 --- a/python_pkg/wake_alarm/tests/test_alarm.py +++ b/python_pkg/wake_alarm/tests/test_alarm.py @@ -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 diff --git a/python_pkg/wake_alarm/tests/test_alarm_part2.py b/python_pkg/wake_alarm/tests/test_alarm_part2.py index bbfd70d..e7a7181 100644 --- a/python_pkg/wake_alarm/tests/test_alarm_part2.py +++ b/python_pkg/wake_alarm/tests/test_alarm_part2.py @@ -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.""" diff --git a/python_pkg/wake_alarm/tests/test_alarm_part3.py b/python_pkg/wake_alarm/tests/test_alarm_part3.py index 157698e..249c4d5 100644 --- a/python_pkg/wake_alarm/tests/test_alarm_part3.py +++ b/python_pkg/wake_alarm/tests/test_alarm_part3.py @@ -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) - alarm.run() - alarm.root.mainloop.assert_called_once() + with patch.object(alarm._lock, "run") as mock_run: + alarm.run() + mock_run.assert_called_once_with() alarm._stop_beep.set() diff --git a/python_pkg/wake_alarm/tests/test_alarm_part4.py b/python_pkg/wake_alarm/tests/test_alarm_part4.py new file mode 100644 index 0000000..b091264 --- /dev/null +++ b/python_pkg/wake_alarm/tests/test_alarm_part4.py @@ -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()