mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-07-04 15:43:02 +02:00
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
This commit is contained in:
parent
9c409a32cd
commit
f424be8fe5
@ -1,78 +1,19 @@
|
|||||||
"""HMAC-based integrity checking for workout log entries."""
|
"""HMAC-based integrity checking — re-exports from shared package."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import hashlib
|
from python_pkg.shared.log_integrity import (
|
||||||
import hmac
|
HMAC_KEY_FILE,
|
||||||
import json
|
_generate_hmac_key,
|
||||||
import logging
|
_load_hmac_key,
|
||||||
import secrets
|
compute_entry_hmac,
|
||||||
|
verify_entry_hmac,
|
||||||
|
)
|
||||||
|
|
||||||
from python_pkg.screen_locker._constants import HMAC_KEY_FILE
|
__all__ = [
|
||||||
|
"HMAC_KEY_FILE",
|
||||||
_logger = logging.getLogger(__name__)
|
"_generate_hmac_key",
|
||||||
|
"_load_hmac_key",
|
||||||
|
"compute_entry_hmac",
|
||||||
def _load_hmac_key() -> bytes | None:
|
"verify_entry_hmac",
|
||||||
"""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 workout log entry.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
entry_data: The log 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 workout log entry.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
entry: The full log 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)
|
|
||||||
|
|||||||
@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import datetime, timezone
|
import calendar
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import subprocess
|
import subprocess
|
||||||
@ -12,6 +13,11 @@ from python_pkg.screen_locker._constants import (
|
|||||||
SHUTDOWN_CONFIG_FILE,
|
SHUTDOWN_CONFIG_FILE,
|
||||||
SICK_DAY_STATE_FILE,
|
SICK_DAY_STATE_FILE,
|
||||||
)
|
)
|
||||||
|
from python_pkg.wake_alarm._constants import (
|
||||||
|
ALARM_DAYS,
|
||||||
|
RTCWAKE_BIN,
|
||||||
|
WAKE_AFTER_HOURS,
|
||||||
|
)
|
||||||
|
|
||||||
_logger = logging.getLogger(__name__)
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -260,3 +266,73 @@ class ShutdownMixin:
|
|||||||
result.stdout.strip(),
|
result.stdout.strip(),
|
||||||
)
|
)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# rtcwake integration for weekend wake alarm
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _is_tomorrow_alarm_day() -> bool:
|
||||||
|
"""Check if tomorrow is an alarm day."""
|
||||||
|
tomorrow = datetime.now(tz=timezone.utc) + timedelta(days=1)
|
||||||
|
return tomorrow.weekday() in ALARM_DAYS
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _compute_wake_timestamp() -> int:
|
||||||
|
"""Compute the UTC epoch timestamp for the next wake alarm.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Epoch seconds WAKE_AFTER_HOURS from now.
|
||||||
|
"""
|
||||||
|
wake_time = datetime.now(tz=timezone.utc) + timedelta(
|
||||||
|
hours=WAKE_AFTER_HOURS,
|
||||||
|
)
|
||||||
|
return calendar.timegm(wake_time.utctimetuple())
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _schedule_rtcwake() -> bool:
|
||||||
|
"""Set rtcwake to power on the PC after WAKE_AFTER_HOURS.
|
||||||
|
|
||||||
|
Uses ``rtcwake -m no`` so the system can shut down normally while
|
||||||
|
the RTC alarm remains set.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if rtcwake was set successfully, False otherwise.
|
||||||
|
"""
|
||||||
|
wake_epoch = ShutdownMixin._compute_wake_timestamp()
|
||||||
|
cmd = [
|
||||||
|
"/usr/bin/sudo",
|
||||||
|
RTCWAKE_BIN,
|
||||||
|
"-m",
|
||||||
|
"no",
|
||||||
|
"-t",
|
||||||
|
str(wake_epoch),
|
||||||
|
]
|
||||||
|
try:
|
||||||
|
subprocess.run(
|
||||||
|
cmd,
|
||||||
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
except subprocess.SubprocessError as exc:
|
||||||
|
_logger.warning("Failed to set rtcwake: %s", exc)
|
||||||
|
return False
|
||||||
|
_logger.info(
|
||||||
|
"rtcwake set: PC will wake at epoch %d",
|
||||||
|
wake_epoch,
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def schedule_wake_if_needed(self) -> bool:
|
||||||
|
"""Schedule rtcwake if tomorrow is an alarm day.
|
||||||
|
|
||||||
|
Call this at shutdown time.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if wake was scheduled, False if not needed or failed.
|
||||||
|
"""
|
||||||
|
if not self._is_tomorrow_alarm_day():
|
||||||
|
_logger.info("Tomorrow is not an alarm day — skipping rtcwake")
|
||||||
|
return False
|
||||||
|
return self._schedule_rtcwake()
|
||||||
|
|||||||
@ -31,6 +31,7 @@ from python_pkg.screen_locker._log_integrity import (
|
|||||||
from python_pkg.screen_locker._phone_verification import PhoneVerificationMixin
|
from python_pkg.screen_locker._phone_verification import PhoneVerificationMixin
|
||||||
from python_pkg.screen_locker._shutdown import ShutdownMixin
|
from python_pkg.screen_locker._shutdown import ShutdownMixin
|
||||||
from python_pkg.screen_locker._ui_flows import UIFlowsMixin
|
from python_pkg.screen_locker._ui_flows import UIFlowsMixin
|
||||||
|
from python_pkg.wake_alarm._state import has_workout_skip_today
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
@ -92,6 +93,9 @@ class ScreenLocker(
|
|||||||
elif self.has_logged_today():
|
elif self.has_logged_today():
|
||||||
_logger.info("Workout already logged today. Skipping screen lock.")
|
_logger.info("Workout already logged today. Skipping screen lock.")
|
||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
|
elif has_workout_skip_today():
|
||||||
|
_logger.info("Wake alarm earned workout skip. Skipping screen lock.")
|
||||||
|
sys.exit(0)
|
||||||
self.root = tk.Tk()
|
self.root = tk.Tk()
|
||||||
title_suffix = (
|
title_suffix = (
|
||||||
" [VERIFY]" if verify_only else (" [DEMO MODE]" if demo_mode else "")
|
" [VERIFY]" if verify_only else (" [DEMO MODE]" if demo_mode else "")
|
||||||
|
|||||||
@ -15,6 +15,8 @@ from python_pkg.screen_locker._log_integrity import (
|
|||||||
verify_entry_hmac,
|
verify_entry_hmac,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
_HMAC_KEY_FILE_PATH = "python_pkg.shared.log_integrity.HMAC_KEY_FILE"
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@ -27,7 +29,7 @@ class TestLoadHmacKey:
|
|||||||
key_file = tmp_path / "hmac.key"
|
key_file = tmp_path / "hmac.key"
|
||||||
key_file.write_bytes(b"secret_key_bytes")
|
key_file.write_bytes(b"secret_key_bytes")
|
||||||
with patch(
|
with patch(
|
||||||
"python_pkg.screen_locker._log_integrity.HMAC_KEY_FILE",
|
_HMAC_KEY_FILE_PATH,
|
||||||
key_file,
|
key_file,
|
||||||
):
|
):
|
||||||
result = _load_hmac_key()
|
result = _load_hmac_key()
|
||||||
@ -37,7 +39,7 @@ class TestLoadHmacKey:
|
|||||||
"""Test returns None when key file doesn't exist."""
|
"""Test returns None when key file doesn't exist."""
|
||||||
key_file = tmp_path / "nonexistent.key"
|
key_file = tmp_path / "nonexistent.key"
|
||||||
with patch(
|
with patch(
|
||||||
"python_pkg.screen_locker._log_integrity.HMAC_KEY_FILE",
|
_HMAC_KEY_FILE_PATH,
|
||||||
key_file,
|
key_file,
|
||||||
):
|
):
|
||||||
result = _load_hmac_key()
|
result = _load_hmac_key()
|
||||||
@ -51,7 +53,7 @@ class TestGenerateHmacKey:
|
|||||||
"""Test key generation creates file with 32-byte key."""
|
"""Test key generation creates file with 32-byte key."""
|
||||||
key_file = tmp_path / "subdir" / "hmac.key"
|
key_file = tmp_path / "subdir" / "hmac.key"
|
||||||
with patch(
|
with patch(
|
||||||
"python_pkg.screen_locker._log_integrity.HMAC_KEY_FILE",
|
_HMAC_KEY_FILE_PATH,
|
||||||
key_file,
|
key_file,
|
||||||
):
|
):
|
||||||
result = _generate_hmac_key()
|
result = _generate_hmac_key()
|
||||||
@ -62,7 +64,7 @@ class TestGenerateHmacKey:
|
|||||||
def test_returns_none_on_write_failure(self) -> None:
|
def test_returns_none_on_write_failure(self) -> None:
|
||||||
"""Test returns None when file cannot be written."""
|
"""Test returns None when file cannot be written."""
|
||||||
with patch(
|
with patch(
|
||||||
"python_pkg.screen_locker._log_integrity.HMAC_KEY_FILE",
|
_HMAC_KEY_FILE_PATH,
|
||||||
) as mock_path:
|
) as mock_path:
|
||||||
mock_path.parent.mkdir.side_effect = OSError("permission denied")
|
mock_path.parent.mkdir.side_effect = OSError("permission denied")
|
||||||
result = _generate_hmac_key()
|
result = _generate_hmac_key()
|
||||||
@ -79,7 +81,7 @@ class TestComputeEntryHmac:
|
|||||||
key_file.write_bytes(key)
|
key_file.write_bytes(key)
|
||||||
entry = {"timestamp": "2025-01-01T00:00:00", "workout_data": {"type": "test"}}
|
entry = {"timestamp": "2025-01-01T00:00:00", "workout_data": {"type": "test"}}
|
||||||
with patch(
|
with patch(
|
||||||
"python_pkg.screen_locker._log_integrity.HMAC_KEY_FILE",
|
_HMAC_KEY_FILE_PATH,
|
||||||
key_file,
|
key_file,
|
||||||
):
|
):
|
||||||
result = compute_entry_hmac(entry)
|
result = compute_entry_hmac(entry)
|
||||||
@ -93,7 +95,7 @@ class TestComputeEntryHmac:
|
|||||||
"""Test returns None when key file is missing."""
|
"""Test returns None when key file is missing."""
|
||||||
key_file = tmp_path / "nonexistent.key"
|
key_file = tmp_path / "nonexistent.key"
|
||||||
with patch(
|
with patch(
|
||||||
"python_pkg.screen_locker._log_integrity.HMAC_KEY_FILE",
|
_HMAC_KEY_FILE_PATH,
|
||||||
key_file,
|
key_file,
|
||||||
):
|
):
|
||||||
result = compute_entry_hmac({"data": "test"})
|
result = compute_entry_hmac({"data": "test"})
|
||||||
@ -114,7 +116,7 @@ class TestVerifyEntryHmac:
|
|||||||
entry = {**entry_data, "hmac": correct_hmac}
|
entry = {**entry_data, "hmac": correct_hmac}
|
||||||
|
|
||||||
with patch(
|
with patch(
|
||||||
"python_pkg.screen_locker._log_integrity.HMAC_KEY_FILE",
|
_HMAC_KEY_FILE_PATH,
|
||||||
key_file,
|
key_file,
|
||||||
):
|
):
|
||||||
assert verify_entry_hmac(entry) is True
|
assert verify_entry_hmac(entry) is True
|
||||||
@ -126,7 +128,7 @@ class TestVerifyEntryHmac:
|
|||||||
entry = {"timestamp": "2025-01-01", "hmac": "wrong_hmac_value"}
|
entry = {"timestamp": "2025-01-01", "hmac": "wrong_hmac_value"}
|
||||||
|
|
||||||
with patch(
|
with patch(
|
||||||
"python_pkg.screen_locker._log_integrity.HMAC_KEY_FILE",
|
_HMAC_KEY_FILE_PATH,
|
||||||
key_file,
|
key_file,
|
||||||
):
|
):
|
||||||
assert verify_entry_hmac(entry) is False
|
assert verify_entry_hmac(entry) is False
|
||||||
@ -146,7 +148,7 @@ class TestVerifyEntryHmac:
|
|||||||
key_file = tmp_path / "nonexistent.key"
|
key_file = tmp_path / "nonexistent.key"
|
||||||
entry = {"timestamp": "2025-01-01", "hmac": "some_hmac"}
|
entry = {"timestamp": "2025-01-01", "hmac": "some_hmac"}
|
||||||
with patch(
|
with patch(
|
||||||
"python_pkg.screen_locker._log_integrity.HMAC_KEY_FILE",
|
_HMAC_KEY_FILE_PATH,
|
||||||
key_file,
|
key_file,
|
||||||
):
|
):
|
||||||
assert verify_entry_hmac(entry) is False
|
assert verify_entry_hmac(entry) is False
|
||||||
|
|||||||
188
screen_locker/tests/test_wake_shutdown.py
Normal file
188
screen_locker/tests/test_wake_shutdown.py
Normal file
@ -0,0 +1,188 @@
|
|||||||
|
"""Tests for rtcwake integration in ShutdownMixin."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
from python_pkg.screen_locker.tests.conftest import create_locker
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
class TestIsTomorrowAlarmDay:
|
||||||
|
"""Tests for _is_tomorrow_alarm_day."""
|
||||||
|
|
||||||
|
def test_sunday_evening_means_monday_alarm(
|
||||||
|
self,
|
||||||
|
mock_tk: MagicMock,
|
||||||
|
mock_sys_exit: MagicMock,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
"""Sunday evening → Monday is alarm day (weekday=0)."""
|
||||||
|
locker = create_locker(mock_tk, tmp_path)
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
# Sunday 2026-04-12 → tomorrow Monday
|
||||||
|
with patch(
|
||||||
|
"python_pkg.screen_locker._shutdown.datetime",
|
||||||
|
) as mock_dt:
|
||||||
|
mock_dt.now.return_value = datetime(2026, 4, 12, 23, 0, tzinfo=timezone.utc)
|
||||||
|
mock_dt.side_effect = datetime
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
# Ensure timedelta works
|
||||||
|
with patch(
|
||||||
|
"python_pkg.screen_locker._shutdown.timedelta",
|
||||||
|
timedelta,
|
||||||
|
):
|
||||||
|
assert locker._is_tomorrow_alarm_day() is True
|
||||||
|
|
||||||
|
def test_monday_evening_is_not_alarm_next(
|
||||||
|
self,
|
||||||
|
mock_tk: MagicMock,
|
||||||
|
mock_sys_exit: MagicMock,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
"""Monday evening → Tuesday is NOT an alarm day."""
|
||||||
|
locker = create_locker(mock_tk, tmp_path)
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
# Monday 2026-04-13 → tomorrow Tuesday (weekday=1)
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"python_pkg.screen_locker._shutdown.datetime",
|
||||||
|
) as mock_dt,
|
||||||
|
patch(
|
||||||
|
"python_pkg.screen_locker._shutdown.timedelta",
|
||||||
|
timedelta,
|
||||||
|
),
|
||||||
|
):
|
||||||
|
mock_dt.now.return_value = datetime(2026, 4, 13, 23, 0, tzinfo=timezone.utc)
|
||||||
|
mock_dt.side_effect = datetime
|
||||||
|
assert locker._is_tomorrow_alarm_day() is False
|
||||||
|
|
||||||
|
def test_thursday_evening_friday_is_alarm(
|
||||||
|
self,
|
||||||
|
mock_tk: MagicMock,
|
||||||
|
mock_sys_exit: MagicMock,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
"""Thursday evening → Friday is alarm day (weekday=4)."""
|
||||||
|
locker = create_locker(mock_tk, tmp_path)
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
# Thursday 2026-04-16 → tomorrow Friday (weekday=4)
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"python_pkg.screen_locker._shutdown.datetime",
|
||||||
|
) as mock_dt,
|
||||||
|
patch(
|
||||||
|
"python_pkg.screen_locker._shutdown.timedelta",
|
||||||
|
timedelta,
|
||||||
|
),
|
||||||
|
):
|
||||||
|
mock_dt.now.return_value = datetime(2026, 4, 16, 23, 0, tzinfo=timezone.utc)
|
||||||
|
mock_dt.side_effect = datetime
|
||||||
|
assert locker._is_tomorrow_alarm_day() is True
|
||||||
|
|
||||||
|
|
||||||
|
class TestScheduleRtcwake:
|
||||||
|
"""Tests for _schedule_rtcwake."""
|
||||||
|
|
||||||
|
def test_success(
|
||||||
|
self,
|
||||||
|
mock_tk: MagicMock,
|
||||||
|
mock_sys_exit: MagicMock,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
"""Successful rtcwake call returns True."""
|
||||||
|
locker = create_locker(mock_tk, tmp_path)
|
||||||
|
with patch(
|
||||||
|
"python_pkg.screen_locker._shutdown.subprocess.run",
|
||||||
|
) as mock_run:
|
||||||
|
mock_run.return_value = MagicMock(returncode=0)
|
||||||
|
assert locker._schedule_rtcwake() is True
|
||||||
|
mock_run.assert_called_once()
|
||||||
|
cmd = mock_run.call_args[0][0]
|
||||||
|
assert "rtcwake" in cmd[1]
|
||||||
|
|
||||||
|
def test_failure_returns_false(
|
||||||
|
self,
|
||||||
|
mock_tk: MagicMock,
|
||||||
|
mock_sys_exit: MagicMock,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
"""Failed rtcwake call returns False."""
|
||||||
|
locker = create_locker(mock_tk, tmp_path)
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"python_pkg.screen_locker._shutdown.subprocess.run",
|
||||||
|
side_effect=subprocess.SubprocessError("rtcwake failed"),
|
||||||
|
):
|
||||||
|
assert locker._schedule_rtcwake() is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestScheduleWakeIfNeeded:
|
||||||
|
"""Tests for schedule_wake_if_needed."""
|
||||||
|
|
||||||
|
def test_skips_when_not_alarm_day(
|
||||||
|
self,
|
||||||
|
mock_tk: MagicMock,
|
||||||
|
mock_sys_exit: MagicMock,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
"""Returns False when tomorrow is not an alarm day."""
|
||||||
|
locker = create_locker(mock_tk, tmp_path)
|
||||||
|
with patch.object(locker, "_is_tomorrow_alarm_day", return_value=False):
|
||||||
|
assert locker.schedule_wake_if_needed() is False
|
||||||
|
|
||||||
|
def test_schedules_when_alarm_day(
|
||||||
|
self,
|
||||||
|
mock_tk: MagicMock,
|
||||||
|
mock_sys_exit: MagicMock,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
"""Returns True when tomorrow is an alarm day and rtcwake succeeds."""
|
||||||
|
locker = create_locker(mock_tk, tmp_path)
|
||||||
|
with (
|
||||||
|
patch.object(locker, "_is_tomorrow_alarm_day", return_value=True),
|
||||||
|
patch.object(locker, "_schedule_rtcwake", return_value=True),
|
||||||
|
):
|
||||||
|
assert locker.schedule_wake_if_needed() is True
|
||||||
|
|
||||||
|
def test_returns_false_when_rtcwake_fails(
|
||||||
|
self,
|
||||||
|
mock_tk: MagicMock,
|
||||||
|
mock_sys_exit: MagicMock,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
"""Returns False when rtcwake call fails."""
|
||||||
|
locker = create_locker(mock_tk, tmp_path)
|
||||||
|
with (
|
||||||
|
patch.object(locker, "_is_tomorrow_alarm_day", return_value=True),
|
||||||
|
patch.object(locker, "_schedule_rtcwake", return_value=False),
|
||||||
|
):
|
||||||
|
assert locker.schedule_wake_if_needed() is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestComputeWakeTimestamp:
|
||||||
|
"""Tests for _compute_wake_timestamp."""
|
||||||
|
|
||||||
|
def test_returns_future_epoch(
|
||||||
|
self,
|
||||||
|
mock_tk: MagicMock,
|
||||||
|
mock_sys_exit: MagicMock,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
"""Wake timestamp is roughly 8 hours from now."""
|
||||||
|
locker = create_locker(mock_tk, tmp_path)
|
||||||
|
import time
|
||||||
|
|
||||||
|
now = int(time.time())
|
||||||
|
wake = locker._compute_wake_timestamp()
|
||||||
|
# Should be ~8 hours ahead (within 60 second tolerance)
|
||||||
|
expected = now + 8 * 3600
|
||||||
|
assert abs(wake - expected) < 60
|
||||||
83
screen_locker/tests/test_wake_skip.py
Normal file
83
screen_locker/tests/test_wake_skip.py
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
"""Tests for wake alarm skip integration in screen_lock.py."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
from python_pkg.screen_locker.tests.conftest import create_locker
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
class TestWakeSkipIntegration:
|
||||||
|
"""Tests for workout skip via wake alarm in screen locker init."""
|
||||||
|
|
||||||
|
def test_exits_when_wake_skip_active(
|
||||||
|
self,
|
||||||
|
mock_tk: MagicMock,
|
||||||
|
mock_sys_exit: MagicMock,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
"""Screen locker exits if wake alarm granted workout skip today."""
|
||||||
|
with patch(
|
||||||
|
"python_pkg.screen_locker.screen_lock.has_workout_skip_today",
|
||||||
|
return_value=True,
|
||||||
|
):
|
||||||
|
create_locker(mock_tk, tmp_path, has_logged=False)
|
||||||
|
|
||||||
|
mock_sys_exit.assert_called_once_with(0)
|
||||||
|
|
||||||
|
def test_does_not_exit_when_no_wake_skip(
|
||||||
|
self,
|
||||||
|
mock_tk: MagicMock,
|
||||||
|
mock_sys_exit: MagicMock,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
"""Screen locker proceeds normally if no wake skip active."""
|
||||||
|
with patch(
|
||||||
|
"python_pkg.screen_locker.screen_lock.has_workout_skip_today",
|
||||||
|
return_value=False,
|
||||||
|
):
|
||||||
|
locker = create_locker(mock_tk, tmp_path, has_logged=False)
|
||||||
|
|
||||||
|
mock_sys_exit.assert_not_called()
|
||||||
|
assert locker is not None
|
||||||
|
|
||||||
|
def test_logged_today_takes_precedence(
|
||||||
|
self,
|
||||||
|
mock_tk: MagicMock,
|
||||||
|
mock_sys_exit: MagicMock,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
"""has_logged_today exits before wake skip is even checked."""
|
||||||
|
with patch(
|
||||||
|
"python_pkg.screen_locker.screen_lock.has_workout_skip_today",
|
||||||
|
return_value=True,
|
||||||
|
):
|
||||||
|
create_locker(mock_tk, tmp_path, has_logged=True)
|
||||||
|
|
||||||
|
# Exits because has_logged_today, not because of wake skip
|
||||||
|
mock_sys_exit.assert_called_once_with(0)
|
||||||
|
|
||||||
|
def test_verify_only_mode_ignores_wake_skip(
|
||||||
|
self,
|
||||||
|
mock_tk: MagicMock,
|
||||||
|
mock_sys_exit: MagicMock,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
"""verify_only mode checks sick day log, not wake skip."""
|
||||||
|
with patch(
|
||||||
|
"python_pkg.screen_locker.screen_lock.has_workout_skip_today",
|
||||||
|
return_value=True,
|
||||||
|
):
|
||||||
|
create_locker(
|
||||||
|
mock_tk,
|
||||||
|
tmp_path,
|
||||||
|
verify_only=True,
|
||||||
|
is_sick_day_log=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# In verify_only mode, exits don't happen from wake skip path
|
||||||
|
mock_sys_exit.assert_not_called()
|
||||||
Loading…
Reference in New Issue
Block a user