commit 3a51a10ca92503b1c087a3cc09d1d802ddba59c0 Author: Krzysztof kuhy Rudnicki Date: Sun Apr 12 20:45:24 2026 +0200 Add tests and fix pre-commit issues across all projects - C/lichess_random_engine, vocabulary_curve, misc/split, 1dvelocitysimulator, opening_learner: test suites added - CPP/miscelanious: tests added - TS/battery-status, champions_leauge_scores, two-inputs: tests added - python_pkg/fm24_searcher, wake_alarm: new packages added - Fix ruff/cppcheck/eslint/clang-format failures - Update .gitignore for C/C++ build artifacts diff --git a/wake_alarm/__init__.py b/wake_alarm/__init__.py new file mode 100644 index 0000000..324280d --- /dev/null +++ b/wake_alarm/__init__.py @@ -0,0 +1 @@ +"""Weekend wake alarm system with escalating beep and dismiss challenge.""" diff --git a/wake_alarm/_alarm.py b/wake_alarm/_alarm.py new file mode 100644 index 0000000..beb51b8 --- /dev/null +++ b/wake_alarm/_alarm.py @@ -0,0 +1,357 @@ +"""Weekend wake alarm daemon with escalating beep and dismiss challenge. + +Run as a systemd service on boot. Checks if today is an alarm day, +plays escalating system beeps, and presents a fullscreen dismiss +challenge (random code typing). Dismissing within the window grants a +workout-free day via HMAC-signed wake state. +""" + +from __future__ import annotations + +from datetime import datetime, timezone +import logging +import secrets +import shutil +import string +import subprocess +import sys +import threading +import time +import tkinter as tk + +from python_pkg.wake_alarm._constants import ( + ALARM_DAYS, + DISMISS_CODE_LENGTH, + DISMISS_CODE_REFRESH_SECONDS, + DISMISS_WINDOW_MINUTES, + LOUD_TOGGLE_INTERVAL, + MEDIUM_BEEP_INTERVAL, + PHASE_MEDIUM_END, + PHASE_SOFT_END, + SOFT_BEEP_INTERVAL, +) +from python_pkg.wake_alarm._state import ( + save_wake_state, + was_alarm_dismissed_today, +) + +_logger = logging.getLogger(__name__) + + +def _generate_code() -> str: + """Generate a random numeric dismiss code.""" + return "".join(secrets.choice(string.digits) for _ in range(DISMISS_CODE_LENGTH)) + + +def _is_alarm_day() -> bool: + """Check if today is an alarm day.""" + return datetime.now(tz=timezone.utc).weekday() in ALARM_DAYS + + +def _beep_soft() -> None: + """Play a soft system beep via terminal bell.""" + sys.stdout.write("\a") + sys.stdout.flush() + + +def _speaker_test_path() -> str: + """Resolve absolute path to speaker-test binary.""" + path = shutil.which("speaker-test") + if path is None: + msg = "speaker-test not found on PATH" + raise FileNotFoundError(msg) + return path + + +def _beep_medium(frequency: int = 1000) -> None: + """Play a medium beep via speaker-test (sine wave, short).""" + try: + subprocess.run( + [ + _speaker_test_path(), + "-t", + "sine", + "-f", + str(frequency), + "-l", + "1", + ], + capture_output=True, + timeout=3, + check=False, + ) + except (OSError, subprocess.TimeoutExpired): + _beep_soft() + + +def _beep_loud(frequency: int = 1000) -> None: + """Play a loud sine tone via speaker-test.""" + try: + subprocess.run( + [ + _speaker_test_path(), + "-t", + "sine", + "-f", + str(frequency), + "-l", + "1", + ], + capture_output=True, + timeout=3, + check=False, + ) + except (OSError, subprocess.TimeoutExpired): + _beep_soft() + + +class WakeAlarm: + """Fullscreen wake alarm with escalating beep and dismiss challenge.""" + + def __init__(self, *, demo_mode: bool = False) -> None: + """Initialize the wake alarm. + + Args: + demo_mode: If True, use a smaller window and shorter timers. + """ + 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.root = tk.Tk() + self.root.title("Wake Alarm" + (" [DEMO]" if demo_mode else "")) + self.root.configure(bg="#1a1a1a") + + if demo_mode: + self.root.geometry("800x600") + else: + screen_w = self.root.winfo_screenwidth() + screen_h = self.root.winfo_screenheight() + fullscreen = True + self.root.overrideredirect(boolean=fullscreen) + self.root.geometry(f"{screen_w}x{screen_h}+0+0") + self.root.attributes("-fullscreen", fullscreen) + self.root.attributes("-topmost", fullscreen) + + self._current_code = _generate_code() + self._build_ui() + self._schedule_code_refresh() + self._schedule_dismiss_window_close() + self._start_beep_thread() + + def _build_ui(self) -> None: + """Build the dismiss challenge UI.""" + self._container = tk.Frame(self.root, bg="#1a1a1a") + self._container.place(relx=0.5, rely=0.5, anchor="center") + + self._title_label = tk.Label( + self._container, + text="WAKE UP!", + font=("Arial", 48, "bold"), + fg="#ff4444", + bg="#1a1a1a", + ) + self._title_label.pack(pady=20) + + self._info_label = tk.Label( + self._container, + text="Type the code below to earn a workout-free day", + font=("Arial", 18), + fg="white", + bg="#1a1a1a", + ) + self._info_label.pack(pady=10) + + self._code_label = tk.Label( + self._container, + text=self._current_code, + font=("Courier", 72, "bold"), + fg="#00ff00", + bg="#1a1a1a", + ) + self._code_label.pack(pady=30) + + self._entry = tk.Entry( + self._container, + font=("Courier", 36), + justify="center", + width=DISMISS_CODE_LENGTH + 2, + ) + self._entry.pack(pady=10) + self._entry.focus_set() + self._entry.bind("", self._on_submit) + + self._status_label = tk.Label( + self._container, + text="", + font=("Arial", 18), + fg="#ff4444", + bg="#1a1a1a", + ) + self._status_label.pack(pady=10) + + self._timer_label = tk.Label( + self._container, + text="", + font=("Arial", 14), + fg="#aaaaaa", + bg="#1a1a1a", + ) + self._timer_label.pack(pady=5) + self._update_timer() + + def _on_submit(self, _event: object = None) -> None: + """Handle code submission.""" + entered = self._entry.get().strip() + if entered == self._current_code: + self._dismiss_alarm(earned_skip=True) + else: + self._status_label.configure(text="Wrong code! Try again.") + self._entry.delete(0, tk.END) + + def _dismiss_alarm(self, *, earned_skip: bool) -> None: + """Dismiss the alarm and save state.""" + self.dismissed = True + self._stop_beep.set() + now_iso = datetime.now(tz=timezone.utc).isoformat() + save_wake_state(dismissed_at=now_iso, skip_workout=earned_skip) + + for widget in self._container.winfo_children(): + widget.destroy() + + msg = ( + "Workout skip earned! Enjoy your morning." + if earned_skip + else "Alarm dismissed. No workout skip." + ) + color = "#00ff00" if earned_skip else "#ffaa00" + + tk.Label( + self._container, + text=msg, + font=("Arial", 36, "bold"), + fg=color, + bg="#1a1a1a", + ).pack(pady=30) + + self.root.after(3000, self._close) + + def _close(self) -> None: + """Close the alarm window.""" + self._stop_beep.set() + self.root.destroy() + + def _schedule_code_refresh(self) -> None: + """Refresh the dismiss code periodically.""" + if self.dismissed: + return + self._current_code = _generate_code() + self._code_label.configure(text=self._current_code) + self._entry.delete(0, tk.END) + ms = DISMISS_CODE_REFRESH_SECONDS * 1000 if not self.demo_mode else 10_000 + self.root.after(ms, self._schedule_code_refresh) + + def _schedule_dismiss_window_close(self) -> None: + """Close dismiss window after the allowed time.""" + ms = DISMISS_WINDOW_MINUTES * 60 * 1000 if not self.demo_mode else 30_000 + self.root.after(ms, self._on_dismiss_window_expired) + + def _on_dismiss_window_expired(self) -> None: + """Called when the dismiss window expires without valid dismissal.""" + if self.dismissed: + return + self._stop_beep.set() + save_wake_state(dismissed_at=None, skip_workout=False) + _logger.info("Dismiss window expired — no workout skip.") + + for widget in self._container.winfo_children(): + widget.destroy() + + tk.Label( + self._container, + text="Too late! No workout skip today.", + font=("Arial", 36, "bold"), + fg="#ff4444", + bg="#1a1a1a", + ).pack(pady=30) + + self.root.after(5000, self._close_and_schedule_fallback) + + def _close_and_schedule_fallback(self) -> None: + """Close the window and schedule the 1 PM fallback alarm.""" + self.root.destroy() + + def _update_timer(self) -> None: + """Update the remaining time display.""" + if self.dismissed: + return + elapsed = time.monotonic() - self._alarm_start + window = DISMISS_WINDOW_MINUTES * 60 if not self.demo_mode else 30 + remaining = max(0, window - elapsed) + minutes = int(remaining) // 60 + seconds = int(remaining) % 60 + self._timer_label.configure( + text=f"Time remaining: {minutes:02d}:{seconds:02d}", + ) + if remaining > 0: + self.root.after(1000, self._update_timer) + + 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() + + def _beep_loop(self) -> None: + """Escalating beep loop running in background thread.""" + while not self._stop_beep.is_set(): + elapsed_minutes = (time.monotonic() - self._alarm_start) / 60.0 + + if elapsed_minutes < PHASE_SOFT_END: + _beep_soft() + self._stop_beep.wait(SOFT_BEEP_INTERVAL) + elif elapsed_minutes < PHASE_MEDIUM_END: + _beep_medium() + self._stop_beep.wait(MEDIUM_BEEP_INTERVAL) + else: + freq = 800 if int(elapsed_minutes * 10) % 2 == 0 else 1200 + _beep_loud(freq) + self._stop_beep.wait(LOUD_TOGGLE_INTERVAL) + + def run(self) -> None: + """Start the alarm main loop.""" + self.root.mainloop() + + +def _should_run_alarm() -> bool: + """Determine if the alarm should run right now.""" + if not _is_alarm_day(): + _logger.info("Not an alarm day. Exiting.") + return False + if was_alarm_dismissed_today(): + _logger.info("Alarm already dismissed today. Exiting.") + return False + return True + + +def main() -> None: + """Entry point for the wake alarm daemon.""" + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(name)s %(levelname)s %(message)s", + ) + + if not _should_run_alarm(): + return + + demo_mode = "--demo" in sys.argv + alarm = WakeAlarm(demo_mode=demo_mode) + alarm.run() + + +if __name__ == "__main__": + main() diff --git a/wake_alarm/_constants.py b/wake_alarm/_constants.py new file mode 100644 index 0000000..7d53c9a --- /dev/null +++ b/wake_alarm/_constants.py @@ -0,0 +1,39 @@ +"""Constants for the weekend wake alarm system.""" + +from __future__ import annotations + +from pathlib import Path + +# Days the wake alarm is active (Python weekday(): Mon=0 ... Sun=6) +# Monday, Friday, Saturday, Sunday +ALARM_DAYS: frozenset[int] = frozenset({0, 4, 5, 6}) + +# How many hours after shutdown the PC should wake +WAKE_AFTER_HOURS: int = 8 + +# Minutes after alarm starts within which you must dismiss to earn skip +DISMISS_WINDOW_MINUTES: int = 30 + +# Hour at which the second (fallback) alarm fires if the first was missed +FALLBACK_ALARM_HOUR: int = 13 + +# Alarm escalation phase boundaries (minutes from alarm start) +PHASE_SOFT_END: int = 5 +PHASE_MEDIUM_END: int = 15 +# After PHASE_MEDIUM_END: continuous sine tone until dismiss window closes + +# Beep intervals per phase (seconds) +SOFT_BEEP_INTERVAL: float = 10.0 +MEDIUM_BEEP_INTERVAL: float = 5.0 +LOUD_TOGGLE_INTERVAL: float = 2.0 + +# Dismiss challenge: length of the random code +DISMISS_CODE_LENGTH: int = 6 +# How often the dismiss code refreshes (seconds) +DISMISS_CODE_REFRESH_SECONDS: int = 30 + +# State file for wake alarm (HMAC-signed) +WAKE_STATE_FILE: Path = Path(__file__).resolve().parent / "wake_state.json" + +# rtcwake binary path +RTCWAKE_BIN: str = "/usr/sbin/rtcwake" diff --git a/wake_alarm/_state.py b/wake_alarm/_state.py new file mode 100644 index 0000000..c1892cc --- /dev/null +++ b/wake_alarm/_state.py @@ -0,0 +1,105 @@ +"""HMAC-signed state management for the weekend wake alarm.""" + +from __future__ import annotations + +from datetime import datetime, timezone +import json +import logging + +from python_pkg.shared.log_integrity import ( + compute_entry_hmac, + verify_entry_hmac, +) +from python_pkg.wake_alarm._constants import WAKE_STATE_FILE + +_logger = logging.getLogger(__name__) + + +def _today_str() -> str: + """Return today's date as YYYY-MM-DD in UTC.""" + return datetime.now(tz=timezone.utc).strftime("%Y-%m-%d") + + +def save_wake_state( + *, + dismissed_at: str | None, + skip_workout: bool, +) -> bool: + """Write today's wake state with HMAC signature. + + Args: + dismissed_at: ISO time when alarm was dismissed, or None. + skip_workout: Whether the user earned a workout skip. + + Returns: + True if saved successfully, False otherwise. + """ + entry: dict[str, object] = { + "date": _today_str(), + "dismissed_at": dismissed_at, + "skip_workout": skip_workout, + } + signature = compute_entry_hmac(entry) + if signature is not None: + entry["hmac"] = signature + else: + _logger.warning("HMAC key unavailable — saving unsigned wake state") + + try: + with WAKE_STATE_FILE.open("w") as f: + json.dump(entry, f, indent=2) + except OSError as exc: + _logger.warning("Failed to save wake state: %s", exc) + return False + + _logger.info( + "Saved wake state: dismissed=%s skip=%s", + dismissed_at, + skip_workout, + ) + return True + + +def load_wake_state() -> dict[str, object] | None: + """Load and verify today's wake state. + + Returns the state dict if it exists, is valid (HMAC OK), and is + for today. Returns None otherwise. + """ + if not WAKE_STATE_FILE.exists(): + return None + + try: + with WAKE_STATE_FILE.open() as f: + state = json.load(f) + except (OSError, json.JSONDecodeError): + _logger.warning("Cannot read wake state file") + return None + + if not isinstance(state, dict): + return None + + if state.get("date") != _today_str(): + return None + + if not verify_entry_hmac(state): + _logger.warning("Wake state HMAC verification failed") + return None + + return state + + +def has_workout_skip_today() -> bool: + """Check if the user earned a workout skip for today.""" + state = load_wake_state() + if state is None: + return False + return bool(state.get("skip_workout")) + + +def was_alarm_dismissed_today() -> bool: + """Check if the alarm was already dismissed today.""" + state = load_wake_state() + if state is None: + return False + return state.get("dismissed_at") is not None diff --git a/wake_alarm/install.sh b/wake_alarm/install.sh new file mode 100755 index 0000000..1a5878f --- /dev/null +++ b/wake_alarm/install.sh @@ -0,0 +1,48 @@ +#!/bin/bash +# Install the weekend wake alarm systemd user service and sudoers entry. +# +# Usage: bash install.sh +# +# What it does: +# 1. Copies wake-alarm.service to ~/.config/systemd/user/ +# 2. Enables and starts the service +# 3. Adds a sudoers entry for passwordless rtcwake + +set -euo pipefail + +SCRIPT_DIR="$(dirname "$(readlink -f "$0")")" +SERVICE_FILE="$SCRIPT_DIR/wake-alarm.service" +SYSTEMD_USER_DIR="$HOME/.config/systemd/user" +SUDOERS_FILE="/etc/sudoers.d/wake-alarm" +RTCWAKE_BIN="/usr/sbin/rtcwake" + +echo "=== Weekend Wake Alarm Installer ===" + +# 1. Install systemd user service +echo "[1/3] Installing systemd user service..." +mkdir -p "$SYSTEMD_USER_DIR" +cp "$SERVICE_FILE" "$SYSTEMD_USER_DIR/wake-alarm.service" +systemctl --user daemon-reload +echo " Installed to $SYSTEMD_USER_DIR/wake-alarm.service" + +# 2. Enable service +echo "[2/3] Enabling wake-alarm.service..." +systemctl --user enable wake-alarm.service +echo " Service enabled (will start on next boot)" + +# 3. Add sudoers entry for rtcwake (requires root) +echo "[3/3] Setting up sudoers for rtcwake..." +SUDOERS_LINE="$USER ALL=(root) NOPASSWD: $RTCWAKE_BIN" +if [[ -f "$SUDOERS_FILE" ]] && grep -qF "$SUDOERS_LINE" "$SUDOERS_FILE"; then + echo " Sudoers entry already exists" +else + echo " Adding sudoers entry (requires sudo)..." + echo "$SUDOERS_LINE" | sudo tee "$SUDOERS_FILE" > /dev/null + sudo chmod 0440 "$SUDOERS_FILE" + echo " Added: $SUDOERS_LINE" +fi + +echo "" +echo "=== Installation complete ===" +echo "The wake alarm will activate on boot for alarm days (Mon, Fri, Sat, Sun)." +echo "To test now: python -m python_pkg.wake_alarm._alarm --demo" diff --git a/wake_alarm/tests/__init__.py b/wake_alarm/tests/__init__.py new file mode 100644 index 0000000..6004834 --- /dev/null +++ b/wake_alarm/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for the wake alarm package.""" diff --git a/wake_alarm/tests/test_alarm.py b/wake_alarm/tests/test_alarm.py new file mode 100644 index 0000000..e8bc772 --- /dev/null +++ b/wake_alarm/tests/test_alarm.py @@ -0,0 +1,719 @@ +"""Tests for _alarm.py — wake alarm daemon, UI, and beep logic.""" + +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, + _beep_loud, + _beep_medium, + _beep_soft, + _generate_code, + _is_alarm_day, + _should_run_alarm, + _speaker_test_path, + main, +) +from python_pkg.wake_alarm._constants import ( + DISMISS_CODE_LENGTH, + PHASE_MEDIUM_END, + PHASE_SOFT_END, +) + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +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): + 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): + yield mock + + +# --------------------------------------------------------------------------- +# Unit tests for pure functions +# --------------------------------------------------------------------------- + + +class TestGenerateCode: + """Tests for _generate_code.""" + + def test_correct_length(self) -> None: + """Generated code has the configured length.""" + code = _generate_code() + assert len(code) == DISMISS_CODE_LENGTH + + def test_all_digits(self) -> None: + """Generated code contains only digits.""" + code = _generate_code() + assert code.isdigit() + + def test_different_codes(self) -> None: + """Two calls produce different codes (probabilistic, but safe).""" + codes = {_generate_code() for _ in range(50)} + assert len(codes) > 1 + + +class TestIsAlarmDay: + """Tests for _is_alarm_day.""" + + def test_monday_is_alarm_day(self) -> None: + """Monday (weekday=0) is an alarm day.""" + from datetime import datetime + + # Create a date that is Monday + with patch( + "python_pkg.wake_alarm._alarm.datetime", + ) as mock_dt: + mock_now = MagicMock() + mock_now.weekday.return_value = 0 # Monday + mock_dt.now.return_value = mock_now + mock_dt.side_effect = datetime + assert _is_alarm_day() is True + + def test_tuesday_is_not_alarm_day(self) -> None: + """Tuesday (weekday=1) is NOT an alarm day.""" + with patch( + "python_pkg.wake_alarm._alarm.datetime", + ) as mock_dt: + mock_now = MagicMock() + mock_now.weekday.return_value = 1 # Tuesday + mock_dt.now.return_value = mock_now + assert _is_alarm_day() is False + + def test_friday_is_alarm_day(self) -> None: + """Friday (weekday=4) is an alarm day.""" + with patch( + "python_pkg.wake_alarm._alarm.datetime", + ) as mock_dt: + mock_now = MagicMock() + mock_now.weekday.return_value = 4 # Friday + mock_dt.now.return_value = mock_now + assert _is_alarm_day() is True + + def test_saturday_is_alarm_day(self) -> None: + """Saturday (weekday=5) is an alarm day.""" + with patch( + "python_pkg.wake_alarm._alarm.datetime", + ) as mock_dt: + mock_now = MagicMock() + mock_now.weekday.return_value = 5 + mock_dt.now.return_value = mock_now + assert _is_alarm_day() is True + + def test_sunday_is_alarm_day(self) -> None: + """Sunday (weekday=6) is an alarm day.""" + with patch( + "python_pkg.wake_alarm._alarm.datetime", + ) as mock_dt: + mock_now = MagicMock() + mock_now.weekday.return_value = 6 + mock_dt.now.return_value = mock_now + assert _is_alarm_day() is True + + def test_wednesday_is_not_alarm_day(self) -> None: + """Wednesday (weekday=2) is NOT an alarm day.""" + with patch( + "python_pkg.wake_alarm._alarm.datetime", + ) as mock_dt: + mock_now = MagicMock() + mock_now.weekday.return_value = 2 + mock_dt.now.return_value = mock_now + assert _is_alarm_day() is False + + +class TestSpeakerTestPath: + """Tests for _speaker_test_path.""" + + def test_returns_path_when_found(self) -> None: + """Return full path when speaker-test is available.""" + with patch( + "python_pkg.wake_alarm._alarm.shutil.which", + return_value="/usr/bin/speaker-test", + ): + assert _speaker_test_path() == "/usr/bin/speaker-test" + + def test_raises_when_not_found(self) -> None: + """Raise FileNotFoundError when speaker-test is missing.""" + with ( + patch( + "python_pkg.wake_alarm._alarm.shutil.which", + return_value=None, + ), + pytest.raises(FileNotFoundError, match="speaker-test not found"), + ): + _speaker_test_path() + + +class TestBeepFunctions: + """Tests for beep helper functions.""" + + def test_beep_soft_writes_bell(self) -> None: + """_beep_soft writes terminal bell character.""" + with patch("python_pkg.wake_alarm._alarm.sys") as mock_sys: + mock_sys.stdout = MagicMock() + _beep_soft() + mock_sys.stdout.write.assert_called_once_with("\a") + mock_sys.stdout.flush.assert_called_once() + + def test_beep_medium_calls_speaker_test(self) -> None: + """_beep_medium runs speaker-test subprocess.""" + with ( + patch( + "python_pkg.wake_alarm._alarm._speaker_test_path", + return_value="/usr/bin/speaker-test", + ), + patch( + "python_pkg.wake_alarm._alarm.subprocess.run", + ) as mock_run, + ): + _beep_medium(frequency=800) + mock_run.assert_called_once() + args = mock_run.call_args[0][0] + assert "/usr/bin/speaker-test" in args + assert "800" in args + + def test_beep_medium_falls_back_on_error(self) -> None: + """_beep_medium falls back to soft beep on OSError.""" + with ( + patch( + "python_pkg.wake_alarm._alarm._speaker_test_path", + return_value="/usr/bin/speaker-test", + ), + patch( + "python_pkg.wake_alarm._alarm.subprocess.run", + side_effect=OSError("no speaker-test"), + ), + patch( + "python_pkg.wake_alarm._alarm._beep_soft", + ) as mock_soft, + ): + _beep_medium() + mock_soft.assert_called_once() + + def test_beep_medium_falls_back_on_timeout(self) -> None: + """_beep_medium falls back on TimeoutExpired.""" + from subprocess import TimeoutExpired + + with ( + patch( + "python_pkg.wake_alarm._alarm._speaker_test_path", + return_value="/usr/bin/speaker-test", + ), + patch( + "python_pkg.wake_alarm._alarm.subprocess.run", + side_effect=TimeoutExpired("cmd", 3), + ), + patch( + "python_pkg.wake_alarm._alarm._beep_soft", + ) as mock_soft, + ): + _beep_medium() + mock_soft.assert_called_once() + + def test_beep_medium_falls_back_on_missing_binary(self) -> None: + """_beep_medium falls back when speaker-test binary not found.""" + with ( + patch( + "python_pkg.wake_alarm._alarm._speaker_test_path", + side_effect=FileNotFoundError("not found"), + ), + patch( + "python_pkg.wake_alarm._alarm._beep_soft", + ) as mock_soft, + ): + _beep_medium() + mock_soft.assert_called_once() + + def test_beep_loud_calls_speaker_test(self) -> None: + """_beep_loud runs speaker-test subprocess.""" + with ( + patch( + "python_pkg.wake_alarm._alarm._speaker_test_path", + return_value="/usr/bin/speaker-test", + ), + patch( + "python_pkg.wake_alarm._alarm.subprocess.run", + ) as mock_run, + ): + _beep_loud(frequency=1200) + mock_run.assert_called_once() + args = mock_run.call_args[0][0] + assert "1200" in args + + def test_beep_loud_falls_back_on_error(self) -> None: + """_beep_loud falls back to soft beep on OSError.""" + with ( + patch( + "python_pkg.wake_alarm._alarm._speaker_test_path", + return_value="/usr/bin/speaker-test", + ), + patch( + "python_pkg.wake_alarm._alarm.subprocess.run", + side_effect=OSError("fail"), + ), + patch( + "python_pkg.wake_alarm._alarm._beep_soft", + ) as mock_soft, + ): + _beep_loud() + mock_soft.assert_called_once() + + def test_beep_loud_falls_back_on_missing_binary(self) -> None: + """_beep_loud falls back when speaker-test binary not found.""" + with ( + patch( + "python_pkg.wake_alarm._alarm._speaker_test_path", + side_effect=FileNotFoundError("not found"), + ), + patch( + "python_pkg.wake_alarm._alarm._beep_soft", + ) as mock_soft, + ): + _beep_loud() + mock_soft.assert_called_once() + + +class TestShouldRunAlarm: + """Tests for _should_run_alarm.""" + + def test_returns_false_on_non_alarm_day(self) -> None: + """Return False when today is not an alarm day.""" + with patch( + "python_pkg.wake_alarm._alarm._is_alarm_day", + return_value=False, + ): + assert _should_run_alarm() is False + + def test_returns_false_when_already_dismissed(self) -> None: + """Return False when alarm was already dismissed today.""" + with ( + patch( + "python_pkg.wake_alarm._alarm._is_alarm_day", + return_value=True, + ), + patch( + "python_pkg.wake_alarm._alarm.was_alarm_dismissed_today", + return_value=True, + ), + ): + assert _should_run_alarm() is False + + def test_returns_true_when_alarm_day_and_not_dismissed(self) -> None: + """Return True when today is alarm day and not yet dismissed.""" + with ( + patch( + "python_pkg.wake_alarm._alarm._is_alarm_day", + return_value=True, + ), + patch( + "python_pkg.wake_alarm._alarm.was_alarm_dismissed_today", + return_value=False, + ), + ): + assert _should_run_alarm() is True + + +class TestWakeAlarmInit: + """Tests for WakeAlarm initialization.""" + + def test_demo_mode_sets_smaller_window( + self, + mock_tk_module: MagicMock, + ) -> None: + """Demo mode creates a smaller window.""" + alarm = WakeAlarm(demo_mode=True) + assert alarm.demo_mode is True + assert alarm.dismissed is False + alarm._stop_beep.set() # Stop beep thread + + def test_production_mode_fullscreen( + self, + mock_tk_module: MagicMock, + ) -> None: + """Production mode activates fullscreen.""" + alarm = WakeAlarm(demo_mode=False) + assert alarm.demo_mode is False + mock_root = mock_tk_module.Tk.return_value + mock_root.overrideredirect.assert_called_once() + alarm._stop_beep.set() + + +class TestWakeAlarmDismiss: + """Tests for alarm dismiss logic.""" + + def test_correct_code_dismisses( + self, + mock_tk_module: MagicMock, + ) -> None: + """Entering the correct code dismisses the alarm.""" + alarm = WakeAlarm(demo_mode=True) + code = alarm._current_code + mock_entry = mock_tk_module.Entry.return_value + mock_entry.get.return_value = code + + with patch( + "python_pkg.wake_alarm._alarm.save_wake_state", + ) as mock_save: + alarm._on_submit() + + assert alarm.dismissed is True + mock_save.assert_called_once() + call_kwargs = mock_save.call_args[1] + assert call_kwargs["skip_workout"] is True + alarm._stop_beep.set() + + def test_wrong_code_does_not_dismiss( + self, + mock_tk_module: MagicMock, + ) -> None: + """Entering the wrong code shows error without dismissing.""" + alarm = WakeAlarm(demo_mode=True) + mock_entry = mock_tk_module.Entry.return_value + mock_entry.get.return_value = "000000" + # Ensure current code is different + alarm._current_code = "123456" + + alarm._on_submit() + + assert alarm.dismissed is False + alarm._stop_beep.set() + + def test_dismiss_window_expired( + self, + mock_tk_module: MagicMock, + ) -> None: + """Window expiry saves state with no skip.""" + alarm = WakeAlarm(demo_mode=True) + + with patch( + "python_pkg.wake_alarm._alarm.save_wake_state", + ) as mock_save: + alarm._on_dismiss_window_expired() + + assert alarm.dismissed is False + mock_save.assert_called_once_with( + dismissed_at=None, + skip_workout=False, + ) + alarm._stop_beep.set() + + def test_dismiss_window_expired_noop_if_already_dismissed( + self, + mock_tk_module: MagicMock, + ) -> None: + """Expiry is a no-op if already dismissed.""" + alarm = WakeAlarm(demo_mode=True) + alarm.dismissed = True + + with patch( + "python_pkg.wake_alarm._alarm.save_wake_state", + ) as mock_save: + alarm._on_dismiss_window_expired() + + mock_save.assert_not_called() + alarm._stop_beep.set() + + +class TestMain: + """Tests for the main() entry point.""" + + def test_exits_when_not_alarm_day(self) -> None: + """main() returns early when not an alarm day.""" + with patch( + "python_pkg.wake_alarm._alarm._should_run_alarm", + return_value=False, + ): + main() # Should just return without error + + def test_creates_alarm_when_should_run( + self, + mock_tk_module: MagicMock, + ) -> None: + """main() creates a WakeAlarm when conditions are met.""" + with ( + patch( + "python_pkg.wake_alarm._alarm._should_run_alarm", + return_value=True, + ), + patch( + "python_pkg.wake_alarm._alarm.sys", + ) as mock_sys, + patch.object(WakeAlarm, "run") as mock_run, + patch.object(WakeAlarm, "__init__", return_value=None), + ): + mock_sys.argv = [] + main() + mock_run.assert_called_once() + + +class TestCodeRefreshAndTimer: + """Tests for code refresh and timer update methods.""" + + def test_code_refresh_changes_code( + self, + mock_tk_module: MagicMock, + ) -> None: + """Code refresh generates a new code.""" + alarm = WakeAlarm(demo_mode=True) + # Call refresh many times — at least one should differ + codes = set() + for _ in range(50): + alarm._schedule_code_refresh() + codes.add(alarm._current_code) + assert len(codes) > 1 + alarm._stop_beep.set() + + def test_code_refresh_noop_when_dismissed( + self, + mock_tk_module: MagicMock, + ) -> None: + """Code refresh is a no-op after dismissal.""" + alarm = WakeAlarm(demo_mode=True) + alarm.dismissed = True + old_code = alarm._current_code + alarm._schedule_code_refresh() + # Code doesn't change because dismissed=True causes early return + assert alarm._current_code == old_code + alarm._stop_beep.set() + + def test_update_timer_noop_when_dismissed( + self, + mock_tk_module: MagicMock, + ) -> None: + """Timer update is a no-op after dismissal.""" + alarm = WakeAlarm(demo_mode=True) + alarm.dismissed = True + alarm._update_timer() # Should not raise + alarm._stop_beep.set() + + +class TestBeepLoop: + """Tests for the beep loop thread.""" + + def test_beep_loop_stops_on_event( + self, + mock_tk_module: MagicMock, + ) -> None: + """Beep loop exits when stop event is set.""" + alarm = WakeAlarm(demo_mode=True) + alarm._stop_beep.set() + # Loop should exit immediately + with patch( + "python_pkg.wake_alarm._alarm._beep_soft", + ): + alarm._beep_loop() + alarm._stop_beep.set() + + +class TestCloseAndFallback: + """Tests for close and fallback scheduling.""" + + def test_close_stops_beep_and_destroys( + self, + mock_tk_module: MagicMock, + ) -> None: + """_close sets stop event and destroys root.""" + alarm = WakeAlarm(demo_mode=True) + alarm._close() + assert alarm._stop_beep.is_set() + alarm.root.destroy.assert_called() + + def test_close_and_schedule_fallback( + self, + mock_tk_module: MagicMock, + ) -> None: + """_close_and_schedule_fallback destroys root.""" + alarm = WakeAlarm(demo_mode=True) + alarm._close_and_schedule_fallback() + alarm.root.destroy.assert_called() + alarm._stop_beep.set() + + +class TestDismissWithoutSkip: + """Tests for alarm dismiss without earning skip.""" + + def test_dismiss_without_skip_shows_no_skip_message( + self, + mock_tk_module: MagicMock, + ) -> None: + """Dismissing with earned_skip=False shows appropriate message.""" + alarm = WakeAlarm(demo_mode=True) + # Simulate existing child widgets + mock_widget = MagicMock() + alarm._container.winfo_children.return_value = [mock_widget] + + with patch( + "python_pkg.wake_alarm._alarm.save_wake_state", + ) as mock_save: + alarm._dismiss_alarm(earned_skip=False) + + assert alarm.dismissed is True + mock_save.assert_called_once() + call_kwargs = mock_save.call_args[1] + assert call_kwargs["skip_workout"] is False + mock_widget.destroy.assert_called_once() + alarm._stop_beep.set() + + +class TestDismissWindowExpiredWidgets: + """Tests for widget cleanup during dismiss window expiry.""" + + def test_expired_creates_label( + self, + mock_tk_module: MagicMock, + ) -> None: + """Expiry creates a 'Too late' label and destroys children.""" + alarm = WakeAlarm(demo_mode=True) + mock_widget = MagicMock() + alarm._container.winfo_children.return_value = [mock_widget] + + with patch( + "python_pkg.wake_alarm._alarm.save_wake_state", + ): + alarm._on_dismiss_window_expired() + + mock_widget.destroy.assert_called_once() + mock_tk_module.Label.assert_called() + alarm._stop_beep.set() + + +class TestBeepLoopPhases: + """Tests for different beep loop escalation phases.""" + + def test_medium_phase( + self, + mock_tk_module: MagicMock, + ) -> None: + """Beep loop enters medium phase after PHASE_SOFT_END minutes.""" + alarm = WakeAlarm(demo_mode=True) + # Set alarm start to make elapsed > PHASE_SOFT_END minutes + import time as time_mod + + alarm._alarm_start = time_mod.monotonic() - (PHASE_SOFT_END + 1) * 60 + + call_count = 0 + + def stop_after_one(*_args: object, **_kwargs: object) -> None: + nonlocal call_count + call_count += 1 + if call_count >= 1: + alarm._stop_beep.set() + + with ( + patch( + "python_pkg.wake_alarm._alarm._beep_medium", + side_effect=stop_after_one, + ) as mock_beep, + ): + alarm._beep_loop() + + mock_beep.assert_called() + alarm._stop_beep.set() + + def test_loud_phase( + self, + mock_tk_module: MagicMock, + ) -> None: + """Beep loop enters loud phase after PHASE_MEDIUM_END minutes.""" + alarm = WakeAlarm(demo_mode=True) + import time as time_mod + + alarm._alarm_start = time_mod.monotonic() - (PHASE_MEDIUM_END + 1) * 60 + + call_count = 0 + + def stop_after_one(*_args: object, **_kwargs: object) -> None: + nonlocal call_count + call_count += 1 + if call_count >= 1: + alarm._stop_beep.set() + + with ( + patch( + "python_pkg.wake_alarm._alarm._beep_loud", + side_effect=stop_after_one, + ) as mock_beep, + ): + alarm._beep_loop() + + mock_beep.assert_called() + alarm._stop_beep.set() + + +class TestRunMethod: + """Tests for the run() method.""" + + def test_run_calls_mainloop( + self, + mock_tk_module: MagicMock, + ) -> None: + """run() calls root.mainloop().""" + alarm = WakeAlarm(demo_mode=True) + alarm.run() + alarm.root.mainloop.assert_called_once() + alarm._stop_beep.set() + + +class TestUpdateTimerActive: + """Tests for timer update when alarm is active.""" + + def test_update_timer_shows_remaining( + self, + mock_tk_module: MagicMock, + ) -> None: + """Timer update shows remaining time when not dismissed.""" + alarm = WakeAlarm(demo_mode=True) + alarm._update_timer() + alarm._timer_label.configure.assert_called() + alarm._stop_beep.set() + + def test_update_timer_stops_at_zero( + self, + mock_tk_module: MagicMock, + ) -> None: + """Timer stops scheduling when remaining time reaches zero.""" + import time as time_mod + + alarm = WakeAlarm(demo_mode=True) + # Set alarm start far in the past so remaining = 0 + alarm._alarm_start = time_mod.monotonic() - 60 * 60 + alarm._update_timer() + # root.after should NOT be called for re-scheduling + # (configure is still called to show 00:00) + alarm._timer_label.configure.assert_called() + alarm._stop_beep.set() diff --git a/wake_alarm/tests/test_state.py b/wake_alarm/tests/test_state.py new file mode 100644 index 0000000..56f32f9 --- /dev/null +++ b/wake_alarm/tests/test_state.py @@ -0,0 +1,261 @@ +"""Tests for _state.py — HMAC-signed wake state management.""" + +from __future__ import annotations + +import json +from typing import TYPE_CHECKING +from unittest.mock import patch + +import pytest + +from python_pkg.wake_alarm._state import ( + _today_str, + has_workout_skip_today, + load_wake_state, + save_wake_state, + was_alarm_dismissed_today, +) + +if TYPE_CHECKING: + from pathlib import Path + + +@pytest.fixture +def wake_state_file(tmp_path: Path) -> Path: + """Provide a temporary wake state file path.""" + return tmp_path / "wake_state.json" + + +@pytest.fixture(autouse=True) +def _patch_wake_state_file(wake_state_file: Path) -> None: + """Redirect WAKE_STATE_FILE to tmp_path for all tests.""" + with patch( + "python_pkg.wake_alarm._state.WAKE_STATE_FILE", + wake_state_file, + ): + yield + + +class TestTodayStr: + """Tests for _today_str helper.""" + + def test_returns_date_string(self) -> None: + """Return a YYYY-MM-DD string for today.""" + result = _today_str() + assert len(result) == 10 + assert result[4] == "-" + assert result[7] == "-" + + +class TestSaveWakeState: + """Tests for save_wake_state.""" + + def test_saves_with_hmac(self, wake_state_file: Path) -> None: + """Save state with HMAC signature when key is available.""" + with patch( + "python_pkg.wake_alarm._state.compute_entry_hmac", + return_value="fakesig", + ): + result = save_wake_state( + dismissed_at="2026-04-12T07:04:00+00:00", + skip_workout=True, + ) + + assert result is True + data = json.loads(wake_state_file.read_text()) + assert data["skip_workout"] is True + assert data["dismissed_at"] == "2026-04-12T07:04:00+00:00" + assert data["hmac"] == "fakesig" + assert data["date"] == _today_str() + + def test_saves_without_hmac(self, wake_state_file: Path) -> None: + """Save unsigned state when HMAC key is unavailable.""" + with patch( + "python_pkg.wake_alarm._state.compute_entry_hmac", + return_value=None, + ): + result = save_wake_state( + dismissed_at=None, + skip_workout=False, + ) + + assert result is True + data = json.loads(wake_state_file.read_text()) + assert data["skip_workout"] is False + assert "hmac" not in data + + def test_returns_false_on_write_error(self, wake_state_file: Path) -> None: + """Return False when file cannot be written.""" + with ( + patch( + "python_pkg.wake_alarm._state.compute_entry_hmac", + return_value="sig", + ), + patch( + "python_pkg.wake_alarm._state.WAKE_STATE_FILE", + wake_state_file / "nonexistent_dir" / "file.json", + ), + ): + result = save_wake_state(dismissed_at=None, skip_workout=False) + + assert result is False + + +class TestLoadWakeState: + """Tests for load_wake_state.""" + + def test_returns_none_when_file_missing(self) -> None: + """Return None when state file doesn't exist.""" + assert load_wake_state() is None + + def test_returns_none_for_wrong_date( + self, + wake_state_file: Path, + ) -> None: + """Return None when state is from a different day.""" + state = {"date": "1999-01-01", "skip_workout": True, "hmac": "x"} + wake_state_file.write_text(json.dumps(state)) + assert load_wake_state() is None + + def test_returns_none_for_invalid_json( + self, + wake_state_file: Path, + ) -> None: + """Return None when file contains invalid JSON.""" + wake_state_file.write_text("not json {{{") + assert load_wake_state() is None + + def test_returns_none_for_non_dict( + self, + wake_state_file: Path, + ) -> None: + """Return None when file contains a non-dict JSON value.""" + wake_state_file.write_text(json.dumps([1, 2, 3])) + assert load_wake_state() is None + + def test_returns_none_for_bad_hmac( + self, + wake_state_file: Path, + ) -> None: + """Return None when HMAC verification fails.""" + state = { + "date": _today_str(), + "skip_workout": True, + "dismissed_at": "07:00", + "hmac": "badsig", + } + wake_state_file.write_text(json.dumps(state)) + with patch( + "python_pkg.wake_alarm._state.verify_entry_hmac", + return_value=False, + ): + assert load_wake_state() is None + + def test_returns_state_for_valid_today( + self, + wake_state_file: Path, + ) -> None: + """Return state dict when file is valid and for today.""" + state = { + "date": _today_str(), + "skip_workout": True, + "dismissed_at": "07:04", + "hmac": "validsig", + } + wake_state_file.write_text(json.dumps(state)) + with patch( + "python_pkg.wake_alarm._state.verify_entry_hmac", + return_value=True, + ): + result = load_wake_state() + + assert result is not None + assert result["skip_workout"] is True + + +class TestHasWorkoutSkipToday: + """Tests for has_workout_skip_today.""" + + def test_returns_false_when_no_state(self) -> None: + """Return False when no state file exists.""" + assert has_workout_skip_today() is False + + def test_returns_true_when_skip_granted( + self, + wake_state_file: Path, + ) -> None: + """Return True when today's state has skip_workout=True.""" + state = { + "date": _today_str(), + "skip_workout": True, + "dismissed_at": "07:04", + "hmac": "sig", + } + wake_state_file.write_text(json.dumps(state)) + with patch( + "python_pkg.wake_alarm._state.verify_entry_hmac", + return_value=True, + ): + assert has_workout_skip_today() is True + + def test_returns_false_when_skip_not_granted( + self, + wake_state_file: Path, + ) -> None: + """Return False when today's state has skip_workout=False.""" + state = { + "date": _today_str(), + "skip_workout": False, + "dismissed_at": None, + "hmac": "sig", + } + wake_state_file.write_text(json.dumps(state)) + with patch( + "python_pkg.wake_alarm._state.verify_entry_hmac", + return_value=True, + ): + assert has_workout_skip_today() is False + + +class TestWasAlarmDismissedToday: + """Tests for was_alarm_dismissed_today.""" + + def test_returns_false_when_no_state(self) -> None: + """Return False when no state file exists.""" + assert was_alarm_dismissed_today() is False + + def test_returns_true_when_dismissed( + self, + wake_state_file: Path, + ) -> None: + """Return True when alarm was dismissed today.""" + state = { + "date": _today_str(), + "dismissed_at": "07:04", + "skip_workout": True, + "hmac": "sig", + } + wake_state_file.write_text(json.dumps(state)) + with patch( + "python_pkg.wake_alarm._state.verify_entry_hmac", + return_value=True, + ): + assert was_alarm_dismissed_today() is True + + def test_returns_false_when_not_dismissed( + self, + wake_state_file: Path, + ) -> None: + """Return False when alarm was not dismissed.""" + state = { + "date": _today_str(), + "dismissed_at": None, + "skip_workout": False, + "hmac": "sig", + } + wake_state_file.write_text(json.dumps(state)) + with patch( + "python_pkg.wake_alarm._state.verify_entry_hmac", + return_value=True, + ): + assert was_alarm_dismissed_today() is False diff --git a/wake_alarm/wake-alarm.service b/wake_alarm/wake-alarm.service new file mode 100644 index 0000000..0330656 --- /dev/null +++ b/wake_alarm/wake-alarm.service @@ -0,0 +1,12 @@ +[Unit] +Description=Weekend Wake Alarm +After=graphical-session.target + +[Service] +Type=simple +ExecStart=/usr/bin/python -m python_pkg.wake_alarm._alarm --production +WorkingDirectory=%h/testsAndMisc +Restart=no + +[Install] +WantedBy=graphical-session.target