mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-07-04 13:03:13 +02:00
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:
parent
12fe41049f
commit
e0ecd58c1d
@ -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"
|
||||
}
|
||||
@ -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"
|
||||
]
|
||||
}
|
||||
@ -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)
|
||||
@ -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
|
||||
@ -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:
|
||||
|
||||
@ -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__)
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
@ -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."""
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
|
||||
153
python_pkg/wake_alarm/tests/test_alarm_part4.py
Normal file
153
python_pkg/wake_alarm/tests/test_alarm_part4.py
Normal 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()
|
||||
Loading…
Reference in New Issue
Block a user