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:
Krzysztof kuhy Rudnicki 2026-04-12 20:45:24 +02:00
parent 9c409a32cd
commit f424be8fe5
6 changed files with 378 additions and 84 deletions

View File

@ -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)

View File

@ -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()

View File

@ -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 "")

View File

@ -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

View 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

View 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()