diff --git a/wake_alarm/_alarm.py b/wake_alarm/_alarm.py index beb51b8..bfbf6b4 100644 --- a/wake_alarm/_alarm.py +++ b/wake_alarm/_alarm.py @@ -48,6 +48,31 @@ def _is_alarm_day() -> bool: return datetime.now(tz=timezone.utc).weekday() in ALARM_DAYS +def _wake_display() -> None: + """Force the display on and disable screensaver during alarm.""" + xset = shutil.which("xset") + if xset is None: + return + for cmd in ( + [xset, "dpms", "force", "on"], + [xset, "s", "off"], + ): + subprocess.run(cmd, check=False, capture_output=True, timeout=5) + + +def _restore_display() -> None: + """Re-enable screensaver after the alarm ends.""" + xset = shutil.which("xset") + if xset is None: + return + subprocess.run( + [xset, "s", "on"], + check=False, + capture_output=True, + timeout=5, + ) + + def _beep_soft() -> None: """Play a soft system beep via terminal bell.""" sys.stdout.write("\a") @@ -119,6 +144,7 @@ class WakeAlarm: 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.title("Wake Alarm" + (" [DEMO]" if demo_mode else "")) @@ -213,6 +239,7 @@ class WakeAlarm: def _dismiss_alarm(self, *, earned_skip: bool) -> None: """Dismiss the alarm and save state.""" + self._active = False self.dismissed = True self._stop_beep.set() now_iso = datetime.now(tz=timezone.utc).isoformat() @@ -241,11 +268,12 @@ class WakeAlarm: def _close(self) -> None: """Close the alarm window.""" self._stop_beep.set() + _restore_display() self.root.destroy() def _schedule_code_refresh(self) -> None: """Refresh the dismiss code periodically.""" - if self.dismissed: + if not self._active: return self._current_code = _generate_code() self._code_label.configure(text=self._current_code) @@ -260,8 +288,9 @@ class WakeAlarm: def _on_dismiss_window_expired(self) -> None: """Called when the dismiss window expires without valid dismissal.""" - if self.dismissed: + if not self._active: return + self._active = False self._stop_beep.set() save_wake_state(dismissed_at=None, skip_workout=False) _logger.info("Dismiss window expired — no workout skip.") @@ -281,6 +310,7 @@ class WakeAlarm: def _close_and_schedule_fallback(self) -> None: """Close the window and schedule the 1 PM fallback alarm.""" + _restore_display() self.root.destroy() def _update_timer(self) -> None: @@ -349,6 +379,7 @@ def main() -> None: return demo_mode = "--demo" in sys.argv + _wake_display() alarm = WakeAlarm(demo_mode=demo_mode) alarm.run() diff --git a/wake_alarm/install.sh b/wake_alarm/install.sh index 9fb8ce8..831484f 100755 --- a/wake_alarm/install.sh +++ b/wake_alarm/install.sh @@ -24,20 +24,29 @@ RTCWAKE_BIN="/usr/sbin/rtcwake" echo "=== Weekend Wake Alarm Installer ===" +# 0. Install system dependencies +echo "[0/5] Checking system dependencies..." +if ! command -v speaker-test &>/dev/null; then + echo " Installing alsa-utils (required for speaker-test)..." + sudo pacman -S --noconfirm alsa-utils +else + echo " alsa-utils already installed" +fi + # 1. Install systemd user service -echo "[1/4] Installing systemd user service..." +echo "[1/5] 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/4] Enabling wake-alarm.service..." +echo "[2/5] Enabling wake-alarm.service..." systemctl --user enable wake-alarm.service echo " Service enabled (will start on next boot)" # 3. Install systemd-sleep hook (restarts alarm after hibernate resume) -echo "[3/4] Installing systemd-sleep hook..." +echo "[3/5] Installing systemd-sleep hook..." sudo cp "$SLEEP_HOOK_SRC" "$SLEEP_HOOK_DST" sudo chmod 0755 "$SLEEP_HOOK_DST" echo " Installed to $SLEEP_HOOK_DST" @@ -61,7 +70,6 @@ sudo chmod 0755 "$SHUTDOWN_WRAPPER_DST" echo " Installed to $SHUTDOWN_WRAPPER_DST" echo " 'shutdown now' will now hibernate (not poweroff) on alarm nights." -echo "" echo "=== Installation complete ===" echo "The wake alarm will activate on boot for alarm days (Mon, Fri, Sat, Sun)." echo "After hibernate resume the sleep hook will restart the alarm service." diff --git a/wake_alarm/tests/test_alarm.py b/wake_alarm/tests/test_alarm.py index e8bc772..21c6375 100644 --- a/wake_alarm/tests/test_alarm.py +++ b/wake_alarm/tests/test_alarm.py @@ -12,20 +12,18 @@ 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, + _restore_display, _should_run_alarm, _speaker_test_path, - main, + _wake_display, ) from python_pkg.wake_alarm._constants import ( DISMISS_CODE_LENGTH, - PHASE_MEDIUM_END, - PHASE_SOFT_END, ) # --------------------------------------------------------------------------- @@ -348,372 +346,29 @@ class TestShouldRunAlarm: assert _should_run_alarm() is True -class TestWakeAlarmInit: - """Tests for WakeAlarm initialization.""" +class TestDisplayHelpers: + """Tests for _wake_display and _restore_display when xset is absent.""" - 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.""" + def test_wake_display_skips_when_xset_missing(self) -> None: + """_wake_display does nothing when xset is not on PATH.""" with ( patch( - "python_pkg.wake_alarm._alarm._should_run_alarm", - return_value=True, + "python_pkg.wake_alarm._alarm.shutil.which", + return_value=None, ), - patch( - "python_pkg.wake_alarm._alarm.sys", - ) as mock_sys, - patch.object(WakeAlarm, "run") as mock_run, - patch.object(WakeAlarm, "__init__", return_value=None), + patch("python_pkg.wake_alarm._alarm.subprocess.run") as mock_run, ): - 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() + _wake_display() + mock_run.assert_not_called() + def test_restore_display_skips_when_xset_missing(self) -> None: + """_restore_display does nothing when xset is not on PATH.""" with ( patch( - "python_pkg.wake_alarm._alarm._beep_medium", - side_effect=stop_after_one, - ) as mock_beep, + "python_pkg.wake_alarm._alarm.shutil.which", + return_value=None, + ), + patch("python_pkg.wake_alarm._alarm.subprocess.run") as mock_run, ): - 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() + _restore_display() + mock_run.assert_not_called() diff --git a/wake_alarm/tests/test_alarm_part2.py b/wake_alarm/tests/test_alarm_part2.py new file mode 100644 index 0000000..5e94c25 --- /dev/null +++ b/wake_alarm/tests/test_alarm_part2.py @@ -0,0 +1,432 @@ +"""Tests for _alarm.py — WakeAlarm init, dismiss, run, and beep phases (part 2).""" + +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, + main, +) +from python_pkg.wake_alarm._constants import ( + PHASE_MEDIUM_END, + PHASE_SOFT_END, +) + +# --------------------------------------------------------------------------- +# 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): + 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 + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + + +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_not_active( + self, + mock_tk_module: MagicMock, + ) -> None: + """Expiry is a no-op if alarm is no longer active.""" + alarm = WakeAlarm(demo_mode=True) + alarm._active = False + + 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_not_active( + self, + mock_tk_module: MagicMock, + ) -> None: + """Code refresh is a no-op when alarm is no longer active.""" + alarm = WakeAlarm(demo_mode=True) + alarm._active = False + old_code = alarm._current_code + alarm._schedule_code_refresh() + # Code doesn't change because _active=False 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()