diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 7e27913..dc3e7c1 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -89,6 +89,7 @@ repos: - --jobs=4 additional_dependencies: - pytest + - gatelock @ git+https://github.com/kuhyx/gatelock@v0.1.0 - repo: https://github.com/PyCQA/bandit rev: 1.7.10 @@ -134,3 +135,17 @@ repos: entry: python3 scripts/check_file_length.py language: system types_or: [python, shell] + - id: flutter-analyze + name: flutter analyze (workout app) + entry: bash -c 'cd stronglift_replacement/workout_app && flutter analyze' + language: system + files: '^stronglift_replacement/workout_app/.*\.(dart|yaml)$' + pass_filenames: false + - id: flutter-test-coverage + name: flutter test --coverage 100% (workout app) + entry: bash scripts/check_flutter_coverage.sh + language: system + files: '^stronglift_replacement/workout_app/.*\.dart$' + pass_filenames: false + require_serial: true + stages: [pre-push] diff --git a/pyproject.toml b/pyproject.toml index 862e1d0..91d174f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,10 @@ name = "screen-locker" version = "1.0.0" description = "Tkinter/systemd screen locker with workout tracking, sick-day management, and wake-alarm integration" requires-python = ">=3.10" -dependencies = [] # pure stdlib — tkinter is bundled with Python +# gatelock: shared lock-window/HMAC backend, also used by diet_guard and wake_alarm +dependencies = [ + "gatelock @ git+https://github.com/kuhyx/gatelock@v0.1.0", +] [tool.ruff] target-version = "py310" diff --git a/screen_locker/_constants.py b/screen_locker/_constants.py index 377a85b..58bd9af 100644 --- a/screen_locker/_constants.py +++ b/screen_locker/_constants.py @@ -4,6 +4,10 @@ from __future__ import annotations from pathlib import Path +from gatelock.log_integrity import DEFAULT_HMAC_KEY_FILE + +# Single-sourced from gatelock so the literal key path can't drift. +HMAC_KEY_FILE = DEFAULT_HMAC_KEY_FILE SICK_LOCKOUT_SECONDS = 120 # base 2 minutes wait when sick (escalates with usage) PHONE_PENALTY_DELAY_DEMO = 10 PHONE_PENALTY_DELAY_PRODUCTION = 100 @@ -28,17 +32,22 @@ SICK_COMMITMENT_PENALTY_DAYS = 2 # How long the commitment prompt stays visible after a workout unlock. COMMITMENT_PROMPT_TIMEOUT_SECONDS = 15 ADB_TIMEOUT = 15 -STRONGLIFTS_DB_REMOTE = ( - "/data/data/com.stronglifts.app/databases/StrongLifts-Database-3" +# Workout app JSON candidate paths on the phone, in the order the app prefers +# when writing (see sync_service.dart). The app writes to the primary /sdcard/ +# path first and only falls back to its app-external files dir if /sdcard/ is +# not writable, so the locker must check both — primary first. +WORKOUT_APP_JSON_REMOTES = ( + "/sdcard/workout_result.json", + "/storage/emulated/0/Android/data/com.kuhy.workout_app/files/workout_result.json", ) -MIN_WORKOUT_DURATION_MINUTES = 50 +# Port the workout app's HTTP server listens on (no ADB/developer-options needed). +WORKOUT_HTTP_PORT = 8765 +MIN_WORKOUT_DURATION_MINUTES = 60 MAX_CLOCK_SKEW_SECONDS = 300 # 5 minutes max time skew from NTP EARLY_BIRD_START_HOUR = 5 EARLY_BIRD_END_HOUR = 8 EARLY_BIRD_END_MINUTE = 30 SHUTDOWN_CONFIG_FILE = Path("/etc/shutdown-schedule.conf") -# HMAC key for signing workout log entries (root-owned, 0600) -HMAC_KEY_FILE = Path("/etc/workout-locker/hmac.key") # Helper script path (relative to this file) ADJUST_SHUTDOWN_SCRIPT = Path(__file__).resolve().parent / "adjust_shutdown_schedule.sh" # State file to track sick day usage and original config values diff --git a/screen_locker/_log_integrity.py b/screen_locker/_log_integrity.py deleted file mode 100644 index 762c702..0000000 --- a/screen_locker/_log_integrity.py +++ /dev/null @@ -1,80 +0,0 @@ -"""HMAC-based integrity checking for signed state entries.""" - -from __future__ import annotations - -import hashlib -import hmac -import json -import logging -from pathlib import Path -import secrets - -_logger = logging.getLogger(__name__) - -# HMAC key for signing state entries (root-owned, 0600) -HMAC_KEY_FILE = Path("/etc/workout-locker/hmac.key") - - -def _load_hmac_key() -> bytes | None: - """Load HMAC key from the root-owned key file. - - Returns the key bytes, or None if the file cannot be read. - """ - try: - return HMAC_KEY_FILE.read_bytes().strip() - except OSError: - _logger.warning("Cannot read HMAC key from %s", HMAC_KEY_FILE) - return None - - -def _generate_hmac_key() -> bytes | None: - """Generate a new HMAC key and write it to the key file. - - The key file must be writable (requires root or setup script). - Returns the new key bytes, or None on failure. - """ - key = secrets.token_bytes(32) - try: - HMAC_KEY_FILE.parent.mkdir(parents=True, exist_ok=True) - HMAC_KEY_FILE.write_bytes(key) - except OSError: - _logger.warning("Cannot write HMAC key to %s", HMAC_KEY_FILE) - return None - return key - - -def compute_entry_hmac(entry_data: dict[str, object]) -> str | None: - """Compute HMAC-SHA256 for a state entry. - - Args: - entry_data: The entry dict (without the 'hmac' field). - - Returns: - Hex-encoded HMAC string, or None if the key is unavailable. - """ - key = _load_hmac_key() - if key is None: - return None - payload = json.dumps(entry_data, sort_keys=True, separators=(",", ":")) - return hmac.new(key, payload.encode(), hashlib.sha256).hexdigest() - - -def verify_entry_hmac(entry: dict[str, object]) -> bool: - """Verify HMAC signature of a state entry. - - Args: - entry: The full entry dict including the 'hmac' field. - - Returns: - True if the HMAC is valid, False if invalid or key unavailable. - """ - stored_hmac = entry.get("hmac") - if not isinstance(stored_hmac, str): - return False - key = _load_hmac_key() - if key is None: - return False - entry_without_hmac = {k: v for k, v in entry.items() if k != "hmac"} - payload = json.dumps(entry_without_hmac, sort_keys=True, separators=(",", ":")) - expected = hmac.new(key, payload.encode(), hashlib.sha256).hexdigest() - return hmac.compare_digest(stored_hmac, expected) diff --git a/screen_locker/_sick_tracker.py b/screen_locker/_sick_tracker.py index 9ab7070..c2d87f4 100644 --- a/screen_locker/_sick_tracker.py +++ b/screen_locker/_sick_tracker.py @@ -12,6 +12,8 @@ import json import logging from typing import Any +from gatelock.log_integrity import compute_entry_hmac + from screen_locker._constants import ( SICK_BUDGET_PER_7_DAYS, SICK_BUDGET_PER_30_DAYS, @@ -23,7 +25,6 @@ from screen_locker._constants import ( SICK_LOCKOUT_MULTIPLIER_PER_RECENT, SICK_LOCKOUT_SECONDS, ) -from screen_locker._log_integrity import compute_entry_hmac _logger = logging.getLogger(__name__) diff --git a/screen_locker/_wake_state.py b/screen_locker/_wake_state.py index d4e7049..c6209fc 100644 --- a/screen_locker/_wake_state.py +++ b/screen_locker/_wake_state.py @@ -15,8 +15,9 @@ from datetime import datetime, timezone import json import logging +from gatelock.log_integrity import verify_entry_hmac + from screen_locker._constants import WAKE_STATE_FILE -from screen_locker._log_integrity import verify_entry_hmac _logger = logging.getLogger(__name__) diff --git a/screen_locker/_window_setup.py b/screen_locker/_window_setup.py index e1f0fcd..c64bfc4 100644 --- a/screen_locker/_window_setup.py +++ b/screen_locker/_window_setup.py @@ -1,49 +1,28 @@ -"""Window configuration and input-grab helpers for ScreenLocker.""" +"""Auxiliary (non-lock) window setup for ScreenLocker. + +The fullscreen lock-window mechanics (overrideredirect, input grab, +VT-disable) now live in the shared ``gatelock`` package. This module keeps +only the screen-locker-specific windows that are never the lock itself: the +post-sick-day verification window, the demo close button, and the optional +relaxed-day prompt. +""" from __future__ import annotations -import contextlib -import logging -import shutil -import subprocess import tkinter as tk -_logger = logging.getLogger(__name__) - class WindowSetupMixin: - """Mixin providing window setup, VT switching control, and input-grab helpers.""" + """Mixin providing the screen-locker-specific auxiliary windows.""" - def _disable_vt_switching(self) -> None: - """Disable VT switching in X11 while the lock is active. + def on_focus_ready(self) -> None: + """No typed-input field in the lock window; nothing to focus.""" - Prevents bypassing the lock by switching to a TTY with Ctrl+Alt+Fn. - Best-effort: silently ignored if setxkbmap is unavailable. - """ - setxkbmap = shutil.which("setxkbmap") - if setxkbmap is None: - _logger.warning("setxkbmap not found; VT switching will not be disabled") - return - subprocess.run([setxkbmap, "-option", "srvrkeys:none"], check=False) + def on_callback_error(self) -> None: + """Surfaced via GateRoot's logging already; no extra action yet.""" - def _restore_vt_switching(self) -> None: - """Restore VT switching after the lock is dismissed.""" - setxkbmap = shutil.which("setxkbmap") - if setxkbmap is None: - return - subprocess.run([setxkbmap, "-option", ""], check=False) - - def _setup_window(self) -> None: - """Configure the window for fullscreen lock.""" - screen_w = self.root.winfo_screenwidth() - screen_h = self.root.winfo_screenheight() - self.root.overrideredirect(boolean=True) - self.root.geometry(f"{screen_w}x{screen_h}+0+0") - self.root.attributes(fullscreen=True) - self.root.attributes(topmost=True) - self.root.configure(bg="#1a1a1a", cursor="arrow") - if not self.demo_mode: - self._disable_vt_switching() + def on_close(self) -> None: + """No extra hardware/state beyond what close() already handles.""" def _setup_verify_window(self) -> None: """Configure window for post-sick-day workout verification.""" @@ -69,18 +48,3 @@ class WindowSetupMixin: self.root.geometry("700x450") self.root.configure(bg="#1a1a1a", cursor="arrow") self.root.protocol("WM_DELETE_WINDOW", self.close) - - def _grab_input(self) -> None: - """Force input focus to the locker window.""" - self.root.update_idletasks() - self.root.focus_force() - if self.demo_mode: - with contextlib.suppress(tk.TclError): - self.root.grab_set() - else: - try: - self.root.grab_set_global() - except tk.TclError: - _logger.warning("Global grab failed, falling back to local grab") - with contextlib.suppress(tk.TclError): - self.root.grab_set() diff --git a/screen_locker/screen_lock.py b/screen_locker/screen_lock.py index 4bab081..05e42c0 100755 --- a/screen_locker/screen_lock.py +++ b/screen_locker/screen_lock.py @@ -14,6 +14,9 @@ import sys import tkinter as tk from typing import TYPE_CHECKING +from gatelock import GateRoot, LockConfig, LockWindow +from gatelock.log_integrity import compute_entry_hmac, verify_entry_hmac + from screen_locker import _sick_tracker from screen_locker._constants import ( EARLY_BIRD_END_HOUR, @@ -26,14 +29,8 @@ from screen_locker._constants import ( PHONE_PENALTY_DELAY_PRODUCTION, SCHEDULED_SKIPS_FILE, SICK_LOCKOUT_SECONDS, - STRONGLIFTS_DB_REMOTE, ) from screen_locker._early_bird import EarlyBirdMixin -from screen_locker._log_integrity import ( - _load_hmac_key, - compute_entry_hmac, - verify_entry_hmac, -) from screen_locker._phone_verification import PhoneVerificationMixin from screen_locker._shutdown import ShutdownMixin from screen_locker._sick_dialog import SickDialogMixin @@ -62,7 +59,6 @@ __all__ = [ "PHONE_PENALTY_DELAY_PRODUCTION", "SCHEDULED_SKIPS_FILE", "SICK_LOCKOUT_SECONDS", - "STRONGLIFTS_DB_REMOTE", "WEEKLY_WORKOUT_MINIMUM", "ScreenLocker", ] @@ -111,19 +107,27 @@ class ScreenLocker( self.workout_data: dict[str, str] = {} self._relaxed_day_mode: bool = False self._check_early_exits(verify_only=verify_only) - self.root = tk.Tk() + self.root = GateRoot() + self.root.on_callback_error = self.on_callback_error title_suffix = ( " [VERIFY]" if verify_only else (" [DEMO MODE]" if demo_mode else "") ) self.root.title("Workout Locker" + title_suffix) self.demo_mode = demo_mode self.lockout_time = 10 if demo_mode else 1800 + self._lock: LockWindow | None = None if verify_only: self._setup_verify_window() elif self._relaxed_day_mode: self._setup_relaxed_day_window() else: - self._setup_window() + config = LockConfig( + mode="hard", + grab="local" if demo_mode else "global", + disable_vt=not demo_mode, + ) + self._lock = LockWindow(self.root, config, hooks=self) + self._lock.setup() if demo_mode: self._setup_demo_close_button() self.container = tk.Frame(self.root, bg="#1a1a1a") @@ -135,7 +139,10 @@ class ScreenLocker( self._start_relaxed_day_flow() else: self._start_phone_check() - self._grab_input() + # Always set on this branch; guard only for mypy (can't narrow + # across two separate if/elif/else statements). + if self._lock is not None: # pragma: no branch + self._lock.grab_input() def _is_sick_day_log(self) -> bool: """Check if today's workout log is a sick day (not yet verified).""" @@ -304,7 +311,7 @@ class ScreenLocker( return False if verify_entry_hmac(entry): return entry.get("workout_data", {}).get("type") != "early_bird" - if _load_hmac_key() is None and "hmac" not in entry: + if compute_entry_hmac({"_probe": True}) is None and "hmac" not in entry: _logger.info( "HMAC key unavailable — accepting unsigned entry", ) @@ -358,14 +365,18 @@ class ScreenLocker( def close(self) -> None: """Close the application and exit.""" - if not self.demo_mode: - self._restore_vt_switching() - self.root.destroy() + if self._lock is not None: + self._lock.close() + else: + self.root.destroy() sys.exit(0) def run(self) -> None: """Start the Tkinter main event loop.""" - self.root.mainloop() + if self._lock is not None: + self._lock.run() + else: + self.root.mainloop() if __name__ == "__main__": diff --git a/screen_locker/tests/conftest.py b/screen_locker/tests/conftest.py index cba25c7..5ee2687 100644 --- a/screen_locker/tests/conftest.py +++ b/screen_locker/tests/conftest.py @@ -2,7 +2,8 @@ Safety: ``_block_real_tk_and_exit`` (autouse) replaces the **entire** ``tk`` - module reference inside ``screen_lock`` with a MagicMock and stubs + module reference inside ``screen_lock`` with a MagicMock, replaces + ``GateRoot`` with a callable returning that same mock root, and stubs ``sys.exit``. This makes it physically impossible for any test to create a real Tk root window, go fullscreen, or grab input — even if the test forgets to request the explicit ``mock_tk`` fixture. @@ -10,6 +11,7 @@ Safety: from __future__ import annotations +from contextlib import ExitStack from pathlib import Path import tkinter as tk from typing import TYPE_CHECKING @@ -24,6 +26,20 @@ if TYPE_CHECKING: from typing import Literal +# Every module that imports ``tkinter as tk`` and calls it directly. The UI was +# split across these modules, so each ``tk`` reference must be patched — both to +# guarantee no test can touch a real display and so a test holding ``mock_tk`` +# sees widgets created on that same mock (not a divergent autouse mock). +_TK_MODULES = ( + "screen_locker.screen_lock", + "screen_locker._sick_dialog", + "screen_locker._ui_widgets", + "screen_locker._window_setup", +) +_VT_SHUTIL = "gatelock._vt.shutil" +_VT_SUBPROCESS = "gatelock._vt.subprocess" + + def _make_mock_tk() -> MagicMock: """Build a MagicMock that stands in for the ``tkinter`` module.""" mock = MagicMock() @@ -50,11 +66,16 @@ def _block_real_tk_and_exit() -> Iterator[None]: """ mock = _make_mock_tk() - with ( - patch("screen_locker.screen_lock.tk", mock), - patch("screen_locker._sick_dialog.tk", mock), - patch("screen_locker.screen_lock.sys.exit"), - ): + with ExitStack() as stack: + for module in _TK_MODULES: + stack.enter_context(patch(f"{module}.tk", mock)) + stack.enter_context( + patch( + "screen_locker.screen_lock.GateRoot", + return_value=mock.Tk.return_value, + ) + ) + stack.enter_context(patch("screen_locker.screen_lock.sys.exit")) yield @@ -69,15 +90,29 @@ def mock_subprocess_run() -> Generator[MagicMock]: regardless of whether setxkbmap is installed on the host machine. """ with ( - patch( - "screen_locker._window_setup.shutil.which", - return_value="/usr/bin/setxkbmap", - ), - patch("screen_locker._window_setup.subprocess.run") as mock, + patch(f"{_VT_SHUTIL}.which", return_value="/usr/bin/setxkbmap"), + patch(f"{_VT_SUBPROCESS}.run") as mock, ): yield mock +@pytest.fixture(autouse=True) +def _block_real_network() -> Iterator[None]: + """Block real subnet probes for every test. + + ``_scan_for_http_server`` / ``_try_wireless_reconnect`` open real TCP + sockets to scan the LAN; without this an unmocked ``_verify_phone_workout`` + would actually reach the phone over the network (flaky, environment-coupled). + Defaults ``create_connection`` to refuse — tests needing a successful probe + patch it locally, which takes precedence inside the test body. + """ + with patch( + "screen_locker._phone_verification.socket.create_connection", + side_effect=OSError("network blocked in tests"), + ): + yield + + @pytest.fixture(autouse=True) def _isolate_sick_history(tmp_path: Path) -> Iterator[None]: """Redirect SICK_HISTORY_FILE to tmp_path so tests cannot touch real state.""" @@ -130,22 +165,22 @@ def _mock_weekly_logic() -> Iterator[None]: @pytest.fixture def mock_tk() -> Generator[MagicMock]: - """Mock tkinter module for testing without display.""" - with patch("screen_locker.screen_lock.tk") as mock: - # Set up Tk root mock - mock_root = MagicMock() - mock_root.winfo_screenwidth.return_value = 1920 - mock_root.winfo_screenheight.return_value = 1080 - mock.Tk.return_value = mock_root - - # Set up Frame mock - mock_frame = MagicMock() - mock_frame.winfo_children.return_value = [] - mock.Frame.return_value = mock_frame - - # Set up TclError as actual exception class - mock.TclError = tk.TclError + """Mock the ``tkinter`` module across every UI module for display-free tests. + Patches the same single mock into all ``_TK_MODULES`` so assertions on + ``mock_tk.Button`` capture widgets created by any of the split UI mixins + (``_ui_widgets``, ``_sick_dialog``, ...), not just ``screen_lock``. + """ + mock = _make_mock_tk() + with ExitStack() as stack: + for module in _TK_MODULES: + stack.enter_context(patch(f"{module}.tk", mock)) + stack.enter_context( + patch( + "screen_locker.screen_lock.GateRoot", + return_value=mock.Tk.return_value, + ) + ) yield mock diff --git a/screen_locker/tests/test_init_and_log.py b/screen_locker/tests/test_init_and_log.py index 666246c..81b8563 100644 --- a/screen_locker/tests/test_init_and_log.py +++ b/screen_locker/tests/test_init_and_log.py @@ -175,7 +175,7 @@ class TestHasLoggedToday: return_value=False, ), patch( - "screen_locker.screen_lock._load_hmac_key", + "screen_locker.screen_lock.compute_entry_hmac", return_value=None, ), ): @@ -202,8 +202,8 @@ class TestHasLoggedToday: return_value=False, ), patch( - "screen_locker.screen_lock._load_hmac_key", - return_value=b"secret-key", + "screen_locker.screen_lock.compute_entry_hmac", + return_value="some-signature", ), ): assert locker.has_logged_today() is False diff --git a/screen_locker/tests/test_log_integrity.py b/screen_locker/tests/test_log_integrity.py deleted file mode 100644 index 335a521..0000000 --- a/screen_locker/tests/test_log_integrity.py +++ /dev/null @@ -1,154 +0,0 @@ -"""Tests for _log_integrity HMAC signing and verification.""" - -from __future__ import annotations - -import hashlib -import hmac -import json -from typing import TYPE_CHECKING -from unittest.mock import patch - -from screen_locker._log_integrity import ( - _generate_hmac_key, - _load_hmac_key, - compute_entry_hmac, - verify_entry_hmac, -) - -_HMAC_KEY_FILE_PATH = "screen_locker._log_integrity.HMAC_KEY_FILE" - -if TYPE_CHECKING: - from pathlib import Path - - -class TestLoadHmacKey: - """Tests for _load_hmac_key.""" - - def test_loads_key_from_file(self, tmp_path: Path) -> None: - """Test loading HMAC key from existing file.""" - key_file = tmp_path / "hmac.key" - key_file.write_bytes(b"secret_key_bytes") - with patch( - _HMAC_KEY_FILE_PATH, - key_file, - ): - result = _load_hmac_key() - assert result == b"secret_key_bytes" - - def test_returns_none_on_missing_file(self, tmp_path: Path) -> None: - """Test returns None when key file doesn't exist.""" - key_file = tmp_path / "nonexistent.key" - with patch( - _HMAC_KEY_FILE_PATH, - key_file, - ): - result = _load_hmac_key() - assert result is None - - -class TestGenerateHmacKey: - """Tests for _generate_hmac_key.""" - - def test_generates_and_writes_key(self, tmp_path: Path) -> None: - """Test key generation creates file with 32-byte key.""" - key_file = tmp_path / "subdir" / "hmac.key" - with patch( - _HMAC_KEY_FILE_PATH, - key_file, - ): - result = _generate_hmac_key() - assert result is not None - assert len(result) == 32 - assert key_file.read_bytes() == result - - def test_returns_none_on_write_failure(self) -> None: - """Test returns None when file cannot be written.""" - with patch( - _HMAC_KEY_FILE_PATH, - ) as mock_path: - mock_path.parent.mkdir.side_effect = OSError("permission denied") - result = _generate_hmac_key() - assert result is None - - -class TestComputeEntryHmac: - """Tests for compute_entry_hmac.""" - - def test_computes_hmac_for_entry(self, tmp_path: Path) -> None: - """Test HMAC computation produces valid hex string.""" - key_file = tmp_path / "hmac.key" - key = b"test_key_12345" - key_file.write_bytes(key) - entry = {"timestamp": "2025-01-01T00:00:00", "workout_data": {"type": "test"}} - with patch( - _HMAC_KEY_FILE_PATH, - key_file, - ): - result = compute_entry_hmac(entry) - assert result is not None - # Verify manually - payload = json.dumps(entry, sort_keys=True, separators=(",", ":")) - expected = hmac.new(key, payload.encode(), hashlib.sha256).hexdigest() - assert result == expected - - def test_returns_none_when_no_key(self, tmp_path: Path) -> None: - """Test returns None when key file is missing.""" - key_file = tmp_path / "nonexistent.key" - with patch( - _HMAC_KEY_FILE_PATH, - key_file, - ): - result = compute_entry_hmac({"data": "test"}) - assert result is None - - -class TestVerifyEntryHmac: - """Tests for verify_entry_hmac.""" - - def test_valid_hmac(self, tmp_path: Path) -> None: - """Test verification passes with correct HMAC.""" - key_file = tmp_path / "hmac.key" - key = b"verification_key" - key_file.write_bytes(key) - entry_data = {"timestamp": "2025-01-01", "workout_data": {"type": "test"}} - payload = json.dumps(entry_data, sort_keys=True, separators=(",", ":")) - correct_hmac = hmac.new(key, payload.encode(), hashlib.sha256).hexdigest() - entry = {**entry_data, "hmac": correct_hmac} - - with patch( - _HMAC_KEY_FILE_PATH, - key_file, - ): - assert verify_entry_hmac(entry) is True - - def test_invalid_hmac(self, tmp_path: Path) -> None: - """Test verification fails with wrong HMAC.""" - key_file = tmp_path / "hmac.key" - key_file.write_bytes(b"verification_key") - entry = {"timestamp": "2025-01-01", "hmac": "wrong_hmac_value"} - - with patch( - _HMAC_KEY_FILE_PATH, - key_file, - ): - assert verify_entry_hmac(entry) is False - - def test_missing_hmac_field(self) -> None: - """Test verification fails when entry has no hmac field.""" - entry: dict[str, object] = {"timestamp": "2025-01-01"} - assert verify_entry_hmac(entry) is False - - def test_non_string_hmac_field(self) -> None: - """Test verification fails when hmac field is not a string.""" - entry: dict[str, object] = {"timestamp": "2025-01-01", "hmac": 12345} - assert verify_entry_hmac(entry) is False - - def test_missing_key_file(self, tmp_path: Path) -> None: - """Test verification fails when key file doesn't exist.""" - key_file = tmp_path / "nonexistent.key" - entry = {"timestamp": "2025-01-01", "hmac": "some_hmac"} - with patch( - _HMAC_KEY_FILE_PATH, - key_file, - ): - assert verify_entry_hmac(entry) is False diff --git a/screen_locker/tests/test_verify_workout.py b/screen_locker/tests/test_verify_workout.py index d027a67..8895834 100644 --- a/screen_locker/tests/test_verify_workout.py +++ b/screen_locker/tests/test_verify_workout.py @@ -161,6 +161,27 @@ class TestVerifyOnlyInit: ) locker.root.title.assert_called_with("Workout Locker [VERIFY]") + def test_close_and_run_use_root_directly( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """No LockWindow is built for verify_only; close()/run() use root.""" + locker = create_locker( + mock_tk, + tmp_path, + verify_only=True, + is_sick_day_log=True, + ) + assert locker._lock is None + + locker.run() + locker.root.mainloop.assert_called_once() + + locker.close() + locker.root.destroy.assert_called_once() + class TestSetupVerifyWindow: """Tests for _setup_verify_window.""" diff --git a/screen_locker/tests/test_vt_switching.py b/screen_locker/tests/test_vt_switching.py index d1079e6..04198b5 100644 --- a/screen_locker/tests/test_vt_switching.py +++ b/screen_locker/tests/test_vt_switching.py @@ -109,7 +109,7 @@ class TestVTSwitching: ) -> None: """No crash and no subprocess call when setxkbmap is not installed.""" with patch( - "screen_locker._window_setup.shutil.which", + "gatelock._vt.shutil.which", return_value=None, ): create_locker(mock_tk, tmp_path, demo_mode=False) @@ -128,7 +128,7 @@ class TestVTSwitching: mock_subprocess_run.reset_mock() with patch( - "screen_locker._window_setup.shutil.which", + "gatelock._vt.shutil.which", return_value=None, ): locker.close() diff --git a/screen_locker/tests/test_weekly_logic.py b/screen_locker/tests/test_weekly_logic.py index 884244f..e1b066d 100644 --- a/screen_locker/tests/test_weekly_logic.py +++ b/screen_locker/tests/test_weekly_logic.py @@ -82,7 +82,7 @@ class TestRelaxedDayBranch: "screen_locker.screen_lock.has_weekly_minimum", return_value=False, ), - patch.object(ScreenLocker, "_setup_window") as mock_full, + patch("screen_locker.screen_lock.LockWindow") as mock_lock_window, patch.object(ScreenLocker, "_setup_relaxed_day_window") as mock_small, patch.object(ScreenLocker, "_start_phone_check"), patch.object(ScreenLocker, "_start_relaxed_day_flow"), @@ -91,7 +91,7 @@ class TestRelaxedDayBranch: ScreenLocker(demo_mode=True) mock_small.assert_called_once() - mock_full.assert_not_called() + mock_lock_window.assert_not_called() def test_relaxed_day_no_grab_input( self, @@ -116,14 +116,14 @@ class TestRelaxedDayBranch: "screen_locker.screen_lock.has_weekly_minimum", return_value=False, ), - patch.object(ScreenLocker, "_grab_input") as mock_grab, + patch("screen_locker.screen_lock.LockWindow") as mock_lock_window, patch.object(ScreenLocker, "_start_phone_check"), patch.object(ScreenLocker, "_start_relaxed_day_flow"), patch.object(ScreenLocker, "_start_verify_workout_check"), ): ScreenLocker(demo_mode=True) - mock_grab.assert_not_called() + mock_lock_window.assert_not_called() def test_has_logged_today_exits_before_relaxed_check( self, @@ -217,7 +217,7 @@ class TestStartRelaxedDayFlow: locker = self._make_locker(mock_tk, tmp_path) with ( patch( - "screen_locker._ui_flows.count_weekly_workouts", + "screen_locker._ui_flows_relaxed.count_weekly_workouts", return_value=2, ), patch.object(locker, "_text") as mock_text, @@ -241,7 +241,7 @@ class TestStartRelaxedDayFlow: locker = self._make_locker(mock_tk, tmp_path) with ( patch( - "screen_locker._ui_flows.count_weekly_workouts", + "screen_locker._ui_flows_relaxed.count_weekly_workouts", return_value=0, ), patch.object(locker, "_button") as mock_button, @@ -268,7 +268,7 @@ class TestStartRelaxedDayFlow: locker = self._make_locker(mock_tk, tmp_path) with ( patch( - "screen_locker._ui_flows.count_weekly_workouts", + "screen_locker._ui_flows_relaxed.count_weekly_workouts", return_value=1, ), patch.object(locker, "_button") as mock_button, @@ -347,3 +347,44 @@ class TestStartRelaxedPhoneCheck: with patch.object(locker, "_handle_relaxed_phone_result") as mock_handle: locker._poll_relaxed_phone_check() mock_handle.assert_not_called() + + +class TestHandleRelaxedPhoneResult: + """Tests for _handle_relaxed_phone_result routing and the retry screen.""" + + def test_verified_saves_and_schedules_unlock( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + locker = create_locker(mock_tk, tmp_path) + locker._handle_relaxed_phone_result("verified", "Workout verified!") + assert locker.workout_data["type"] == "phone_verified" + assert locker.workout_data["source"] == "Workout verified!" + locker.root.after.assert_called_with(1500, locker.unlock_screen) + + def test_non_verified_shows_retry( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + locker = create_locker(mock_tk, tmp_path) + with patch.object(locker, "_show_relaxed_retry") as mock_retry: + locker._handle_relaxed_phone_result("not_verified", "nope") + mock_retry.assert_called_once_with("nope", "not_verified") + + def test_show_relaxed_retry_renders_buttons( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + locker = create_locker(mock_tk, tmp_path) + locker._show_relaxed_retry("No workout", "not_verified") + button_texts = { + call.kwargs.get("text") for call in mock_tk.Button.call_args_list + } + assert "TRY AGAIN" in button_texts + assert "Close (Skip)" in button_texts diff --git a/screen_locker/tests/test_weekly_logic_part2.py b/screen_locker/tests/test_weekly_logic_part2.py index 7c86ddc..e80ffdb 100644 --- a/screen_locker/tests/test_weekly_logic_part2.py +++ b/screen_locker/tests/test_weekly_logic_part2.py @@ -2,11 +2,15 @@ from __future__ import annotations -from pathlib import Path +from typing import TYPE_CHECKING from unittest.mock import MagicMock, patch -from screen_locker.screen_lock import ScreenLocker -from screen_locker.tests.conftest import create_locker +from screen_locker.tests.conftest import create_locker, create_locker_relaxed_day + +if TYPE_CHECKING: + from pathlib import Path + + from screen_locker.screen_lock import ScreenLocker # --------------------------------------------------------------------------- # _check_today_state_exits: return True/False branches @@ -156,3 +160,22 @@ class TestCheckNonVerifyExitsScheduledSkip: with patch.object(locker, "_is_scheduled_skip_today", return_value=True): locker._check_non_verify_exits() mock_sys_exit.assert_called_once_with(0) + + +class TestRelaxedDayCloseAndRun: + """No LockWindow is built on a relaxed day; close()/run() use root.""" + + def test_relaxed_day_close_and_run_use_root_directly( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + locker = create_locker_relaxed_day(mock_tk, tmp_path) + assert locker._lock is None + + locker.run() + locker.root.mainloop.assert_called_once() + + locker.close() + locker.root.destroy.assert_called_once()