mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-07-04 12:03:09 +02:00
Migrate to the shared gatelock backend
ScreenLocker now composes gatelock.GateRoot + gatelock.LockWindow for the actual lock window instead of the inline WindowSetupMixin mechanics; the verify/relaxed-day auxiliary windows (never the lock itself) stay as plain Tk windows. The hand-copied _log_integrity.py is deleted in favor of gatelock.log_integrity (the canonical, non-duplicated module). This is the second of three migrations (diet_guard done, wake_alarm next). Two deliberate behavior changes, both confirmed: - dependencies = [] (pure stdlib) now includes gatelock, a documented departure from the prior zero-deps stance. - production grab upgraded from single-attempt-then-local-fallback to diet_guard's retry-forever (robust to e.g. a fullscreen game holding the grab). Net hardening as a side effect: run()/close() now go through gatelock's signal-safe lifecycle, so SIGTERM/SIGINT restore VT switching on every exit path -- previously only a clean close() did, leaving VT switching disabled if the service was killed mid-lock. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XCdT46zV8hESDvbgYMGDLt
This commit is contained in:
parent
d8062a601f
commit
70cf6f5425
@ -89,6 +89,7 @@ repos:
|
|||||||
- --jobs=4
|
- --jobs=4
|
||||||
additional_dependencies:
|
additional_dependencies:
|
||||||
- pytest
|
- pytest
|
||||||
|
- gatelock @ git+https://github.com/kuhyx/gatelock@v0.1.0
|
||||||
|
|
||||||
- repo: https://github.com/PyCQA/bandit
|
- repo: https://github.com/PyCQA/bandit
|
||||||
rev: 1.7.10
|
rev: 1.7.10
|
||||||
@ -134,3 +135,17 @@ repos:
|
|||||||
entry: python3 scripts/check_file_length.py
|
entry: python3 scripts/check_file_length.py
|
||||||
language: system
|
language: system
|
||||||
types_or: [python, shell]
|
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]
|
||||||
|
|||||||
@ -3,7 +3,10 @@ name = "screen-locker"
|
|||||||
version = "1.0.0"
|
version = "1.0.0"
|
||||||
description = "Tkinter/systemd screen locker with workout tracking, sick-day management, and wake-alarm integration"
|
description = "Tkinter/systemd screen locker with workout tracking, sick-day management, and wake-alarm integration"
|
||||||
requires-python = ">=3.10"
|
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]
|
[tool.ruff]
|
||||||
target-version = "py310"
|
target-version = "py310"
|
||||||
|
|||||||
@ -4,6 +4,10 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from pathlib import Path
|
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)
|
SICK_LOCKOUT_SECONDS = 120 # base 2 minutes wait when sick (escalates with usage)
|
||||||
PHONE_PENALTY_DELAY_DEMO = 10
|
PHONE_PENALTY_DELAY_DEMO = 10
|
||||||
PHONE_PENALTY_DELAY_PRODUCTION = 100
|
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.
|
# How long the commitment prompt stays visible after a workout unlock.
|
||||||
COMMITMENT_PROMPT_TIMEOUT_SECONDS = 15
|
COMMITMENT_PROMPT_TIMEOUT_SECONDS = 15
|
||||||
ADB_TIMEOUT = 15
|
ADB_TIMEOUT = 15
|
||||||
STRONGLIFTS_DB_REMOTE = (
|
# Workout app JSON candidate paths on the phone, in the order the app prefers
|
||||||
"/data/data/com.stronglifts.app/databases/StrongLifts-Database-3"
|
# 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
|
MAX_CLOCK_SKEW_SECONDS = 300 # 5 minutes max time skew from NTP
|
||||||
EARLY_BIRD_START_HOUR = 5
|
EARLY_BIRD_START_HOUR = 5
|
||||||
EARLY_BIRD_END_HOUR = 8
|
EARLY_BIRD_END_HOUR = 8
|
||||||
EARLY_BIRD_END_MINUTE = 30
|
EARLY_BIRD_END_MINUTE = 30
|
||||||
SHUTDOWN_CONFIG_FILE = Path("/etc/shutdown-schedule.conf")
|
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)
|
# Helper script path (relative to this file)
|
||||||
ADJUST_SHUTDOWN_SCRIPT = Path(__file__).resolve().parent / "adjust_shutdown_schedule.sh"
|
ADJUST_SHUTDOWN_SCRIPT = Path(__file__).resolve().parent / "adjust_shutdown_schedule.sh"
|
||||||
# State file to track sick day usage and original config values
|
# State file to track sick day usage and original config values
|
||||||
|
|||||||
@ -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)
|
|
||||||
@ -12,6 +12,8 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from gatelock.log_integrity import compute_entry_hmac
|
||||||
|
|
||||||
from screen_locker._constants import (
|
from screen_locker._constants import (
|
||||||
SICK_BUDGET_PER_7_DAYS,
|
SICK_BUDGET_PER_7_DAYS,
|
||||||
SICK_BUDGET_PER_30_DAYS,
|
SICK_BUDGET_PER_30_DAYS,
|
||||||
@ -23,7 +25,6 @@ from screen_locker._constants import (
|
|||||||
SICK_LOCKOUT_MULTIPLIER_PER_RECENT,
|
SICK_LOCKOUT_MULTIPLIER_PER_RECENT,
|
||||||
SICK_LOCKOUT_SECONDS,
|
SICK_LOCKOUT_SECONDS,
|
||||||
)
|
)
|
||||||
from screen_locker._log_integrity import compute_entry_hmac
|
|
||||||
|
|
||||||
_logger = logging.getLogger(__name__)
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|||||||
@ -15,8 +15,9 @@ from datetime import datetime, timezone
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
|
from gatelock.log_integrity import verify_entry_hmac
|
||||||
|
|
||||||
from screen_locker._constants import WAKE_STATE_FILE
|
from screen_locker._constants import WAKE_STATE_FILE
|
||||||
from screen_locker._log_integrity import verify_entry_hmac
|
|
||||||
|
|
||||||
_logger = logging.getLogger(__name__)
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|||||||
@ -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
|
from __future__ import annotations
|
||||||
|
|
||||||
import contextlib
|
|
||||||
import logging
|
|
||||||
import shutil
|
|
||||||
import subprocess
|
|
||||||
import tkinter as tk
|
import tkinter as tk
|
||||||
|
|
||||||
_logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class WindowSetupMixin:
|
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:
|
def on_focus_ready(self) -> None:
|
||||||
"""Disable VT switching in X11 while the lock is active.
|
"""No typed-input field in the lock window; nothing to focus."""
|
||||||
|
|
||||||
Prevents bypassing the lock by switching to a TTY with Ctrl+Alt+Fn.
|
def on_callback_error(self) -> None:
|
||||||
Best-effort: silently ignored if setxkbmap is unavailable.
|
"""Surfaced via GateRoot's logging already; no extra action yet."""
|
||||||
"""
|
|
||||||
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 _restore_vt_switching(self) -> None:
|
def on_close(self) -> None:
|
||||||
"""Restore VT switching after the lock is dismissed."""
|
"""No extra hardware/state beyond what close() already handles."""
|
||||||
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 _setup_verify_window(self) -> None:
|
def _setup_verify_window(self) -> None:
|
||||||
"""Configure window for post-sick-day workout verification."""
|
"""Configure window for post-sick-day workout verification."""
|
||||||
@ -69,18 +48,3 @@ class WindowSetupMixin:
|
|||||||
self.root.geometry("700x450")
|
self.root.geometry("700x450")
|
||||||
self.root.configure(bg="#1a1a1a", cursor="arrow")
|
self.root.configure(bg="#1a1a1a", cursor="arrow")
|
||||||
self.root.protocol("WM_DELETE_WINDOW", self.close)
|
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()
|
|
||||||
|
|||||||
@ -14,6 +14,9 @@ import sys
|
|||||||
import tkinter as tk
|
import tkinter as tk
|
||||||
from typing import TYPE_CHECKING
|
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 import _sick_tracker
|
||||||
from screen_locker._constants import (
|
from screen_locker._constants import (
|
||||||
EARLY_BIRD_END_HOUR,
|
EARLY_BIRD_END_HOUR,
|
||||||
@ -26,14 +29,8 @@ from screen_locker._constants import (
|
|||||||
PHONE_PENALTY_DELAY_PRODUCTION,
|
PHONE_PENALTY_DELAY_PRODUCTION,
|
||||||
SCHEDULED_SKIPS_FILE,
|
SCHEDULED_SKIPS_FILE,
|
||||||
SICK_LOCKOUT_SECONDS,
|
SICK_LOCKOUT_SECONDS,
|
||||||
STRONGLIFTS_DB_REMOTE,
|
|
||||||
)
|
)
|
||||||
from screen_locker._early_bird import EarlyBirdMixin
|
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._phone_verification import PhoneVerificationMixin
|
||||||
from screen_locker._shutdown import ShutdownMixin
|
from screen_locker._shutdown import ShutdownMixin
|
||||||
from screen_locker._sick_dialog import SickDialogMixin
|
from screen_locker._sick_dialog import SickDialogMixin
|
||||||
@ -62,7 +59,6 @@ __all__ = [
|
|||||||
"PHONE_PENALTY_DELAY_PRODUCTION",
|
"PHONE_PENALTY_DELAY_PRODUCTION",
|
||||||
"SCHEDULED_SKIPS_FILE",
|
"SCHEDULED_SKIPS_FILE",
|
||||||
"SICK_LOCKOUT_SECONDS",
|
"SICK_LOCKOUT_SECONDS",
|
||||||
"STRONGLIFTS_DB_REMOTE",
|
|
||||||
"WEEKLY_WORKOUT_MINIMUM",
|
"WEEKLY_WORKOUT_MINIMUM",
|
||||||
"ScreenLocker",
|
"ScreenLocker",
|
||||||
]
|
]
|
||||||
@ -111,19 +107,27 @@ class ScreenLocker(
|
|||||||
self.workout_data: dict[str, str] = {}
|
self.workout_data: dict[str, str] = {}
|
||||||
self._relaxed_day_mode: bool = False
|
self._relaxed_day_mode: bool = False
|
||||||
self._check_early_exits(verify_only=verify_only)
|
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 = (
|
title_suffix = (
|
||||||
" [VERIFY]" if verify_only else (" [DEMO MODE]" if demo_mode else "")
|
" [VERIFY]" if verify_only else (" [DEMO MODE]" if demo_mode else "")
|
||||||
)
|
)
|
||||||
self.root.title("Workout Locker" + title_suffix)
|
self.root.title("Workout Locker" + title_suffix)
|
||||||
self.demo_mode = demo_mode
|
self.demo_mode = demo_mode
|
||||||
self.lockout_time = 10 if demo_mode else 1800
|
self.lockout_time = 10 if demo_mode else 1800
|
||||||
|
self._lock: LockWindow | None = None
|
||||||
if verify_only:
|
if verify_only:
|
||||||
self._setup_verify_window()
|
self._setup_verify_window()
|
||||||
elif self._relaxed_day_mode:
|
elif self._relaxed_day_mode:
|
||||||
self._setup_relaxed_day_window()
|
self._setup_relaxed_day_window()
|
||||||
else:
|
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:
|
if demo_mode:
|
||||||
self._setup_demo_close_button()
|
self._setup_demo_close_button()
|
||||||
self.container = tk.Frame(self.root, bg="#1a1a1a")
|
self.container = tk.Frame(self.root, bg="#1a1a1a")
|
||||||
@ -135,7 +139,10 @@ class ScreenLocker(
|
|||||||
self._start_relaxed_day_flow()
|
self._start_relaxed_day_flow()
|
||||||
else:
|
else:
|
||||||
self._start_phone_check()
|
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:
|
def _is_sick_day_log(self) -> bool:
|
||||||
"""Check if today's workout log is a sick day (not yet verified)."""
|
"""Check if today's workout log is a sick day (not yet verified)."""
|
||||||
@ -304,7 +311,7 @@ class ScreenLocker(
|
|||||||
return False
|
return False
|
||||||
if verify_entry_hmac(entry):
|
if verify_entry_hmac(entry):
|
||||||
return entry.get("workout_data", {}).get("type") != "early_bird"
|
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(
|
_logger.info(
|
||||||
"HMAC key unavailable — accepting unsigned entry",
|
"HMAC key unavailable — accepting unsigned entry",
|
||||||
)
|
)
|
||||||
@ -358,14 +365,18 @@ class ScreenLocker(
|
|||||||
|
|
||||||
def close(self) -> None:
|
def close(self) -> None:
|
||||||
"""Close the application and exit."""
|
"""Close the application and exit."""
|
||||||
if not self.demo_mode:
|
if self._lock is not None:
|
||||||
self._restore_vt_switching()
|
self._lock.close()
|
||||||
self.root.destroy()
|
else:
|
||||||
|
self.root.destroy()
|
||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
|
|
||||||
def run(self) -> None:
|
def run(self) -> None:
|
||||||
"""Start the Tkinter main event loop."""
|
"""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__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
Safety:
|
Safety:
|
||||||
``_block_real_tk_and_exit`` (autouse) replaces the **entire** ``tk``
|
``_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
|
``sys.exit``. This makes it physically impossible for any test to
|
||||||
create a real Tk root window, go fullscreen, or grab input — even if
|
create a real Tk root window, go fullscreen, or grab input — even if
|
||||||
the test forgets to request the explicit ``mock_tk`` fixture.
|
the test forgets to request the explicit ``mock_tk`` fixture.
|
||||||
@ -10,6 +11,7 @@ Safety:
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from contextlib import ExitStack
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import tkinter as tk
|
import tkinter as tk
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
@ -24,6 +26,20 @@ if TYPE_CHECKING:
|
|||||||
from typing import Literal
|
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:
|
def _make_mock_tk() -> MagicMock:
|
||||||
"""Build a MagicMock that stands in for the ``tkinter`` module."""
|
"""Build a MagicMock that stands in for the ``tkinter`` module."""
|
||||||
mock = MagicMock()
|
mock = MagicMock()
|
||||||
@ -50,11 +66,16 @@ def _block_real_tk_and_exit() -> Iterator[None]:
|
|||||||
"""
|
"""
|
||||||
mock = _make_mock_tk()
|
mock = _make_mock_tk()
|
||||||
|
|
||||||
with (
|
with ExitStack() as stack:
|
||||||
patch("screen_locker.screen_lock.tk", mock),
|
for module in _TK_MODULES:
|
||||||
patch("screen_locker._sick_dialog.tk", mock),
|
stack.enter_context(patch(f"{module}.tk", mock))
|
||||||
patch("screen_locker.screen_lock.sys.exit"),
|
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
|
yield
|
||||||
|
|
||||||
|
|
||||||
@ -69,15 +90,29 @@ def mock_subprocess_run() -> Generator[MagicMock]:
|
|||||||
regardless of whether setxkbmap is installed on the host machine.
|
regardless of whether setxkbmap is installed on the host machine.
|
||||||
"""
|
"""
|
||||||
with (
|
with (
|
||||||
patch(
|
patch(f"{_VT_SHUTIL}.which", return_value="/usr/bin/setxkbmap"),
|
||||||
"screen_locker._window_setup.shutil.which",
|
patch(f"{_VT_SUBPROCESS}.run") as mock,
|
||||||
return_value="/usr/bin/setxkbmap",
|
|
||||||
),
|
|
||||||
patch("screen_locker._window_setup.subprocess.run") as mock,
|
|
||||||
):
|
):
|
||||||
yield 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)
|
@pytest.fixture(autouse=True)
|
||||||
def _isolate_sick_history(tmp_path: Path) -> Iterator[None]:
|
def _isolate_sick_history(tmp_path: Path) -> Iterator[None]:
|
||||||
"""Redirect SICK_HISTORY_FILE to tmp_path so tests cannot touch real state."""
|
"""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
|
@pytest.fixture
|
||||||
def mock_tk() -> Generator[MagicMock]:
|
def mock_tk() -> Generator[MagicMock]:
|
||||||
"""Mock tkinter module for testing without display."""
|
"""Mock the ``tkinter`` module across every UI module for display-free tests.
|
||||||
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
|
|
||||||
|
|
||||||
|
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
|
yield mock
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -175,7 +175,7 @@ class TestHasLoggedToday:
|
|||||||
return_value=False,
|
return_value=False,
|
||||||
),
|
),
|
||||||
patch(
|
patch(
|
||||||
"screen_locker.screen_lock._load_hmac_key",
|
"screen_locker.screen_lock.compute_entry_hmac",
|
||||||
return_value=None,
|
return_value=None,
|
||||||
),
|
),
|
||||||
):
|
):
|
||||||
@ -202,8 +202,8 @@ class TestHasLoggedToday:
|
|||||||
return_value=False,
|
return_value=False,
|
||||||
),
|
),
|
||||||
patch(
|
patch(
|
||||||
"screen_locker.screen_lock._load_hmac_key",
|
"screen_locker.screen_lock.compute_entry_hmac",
|
||||||
return_value=b"secret-key",
|
return_value="some-signature",
|
||||||
),
|
),
|
||||||
):
|
):
|
||||||
assert locker.has_logged_today() is False
|
assert locker.has_logged_today() is False
|
||||||
|
|||||||
@ -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
|
|
||||||
@ -161,6 +161,27 @@ class TestVerifyOnlyInit:
|
|||||||
)
|
)
|
||||||
locker.root.title.assert_called_with("Workout Locker [VERIFY]")
|
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:
|
class TestSetupVerifyWindow:
|
||||||
"""Tests for _setup_verify_window."""
|
"""Tests for _setup_verify_window."""
|
||||||
|
|||||||
@ -109,7 +109,7 @@ class TestVTSwitching:
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""No crash and no subprocess call when setxkbmap is not installed."""
|
"""No crash and no subprocess call when setxkbmap is not installed."""
|
||||||
with patch(
|
with patch(
|
||||||
"screen_locker._window_setup.shutil.which",
|
"gatelock._vt.shutil.which",
|
||||||
return_value=None,
|
return_value=None,
|
||||||
):
|
):
|
||||||
create_locker(mock_tk, tmp_path, demo_mode=False)
|
create_locker(mock_tk, tmp_path, demo_mode=False)
|
||||||
@ -128,7 +128,7 @@ class TestVTSwitching:
|
|||||||
mock_subprocess_run.reset_mock()
|
mock_subprocess_run.reset_mock()
|
||||||
|
|
||||||
with patch(
|
with patch(
|
||||||
"screen_locker._window_setup.shutil.which",
|
"gatelock._vt.shutil.which",
|
||||||
return_value=None,
|
return_value=None,
|
||||||
):
|
):
|
||||||
locker.close()
|
locker.close()
|
||||||
|
|||||||
@ -82,7 +82,7 @@ class TestRelaxedDayBranch:
|
|||||||
"screen_locker.screen_lock.has_weekly_minimum",
|
"screen_locker.screen_lock.has_weekly_minimum",
|
||||||
return_value=False,
|
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, "_setup_relaxed_day_window") as mock_small,
|
||||||
patch.object(ScreenLocker, "_start_phone_check"),
|
patch.object(ScreenLocker, "_start_phone_check"),
|
||||||
patch.object(ScreenLocker, "_start_relaxed_day_flow"),
|
patch.object(ScreenLocker, "_start_relaxed_day_flow"),
|
||||||
@ -91,7 +91,7 @@ class TestRelaxedDayBranch:
|
|||||||
ScreenLocker(demo_mode=True)
|
ScreenLocker(demo_mode=True)
|
||||||
|
|
||||||
mock_small.assert_called_once()
|
mock_small.assert_called_once()
|
||||||
mock_full.assert_not_called()
|
mock_lock_window.assert_not_called()
|
||||||
|
|
||||||
def test_relaxed_day_no_grab_input(
|
def test_relaxed_day_no_grab_input(
|
||||||
self,
|
self,
|
||||||
@ -116,14 +116,14 @@ class TestRelaxedDayBranch:
|
|||||||
"screen_locker.screen_lock.has_weekly_minimum",
|
"screen_locker.screen_lock.has_weekly_minimum",
|
||||||
return_value=False,
|
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_phone_check"),
|
||||||
patch.object(ScreenLocker, "_start_relaxed_day_flow"),
|
patch.object(ScreenLocker, "_start_relaxed_day_flow"),
|
||||||
patch.object(ScreenLocker, "_start_verify_workout_check"),
|
patch.object(ScreenLocker, "_start_verify_workout_check"),
|
||||||
):
|
):
|
||||||
ScreenLocker(demo_mode=True)
|
ScreenLocker(demo_mode=True)
|
||||||
|
|
||||||
mock_grab.assert_not_called()
|
mock_lock_window.assert_not_called()
|
||||||
|
|
||||||
def test_has_logged_today_exits_before_relaxed_check(
|
def test_has_logged_today_exits_before_relaxed_check(
|
||||||
self,
|
self,
|
||||||
@ -217,7 +217,7 @@ class TestStartRelaxedDayFlow:
|
|||||||
locker = self._make_locker(mock_tk, tmp_path)
|
locker = self._make_locker(mock_tk, tmp_path)
|
||||||
with (
|
with (
|
||||||
patch(
|
patch(
|
||||||
"screen_locker._ui_flows.count_weekly_workouts",
|
"screen_locker._ui_flows_relaxed.count_weekly_workouts",
|
||||||
return_value=2,
|
return_value=2,
|
||||||
),
|
),
|
||||||
patch.object(locker, "_text") as mock_text,
|
patch.object(locker, "_text") as mock_text,
|
||||||
@ -241,7 +241,7 @@ class TestStartRelaxedDayFlow:
|
|||||||
locker = self._make_locker(mock_tk, tmp_path)
|
locker = self._make_locker(mock_tk, tmp_path)
|
||||||
with (
|
with (
|
||||||
patch(
|
patch(
|
||||||
"screen_locker._ui_flows.count_weekly_workouts",
|
"screen_locker._ui_flows_relaxed.count_weekly_workouts",
|
||||||
return_value=0,
|
return_value=0,
|
||||||
),
|
),
|
||||||
patch.object(locker, "_button") as mock_button,
|
patch.object(locker, "_button") as mock_button,
|
||||||
@ -268,7 +268,7 @@ class TestStartRelaxedDayFlow:
|
|||||||
locker = self._make_locker(mock_tk, tmp_path)
|
locker = self._make_locker(mock_tk, tmp_path)
|
||||||
with (
|
with (
|
||||||
patch(
|
patch(
|
||||||
"screen_locker._ui_flows.count_weekly_workouts",
|
"screen_locker._ui_flows_relaxed.count_weekly_workouts",
|
||||||
return_value=1,
|
return_value=1,
|
||||||
),
|
),
|
||||||
patch.object(locker, "_button") as mock_button,
|
patch.object(locker, "_button") as mock_button,
|
||||||
@ -347,3 +347,44 @@ class TestStartRelaxedPhoneCheck:
|
|||||||
with patch.object(locker, "_handle_relaxed_phone_result") as mock_handle:
|
with patch.object(locker, "_handle_relaxed_phone_result") as mock_handle:
|
||||||
locker._poll_relaxed_phone_check()
|
locker._poll_relaxed_phone_check()
|
||||||
mock_handle.assert_not_called()
|
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
|
||||||
|
|||||||
@ -2,11 +2,15 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from pathlib import Path
|
from typing import TYPE_CHECKING
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
from screen_locker.screen_lock import ScreenLocker
|
from screen_locker.tests.conftest import create_locker, create_locker_relaxed_day
|
||||||
from screen_locker.tests.conftest import create_locker
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from screen_locker.screen_lock import ScreenLocker
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# _check_today_state_exits: return True/False branches
|
# _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):
|
with patch.object(locker, "_is_scheduled_skip_today", return_value=True):
|
||||||
locker._check_non_verify_exits()
|
locker._check_non_verify_exits()
|
||||||
mock_sys_exit.assert_called_once_with(0)
|
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()
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user