mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-07-04 15:43:06 +02:00
chore: remove screen_locker — extracted to own repo
Moved to https://github.com/kuhyx/screen-locker with full git history, vendored dependencies (shared.log_integrity, wake_alarm constants + has_workout_skip_today), 392 tests at 100% branch coverage. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
acfb1c48a0
commit
a29e9fb7bd
24
CLAUDE.md
24
CLAUDE.md
@ -3,11 +3,12 @@
|
|||||||
## Project Overview
|
## Project Overview
|
||||||
|
|
||||||
A mixed-language monorepo containing Python packages, Bash scripts, and misc automation. Actively-developed
|
A mixed-language monorepo containing Python packages, Bash scripts, and misc automation. Actively-developed
|
||||||
components span personal productivity tools: alarm/shutdown scheduling, screen locking, Linux system
|
components span personal productivity tools: alarm/shutdown scheduling, Linux system configuration, and
|
||||||
configuration, and Android phone focus enforcement.
|
Android phone focus enforcement.
|
||||||
|
|
||||||
Steam backlog enforcer has been extracted to its own repo:
|
Extracted to their own repos:
|
||||||
[`steam-backlog-enforcer`](https://github.com/kuhyx/steam-backlog-enforcer).
|
- [`steam-backlog-enforcer`](https://github.com/kuhyx/steam-backlog-enforcer)
|
||||||
|
- [`screen-locker`](https://github.com/kuhyx/screen-locker)
|
||||||
|
|
||||||
Archived / unmaintained projects live in the sibling repository
|
Archived / unmaintained projects live in the sibling repository
|
||||||
[`testsAndMisc-archive`](https://github.com/kuhyx/testsAndMisc-archive).
|
[`testsAndMisc-archive`](https://github.com/kuhyx/testsAndMisc-archive).
|
||||||
@ -31,17 +32,6 @@ Archived / unmaintained projects live in the sibling repository
|
|||||||
|
|
||||||
### Python Packages (`python_pkg/`)
|
### Python Packages (`python_pkg/`)
|
||||||
|
|
||||||
- **screen_locker/** — Tkinter/systemd screen locker with workout tracking and sick-day management
|
|
||||||
- `screen_lock.py` — main locker UI
|
|
||||||
- `_early_bird.py` — early-bird workout check
|
|
||||||
- `_sick_tracker.py` — sick-day tracker
|
|
||||||
- `_shutdown.py` — scheduled shutdown integration
|
|
||||||
- `_phone_verification.py` — phone check integration
|
|
||||||
- `_ui_flows.py` / `_window_setup.py` — UI helpers
|
|
||||||
- `_time_check.py` — time/schedule checks
|
|
||||||
- `_log_integrity.py` — tamper-evident workout logs
|
|
||||||
- `tests/` — 100% branch coverage enforced (300+ tests)
|
|
||||||
|
|
||||||
- **wake_alarm/** — Alarm + fan ramp + Tapo P110 smart plug control
|
- **wake_alarm/** — Alarm + fan ramp + Tapo P110 smart plug control
|
||||||
- `_alarm.py` — alarm logic
|
- `_alarm.py` — alarm logic
|
||||||
- `_smart_plug.py` — Tapo P110 control
|
- `_smart_plug.py` — Tapo P110 control
|
||||||
@ -58,9 +48,6 @@ Archived / unmaintained projects live in the sibling repository
|
|||||||
- `network_query.py` / `usb_query.py` — device discovery
|
- `network_query.py` / `usb_query.py` — device discovery
|
||||||
- `tests/` — pytest tests
|
- `tests/` — pytest tests
|
||||||
|
|
||||||
- **screen_locker** and **wake_alarm** share the `midnight_shutdown` integration:
|
|
||||||
on alarm nights the system hibernates instead of powering off.
|
|
||||||
|
|
||||||
- **shared/** — Shared utilities across python_pkg subpackages
|
- **shared/** — Shared utilities across python_pkg subpackages
|
||||||
- **random_jpg/** — Random JPEG downloader utility
|
- **random_jpg/** — Random JPEG downloader utility
|
||||||
- **geo_cache/** — Geographic coordinate cache helper
|
- **geo_cache/** — Geographic coordinate cache helper
|
||||||
@ -148,7 +135,6 @@ before committing. The `ai-evidence-contract` hook will reject commits without i
|
|||||||
python -m pytest python_pkg/ --cov=python_pkg --cov-branch --cov-fail-under=100
|
python -m pytest python_pkg/ --cov=python_pkg --cov-branch --cov-fail-under=100
|
||||||
|
|
||||||
# Run a single package
|
# Run a single package
|
||||||
python -m pytest python_pkg/screen_locker/ --cov=python_pkg.screen_locker --cov-branch --cov-fail-under=100
|
|
||||||
python -m pytest python_pkg/wake_alarm/ --cov=python_pkg.wake_alarm --cov-branch --cov-fail-under=100
|
python -m pytest python_pkg/wake_alarm/ --cov=python_pkg.wake_alarm --cov-branch --cov-fail-under=100
|
||||||
python -m pytest python_pkg/brother_printer/ --cov=python_pkg.brother_printer --cov-branch --cov-fail-under=100
|
python -m pytest python_pkg/brother_printer/ --cov=python_pkg.brother_printer --cov-branch --cov-fail-under=100
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"intent": "Remove screen_locker from monorepo after extracting it to its own GitHub repo.",
|
||||||
|
"scope": [
|
||||||
|
"python_pkg/screen_locker/ — deleted",
|
||||||
|
"CLAUDE.md — removed screen_locker sections, added link to new repo",
|
||||||
|
"No other packages affected"
|
||||||
|
],
|
||||||
|
"changes": [
|
||||||
|
"Deleted python_pkg/screen_locker/ (extracted to github.com/kuhyx/screen-locker with 43 commits)",
|
||||||
|
"Cross-package deps vendored: shared.log_integrity inlined, wake_alarm constants copied, has_workout_skip_today extracted to _wake_state.py",
|
||||||
|
"Updated CLAUDE.md: removed screen_locker section, removed its test command"
|
||||||
|
],
|
||||||
|
"verification": [
|
||||||
|
{
|
||||||
|
"command": "python -m pytest python_pkg/ --cov=python_pkg --cov-branch --cov-fail-under=100 -q",
|
||||||
|
"result": "pass",
|
||||||
|
"evidence": "523 passed, 100% branch coverage across all remaining packages (wake_alarm, brother_printer, wake_alarm, shared)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"risks": [
|
||||||
|
"screen_locker._wake_state reads wake_alarm's wake_state.json at a relative path — if wake_alarm moves, update WAKE_STATE_FILE in screen_locker/_constants.py"
|
||||||
|
],
|
||||||
|
"rollback": [
|
||||||
|
"Clone github.com/kuhyx/screen-locker, reverse the import rewrites (screen_locker.* → python_pkg.screen_locker.*), restore under python_pkg/"
|
||||||
|
]
|
||||||
|
}
|
||||||
@ -1 +0,0 @@
|
|||||||
"""Screen locker module."""
|
|
||||||
@ -1,50 +0,0 @@
|
|||||||
"""Constants for the screen locker module."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
SICK_LOCKOUT_SECONDS = 120 # base 2 minutes wait when sick (escalates with usage)
|
|
||||||
PHONE_PENALTY_DELAY_DEMO = 10
|
|
||||||
PHONE_PENALTY_DELAY_PRODUCTION = 100
|
|
||||||
# Penalty added to phone-penalty timer when ADB / phone unavailable
|
|
||||||
# (so unplugging phone does not become an easy escape into sick mode).
|
|
||||||
NO_PHONE_EXTRA_LOCKOUT_SECONDS = 480 # extra 8 minutes on top of base
|
|
||||||
# Sick day rate-limiting (rolling windows). Once any window is exhausted
|
|
||||||
# the "I'm sick" button disappears entirely.
|
|
||||||
SICK_BUDGET_PER_7_DAYS = 1
|
|
||||||
SICK_BUDGET_PER_30_DAYS = 3
|
|
||||||
SICK_BUDGET_PER_90_DAYS = 10
|
|
||||||
# Each sick day in the trailing 30 days doubles the wait countdown.
|
|
||||||
SICK_LOCKOUT_MULTIPLIER_PER_RECENT = 2
|
|
||||||
# Minimum chars in the freeform sick justification.
|
|
||||||
SICK_JUSTIFICATION_MIN_CHARS = 120
|
|
||||||
# How many past sick justifications to show on the dialog (read-only).
|
|
||||||
SICK_HISTORY_REVIEW_COUNT = 10
|
|
||||||
# Forced read-only delay before SUBMIT enables when a commitment was made.
|
|
||||||
SICK_COMMITMENT_FORCED_READ_SECONDS = 5
|
|
||||||
# Breaking a commitment counts as this many sick budget days.
|
|
||||||
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"
|
|
||||||
)
|
|
||||||
MIN_WORKOUT_DURATION_MINUTES = 50
|
|
||||||
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
|
|
||||||
SICK_DAY_STATE_FILE = Path(__file__).resolve().parent / "sick_day_state.json"
|
|
||||||
# Persistent sick-day history (rate-limit, debt, commitments, justifications).
|
|
||||||
# Distinct from SICK_DAY_STATE_FILE which is a one-day shutdown-config snapshot.
|
|
||||||
SICK_HISTORY_FILE = Path(__file__).resolve().parent / "sick_history.json"
|
|
||||||
# JSON list of ISO date strings ("YYYY-MM-DD") for which the screen lock is skipped.
|
|
||||||
SCHEDULED_SKIPS_FILE = Path(__file__).resolve().parent / "scheduled_skips.json"
|
|
||||||
@ -1,72 +0,0 @@
|
|||||||
"""Early bird window detection and log helpers for ScreenLocker."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
|
|
||||||
from python_pkg.screen_locker._constants import (
|
|
||||||
EARLY_BIRD_END_HOUR,
|
|
||||||
EARLY_BIRD_END_MINUTE,
|
|
||||||
EARLY_BIRD_START_HOUR,
|
|
||||||
)
|
|
||||||
|
|
||||||
_logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class EarlyBirdMixin:
|
|
||||||
"""Mixin providing early-bird time window checks and log helpers."""
|
|
||||||
|
|
||||||
def _get_local_time_minutes(self) -> int:
|
|
||||||
"""Return current local time as minutes from midnight."""
|
|
||||||
now = datetime.now(tz=timezone.utc).astimezone()
|
|
||||||
return now.hour * 60 + now.minute
|
|
||||||
|
|
||||||
def _is_early_bird_time(self) -> bool:
|
|
||||||
"""Return True if current local time is in the early bird window."""
|
|
||||||
minutes = self._get_local_time_minutes()
|
|
||||||
start = EARLY_BIRD_START_HOUR * 60
|
|
||||||
end = EARLY_BIRD_END_HOUR * 60 + EARLY_BIRD_END_MINUTE
|
|
||||||
return start <= minutes < end
|
|
||||||
|
|
||||||
def _is_early_bird_log(self) -> bool:
|
|
||||||
"""Check if today's workout log entry is an early_bird provisional entry."""
|
|
||||||
if not self.log_file.exists():
|
|
||||||
return False
|
|
||||||
try:
|
|
||||||
with self.log_file.open() as f:
|
|
||||||
logs = json.load(f)
|
|
||||||
except (OSError, json.JSONDecodeError):
|
|
||||||
return False
|
|
||||||
today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d")
|
|
||||||
entry = logs.get(today)
|
|
||||||
if entry is None:
|
|
||||||
return False
|
|
||||||
return entry.get("workout_data", {}).get("type") == "early_bird"
|
|
||||||
|
|
||||||
def _save_early_bird_log(self) -> None:
|
|
||||||
"""Save an early_bird provisional entry to the workout log."""
|
|
||||||
self.workout_data = {"type": "early_bird"}
|
|
||||||
self.save_workout_log()
|
|
||||||
|
|
||||||
def _try_auto_upgrade_early_bird(self) -> bool:
|
|
||||||
"""Silently upgrade today's early_bird entry if phone shows a workout."""
|
|
||||||
try:
|
|
||||||
status, message = self._verify_phone_workout()
|
|
||||||
except (OSError, RuntimeError) as exc:
|
|
||||||
_logger.info("Early bird upgrade phone check failed: %s", exc)
|
|
||||||
return False
|
|
||||||
if status != "verified":
|
|
||||||
_logger.info(
|
|
||||||
"Early bird upgrade skipped (phone status=%s): %s",
|
|
||||||
status,
|
|
||||||
message,
|
|
||||||
)
|
|
||||||
return False
|
|
||||||
self.workout_data["type"] = "phone_verified"
|
|
||||||
self.workout_data["source"] = message
|
|
||||||
self.workout_data["after_early_bird"] = "true"
|
|
||||||
self._adjust_shutdown_time_later()
|
|
||||||
self.save_workout_log()
|
|
||||||
return True
|
|
||||||
@ -1,19 +0,0 @@
|
|||||||
"""HMAC-based integrity checking — re-exports from shared package."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from python_pkg.shared.log_integrity import (
|
|
||||||
HMAC_KEY_FILE,
|
|
||||||
_generate_hmac_key,
|
|
||||||
_load_hmac_key,
|
|
||||||
compute_entry_hmac,
|
|
||||||
verify_entry_hmac,
|
|
||||||
)
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"HMAC_KEY_FILE",
|
|
||||||
"_generate_hmac_key",
|
|
||||||
"_load_hmac_key",
|
|
||||||
"compute_entry_hmac",
|
|
||||||
"verify_entry_hmac",
|
|
||||||
]
|
|
||||||
@ -1,355 +0,0 @@
|
|||||||
"""Phone workout verification mixin using ADB and StrongLifts."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from concurrent.futures import ( # pylint: disable=no-name-in-module
|
|
||||||
ThreadPoolExecutor,
|
|
||||||
as_completed,
|
|
||||||
)
|
|
||||||
import contextlib
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
from pathlib import Path
|
|
||||||
import shutil
|
|
||||||
import socket
|
|
||||||
import sqlite3
|
|
||||||
import subprocess
|
|
||||||
import tempfile
|
|
||||||
import time
|
|
||||||
|
|
||||||
from python_pkg.screen_locker._constants import (
|
|
||||||
ADB_TIMEOUT,
|
|
||||||
MIN_WORKOUT_DURATION_MINUTES,
|
|
||||||
STRONGLIFTS_DB_REMOTE,
|
|
||||||
)
|
|
||||||
from python_pkg.screen_locker._time_check import check_clock_skew
|
|
||||||
|
|
||||||
_logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class PhoneVerificationMixin:
|
|
||||||
"""Mixin providing phone-based workout verification via ADB."""
|
|
||||||
|
|
||||||
def _run_adb(self, args: list[str]) -> tuple[bool, str]:
|
|
||||||
"""Run an ADB command and return success flag and stdout."""
|
|
||||||
adb = shutil.which("adb") or "adb"
|
|
||||||
# When multiple devices are connected (e.g. USB + wireless), pin to
|
|
||||||
# the wireless device's serial to avoid "more than one device" errors.
|
|
||||||
_discovery_cmds = {"devices", "connect", "disconnect", "kill-server"}
|
|
||||||
serial = (
|
|
||||||
self._get_wireless_serial()
|
|
||||||
if args and args[0] not in _discovery_cmds
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
serial_args = ["-s", serial] if serial else []
|
|
||||||
try:
|
|
||||||
result = subprocess.run(
|
|
||||||
[adb, *serial_args, *args],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=ADB_TIMEOUT,
|
|
||||||
check=False,
|
|
||||||
)
|
|
||||||
except (FileNotFoundError, OSError) as exc:
|
|
||||||
_logger.warning("ADB not available: %s", exc)
|
|
||||||
return False, ""
|
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
_logger.warning("ADB command timed out: %s", args)
|
|
||||||
return False, ""
|
|
||||||
return not result.returncode, result.stdout
|
|
||||||
|
|
||||||
def _adb_shell(
|
|
||||||
self,
|
|
||||||
command: str,
|
|
||||||
*,
|
|
||||||
root: bool = False,
|
|
||||||
) -> tuple[bool, str]:
|
|
||||||
"""Run a shell command on the connected Android device."""
|
|
||||||
if root:
|
|
||||||
return self._run_adb(["shell", "su", "-c", command])
|
|
||||||
return self._run_adb(["shell", command])
|
|
||||||
|
|
||||||
def _get_wireless_serial(self) -> str | None:
|
|
||||||
"""Return the serial (ip:port) of the first connected wireless ADB device.
|
|
||||||
|
|
||||||
Used to pin ADB commands to the wireless device when multiple devices
|
|
||||||
(e.g. USB cable + wireless debugging) are simultaneously connected.
|
|
||||||
"""
|
|
||||||
success, output = self._run_adb(["devices"])
|
|
||||||
if not success:
|
|
||||||
return None
|
|
||||||
for line in output.strip().split("\n")[1:]:
|
|
||||||
parts = line.split()
|
|
||||||
if parts and ":" in parts[0] and "device" in line and "offline" not in line:
|
|
||||||
return parts[0]
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _has_adb_device(self) -> bool:
|
|
||||||
"""Return True if adb devices shows at least one connected device."""
|
|
||||||
success, output = self._run_adb(["devices"])
|
|
||||||
if not success:
|
|
||||||
return False
|
|
||||||
lines = output.strip().split("\n")[1:]
|
|
||||||
return any("device" in line and "offline" not in line for line in lines)
|
|
||||||
|
|
||||||
def _try_adb_connect(self, address: str) -> bool:
|
|
||||||
"""Run adb connect to address. Returns True on success."""
|
|
||||||
_, output = self._run_adb(["connect", address])
|
|
||||||
lower = output.lower()
|
|
||||||
return "connected" in lower and "unable" not in lower and "failed" not in lower
|
|
||||||
|
|
||||||
def _get_local_subnet_prefix(self) -> str | None:
|
|
||||||
"""Detect the local /24 network prefix (e.g. '192.168.1')."""
|
|
||||||
with (
|
|
||||||
contextlib.suppress(OSError),
|
|
||||||
socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock,
|
|
||||||
):
|
|
||||||
sock.connect(("8.8.8.8", 80))
|
|
||||||
return ".".join(sock.getsockname()[0].split(".")[:3])
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _try_wireless_reconnect(self) -> bool:
|
|
||||||
"""Scan local /24 subnet on port 5555 and attempt ADB connect to phone."""
|
|
||||||
prefix = self._get_local_subnet_prefix()
|
|
||||||
if prefix is None:
|
|
||||||
_logger.info("Could not determine local subnet for wireless scan")
|
|
||||||
return False
|
|
||||||
|
|
||||||
def probe(i: int) -> bool:
|
|
||||||
ip = f"{prefix}.{i}"
|
|
||||||
with (
|
|
||||||
contextlib.suppress(OSError),
|
|
||||||
socket.create_connection((ip, 5555), timeout=0.5),
|
|
||||||
):
|
|
||||||
if self._try_adb_connect(f"{ip}:5555"):
|
|
||||||
return self._has_adb_device()
|
|
||||||
return False
|
|
||||||
|
|
||||||
_logger.info("Scanning %s.1-254:5555 for phone...", prefix)
|
|
||||||
with ThreadPoolExecutor(max_workers=64) as executor:
|
|
||||||
for future in as_completed(
|
|
||||||
executor.submit(probe, i) for i in range(1, 255)
|
|
||||||
):
|
|
||||||
if future.result():
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
def _is_phone_connected(self) -> bool:
|
|
||||||
"""Check if an Android device is connected via ADB.
|
|
||||||
|
|
||||||
If no device is visible, attempts wireless reconnection using the
|
|
||||||
stored phone IP/port config. USB-connected devices are detected
|
|
||||||
automatically by adb devices without any extra steps.
|
|
||||||
"""
|
|
||||||
if self._has_adb_device():
|
|
||||||
return True
|
|
||||||
_logger.info("No ADB device detected — attempting wireless reconnect...")
|
|
||||||
return self._try_wireless_reconnect()
|
|
||||||
|
|
||||||
def _pull_stronglifts_db(self) -> Path | None:
|
|
||||||
"""Pull StrongLifts database from phone to a local temp file.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Path to the local copy, or None on failure.
|
|
||||||
"""
|
|
||||||
tmp = Path(tempfile.gettempdir()) / "stronglifts_check.db"
|
|
||||||
success, _ = self._adb_shell(
|
|
||||||
f"cat '{STRONGLIFTS_DB_REMOTE}' > /sdcard/_sl_tmp.db",
|
|
||||||
root=True,
|
|
||||||
)
|
|
||||||
if not success:
|
|
||||||
return None
|
|
||||||
ok, _ = self._run_adb(["pull", "/sdcard/_sl_tmp.db", str(tmp)])
|
|
||||||
if not ok:
|
|
||||||
return None
|
|
||||||
return tmp
|
|
||||||
|
|
||||||
def _count_today_workouts(self, db_path: Path) -> int:
|
|
||||||
"""Count today's workouts in a local copy of StrongLifts DB.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
db_path: Path to the locally-pulled StrongLifts database.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Number of workouts started today (local time).
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
conn = sqlite3.connect(str(db_path))
|
|
||||||
try:
|
|
||||||
cursor = conn.execute(
|
|
||||||
"SELECT COUNT(*) FROM workouts "
|
|
||||||
"WHERE date(start / 1000, 'unixepoch', 'localtime') "
|
|
||||||
"= date('now', 'localtime')",
|
|
||||||
)
|
|
||||||
row = cursor.fetchone()
|
|
||||||
return int(row[0]) if row else 0
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
except (sqlite3.Error, ValueError, TypeError):
|
|
||||||
_logger.warning("Failed to query StrongLifts database")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
def _get_today_workout_duration_minutes(self, db_path: Path) -> float:
|
|
||||||
"""Get the total duration in minutes of today's workouts.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
db_path: Path to the locally-pulled StrongLifts database.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Total duration in minutes of all workouts started today.
|
|
||||||
Returns 0.0 on any error or if no workouts found.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
conn = sqlite3.connect(str(db_path))
|
|
||||||
try:
|
|
||||||
cursor = conn.execute(
|
|
||||||
"SELECT SUM((finish - start) / 1000.0 / 60.0) "
|
|
||||||
"FROM workouts "
|
|
||||||
"WHERE date(start / 1000, 'unixepoch', 'localtime') "
|
|
||||||
"= date('now', 'localtime') "
|
|
||||||
"AND finish > start",
|
|
||||||
)
|
|
||||||
row = cursor.fetchone()
|
|
||||||
return float(row[0]) if row and row[0] is not None else 0.0
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
except (sqlite3.Error, ValueError, TypeError):
|
|
||||||
_logger.warning("Failed to query workout duration")
|
|
||||||
return 0.0
|
|
||||||
|
|
||||||
def _get_today_exercise_count(self, db_path: Path) -> int:
|
|
||||||
"""Count distinct exercises in today's workouts.
|
|
||||||
|
|
||||||
Parses the JSON ``exercises`` column in the ``workouts`` table.
|
|
||||||
Each workout row stores its exercises as a JSON array, not in a
|
|
||||||
separate relational table.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
db_path: Path to the locally-pulled StrongLifts database.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Number of distinct exercises across today's workouts.
|
|
||||||
Returns 0 on any error.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
conn = sqlite3.connect(str(db_path))
|
|
||||||
try:
|
|
||||||
cursor = conn.execute(
|
|
||||||
"SELECT exercises FROM workouts "
|
|
||||||
"WHERE date(start / 1000, 'unixepoch', 'localtime') "
|
|
||||||
"= date('now', 'localtime')",
|
|
||||||
)
|
|
||||||
exercise_ids: set[str] = set()
|
|
||||||
for (exercises_json,) in cursor:
|
|
||||||
if not exercises_json:
|
|
||||||
continue
|
|
||||||
for ex in json.loads(exercises_json):
|
|
||||||
ex_id = ex.get("id") or ex.get("name", "")
|
|
||||||
if ex_id:
|
|
||||||
exercise_ids.add(ex_id)
|
|
||||||
return len(exercise_ids)
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
except (sqlite3.Error, ValueError, TypeError, json.JSONDecodeError):
|
|
||||||
_logger.warning("Failed to query exercise count")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
def _is_workout_finish_recent(self, db_path: Path) -> bool:
|
|
||||||
"""Check if the latest workout's finish time is recent.
|
|
||||||
|
|
||||||
A fresh workout should have finished within the last 24 hours.
|
|
||||||
This prevents using an old pre-prepared database dump while
|
|
||||||
still accepting workouts done earlier the same day.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
db_path: Path to the locally-pulled StrongLifts database.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if the latest finish time is within 24 hours of now.
|
|
||||||
"""
|
|
||||||
max_age_seconds = 24 * 3600 # accept same-day workouts
|
|
||||||
try:
|
|
||||||
conn = sqlite3.connect(str(db_path))
|
|
||||||
try:
|
|
||||||
cursor = conn.execute(
|
|
||||||
"SELECT MAX(finish) FROM workouts "
|
|
||||||
"WHERE date(start / 1000, 'unixepoch', 'localtime') "
|
|
||||||
"= date('now', 'localtime') "
|
|
||||||
"AND finish > start",
|
|
||||||
)
|
|
||||||
row = cursor.fetchone()
|
|
||||||
if not row or row[0] is None:
|
|
||||||
return False
|
|
||||||
finish_epoch = int(row[0]) / 1000.0
|
|
||||||
return (time.time() - finish_epoch) < max_age_seconds
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
except (sqlite3.Error, ValueError, TypeError):
|
|
||||||
_logger.warning("Failed to query workout finish time")
|
|
||||||
return False
|
|
||||||
|
|
||||||
def _validate_workout_db(
|
|
||||||
self,
|
|
||||||
local_db: Path,
|
|
||||||
) -> tuple[str, str] | None:
|
|
||||||
"""Validate workout database has a recent, real workout.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
A (status, message) tuple if validation fails, or None if OK.
|
|
||||||
"""
|
|
||||||
count = self._count_today_workouts(local_db)
|
|
||||||
if count <= 0:
|
|
||||||
return "not_verified", "No workout found on phone today"
|
|
||||||
if not self._is_workout_finish_recent(local_db):
|
|
||||||
return (
|
|
||||||
"stale",
|
|
||||||
"Workout finish time is too old. Did you actually work out today?",
|
|
||||||
)
|
|
||||||
exercise_count = self._get_today_exercise_count(local_db)
|
|
||||||
if exercise_count < 1:
|
|
||||||
return (
|
|
||||||
"no_exercises",
|
|
||||||
"No exercises found in today's workout. "
|
|
||||||
"Log actual exercises in StrongLifts!",
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _verify_phone_workout(self) -> tuple[str, str]:
|
|
||||||
"""Verify workout was recorded in StrongLifts on the phone.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Tuple of (status, message) where status is one of:
|
|
||||||
- "verified": Workout confirmed and >= minimum duration.
|
|
||||||
- "too_short": Workout found but shorter than minimum.
|
|
||||||
- "not_verified": Phone connected but no workout found.
|
|
||||||
- "no_phone": No phone connected via ADB.
|
|
||||||
- "error": Could not access StrongLifts database.
|
|
||||||
- "stale": Workout finish time is not recent.
|
|
||||||
- "no_exercises": Workout has no logged exercises.
|
|
||||||
- "clock_tampered": System clock skew exceeds threshold.
|
|
||||||
"""
|
|
||||||
clock_ok, clock_msg = check_clock_skew()
|
|
||||||
if not clock_ok:
|
|
||||||
return "clock_tampered", clock_msg
|
|
||||||
if not self._is_phone_connected():
|
|
||||||
return "no_phone", "No phone connected via ADB"
|
|
||||||
local_db = self._pull_stronglifts_db()
|
|
||||||
if local_db is None:
|
|
||||||
return "error", "StrongLifts database not found on phone"
|
|
||||||
db_error = self._validate_workout_db(local_db)
|
|
||||||
if db_error is not None:
|
|
||||||
return db_error
|
|
||||||
duration = self._get_today_workout_duration_minutes(local_db)
|
|
||||||
if duration < MIN_WORKOUT_DURATION_MINUTES:
|
|
||||||
return (
|
|
||||||
"too_short",
|
|
||||||
f"Workout too short! {duration:.0f} min logged, "
|
|
||||||
f"need at least {MIN_WORKOUT_DURATION_MINUTES} min.",
|
|
||||||
)
|
|
||||||
exercise_count = self._get_today_exercise_count(local_db)
|
|
||||||
return (
|
|
||||||
"verified",
|
|
||||||
f"Workout verified! ({self._count_today_workouts(local_db)}"
|
|
||||||
f" session(s), {duration:.0f} min, "
|
|
||||||
f"{exercise_count} exercise(s))",
|
|
||||||
)
|
|
||||||
@ -1,340 +0,0 @@
|
|||||||
"""Shutdown schedule adjustment mixin for the screen locker."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import calendar
|
|
||||||
from datetime import datetime, timedelta, timezone
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import subprocess
|
|
||||||
|
|
||||||
from python_pkg.screen_locker._constants import (
|
|
||||||
ADJUST_SHUTDOWN_SCRIPT,
|
|
||||||
SHUTDOWN_CONFIG_FILE,
|
|
||||||
SICK_DAY_STATE_FILE,
|
|
||||||
)
|
|
||||||
from python_pkg.wake_alarm._constants import (
|
|
||||||
ALARM_DAYS,
|
|
||||||
RTCWAKE_BIN,
|
|
||||||
WAKE_AFTER_HOURS,
|
|
||||||
)
|
|
||||||
|
|
||||||
_logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class ShutdownMixin:
|
|
||||||
"""Mixin providing shutdown schedule adjustment functionality."""
|
|
||||||
|
|
||||||
def _apply_earlier_shutdown(self, today: str) -> bool:
|
|
||||||
"""Read config, save state, and write earlier shutdown hours."""
|
|
||||||
config_values = self._read_shutdown_config()
|
|
||||||
if config_values is None:
|
|
||||||
return False
|
|
||||||
mon_wed_hour, thu_sun_hour, morning_end_hour = config_values
|
|
||||||
if not self._save_sick_day_state(today, mon_wed_hour, thu_sun_hour):
|
|
||||||
_logger.error("Failed to save state - aborting adjustment")
|
|
||||||
return False
|
|
||||||
new_mon_wed = max(18, mon_wed_hour - 1)
|
|
||||||
new_thu_sun = max(18, thu_sun_hour - 1)
|
|
||||||
return self._write_shutdown_config(
|
|
||||||
new_mon_wed,
|
|
||||||
new_thu_sun,
|
|
||||||
morning_end_hour,
|
|
||||||
)
|
|
||||||
|
|
||||||
def _adjust_shutdown_time_earlier(self) -> bool:
|
|
||||||
"""Adjust shutdown schedule 1.5 hours earlier (stricter).
|
|
||||||
|
|
||||||
This can only be used once per day. Original values are saved and
|
|
||||||
automatically restored when checked the next day.
|
|
||||||
|
|
||||||
Returns True if successful, False otherwise.
|
|
||||||
"""
|
|
||||||
today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d")
|
|
||||||
self._restore_original_config_if_needed()
|
|
||||||
if self._sick_mode_used_today():
|
|
||||||
_logger.warning("Sick mode already used today")
|
|
||||||
return False
|
|
||||||
try:
|
|
||||||
return self._apply_earlier_shutdown(today)
|
|
||||||
except (OSError, ValueError) as e:
|
|
||||||
_logger.warning("Failed to adjust shutdown time: %s", e)
|
|
||||||
return False
|
|
||||||
|
|
||||||
def _adjust_shutdown_time_later(self) -> bool:
|
|
||||||
"""Adjust shutdown schedule 2 hours later as workout reward.
|
|
||||||
|
|
||||||
Returns True if successful, False otherwise.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
config_values = self._read_shutdown_config()
|
|
||||||
if config_values is None:
|
|
||||||
return False
|
|
||||||
mon_wed_hour, thu_sun_hour, morning_end_hour = config_values
|
|
||||||
new_mon_wed = min(23, mon_wed_hour + 2)
|
|
||||||
new_thu_sun = min(23, thu_sun_hour + 2)
|
|
||||||
return self._write_shutdown_config(
|
|
||||||
new_mon_wed,
|
|
||||||
new_thu_sun,
|
|
||||||
morning_end_hour,
|
|
||||||
restore=True,
|
|
||||||
)
|
|
||||||
except (OSError, ValueError) as e:
|
|
||||||
_logger.warning("Failed to adjust shutdown time for workout: %s", e)
|
|
||||||
return False
|
|
||||||
|
|
||||||
def _sick_mode_used_today(self) -> bool:
|
|
||||||
"""Check if sick mode was already used today."""
|
|
||||||
if not SICK_DAY_STATE_FILE.exists():
|
|
||||||
return False
|
|
||||||
|
|
||||||
try:
|
|
||||||
with SICK_DAY_STATE_FILE.open() as f:
|
|
||||||
state = json.load(f)
|
|
||||||
today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d")
|
|
||||||
return state.get("date") == today
|
|
||||||
except (OSError, json.JSONDecodeError):
|
|
||||||
return False
|
|
||||||
|
|
||||||
def _save_sick_day_state(
|
|
||||||
self,
|
|
||||||
date: str,
|
|
||||||
orig_mon_wed: int,
|
|
||||||
orig_thu_sun: int,
|
|
||||||
) -> bool:
|
|
||||||
"""Save sick day state with original config values.
|
|
||||||
|
|
||||||
Returns True if saved successfully, False otherwise.
|
|
||||||
"""
|
|
||||||
state = {
|
|
||||||
"date": date,
|
|
||||||
"original_mon_wed_hour": orig_mon_wed,
|
|
||||||
"original_thu_sun_hour": orig_thu_sun,
|
|
||||||
}
|
|
||||||
try:
|
|
||||||
with SICK_DAY_STATE_FILE.open("w") as f:
|
|
||||||
json.dump(state, f, indent=2)
|
|
||||||
except OSError as e:
|
|
||||||
_logger.warning("Failed to save sick day state: %s", e)
|
|
||||||
return False
|
|
||||||
|
|
||||||
_logger.info("Saved sick day state for %s", date)
|
|
||||||
return True
|
|
||||||
|
|
||||||
def _load_sick_day_state(self) -> tuple[str, int, int] | None:
|
|
||||||
"""Load sick day state file.
|
|
||||||
|
|
||||||
Returns (date, orig_mon_wed_hour, orig_thu_sun_hour) or None.
|
|
||||||
"""
|
|
||||||
with SICK_DAY_STATE_FILE.open() as f:
|
|
||||||
state = json.load(f)
|
|
||||||
date = state.get("date")
|
|
||||||
orig_mw = state.get("original_mon_wed_hour")
|
|
||||||
orig_ts = state.get("original_thu_sun_hour")
|
|
||||||
if date is None or orig_mw is None or orig_ts is None:
|
|
||||||
return None
|
|
||||||
return (str(date), int(orig_mw), int(orig_ts))
|
|
||||||
|
|
||||||
def _write_restored_config(
|
|
||||||
self,
|
|
||||||
orig_mw: int,
|
|
||||||
orig_ts: int,
|
|
||||||
state_date: str,
|
|
||||||
) -> None:
|
|
||||||
"""Write restored config values and clean up state file."""
|
|
||||||
config_values = self._read_shutdown_config()
|
|
||||||
if config_values:
|
|
||||||
_, _, morning_end = config_values
|
|
||||||
_logger.info(
|
|
||||||
"Restoring original shutdown config from %s",
|
|
||||||
state_date,
|
|
||||||
)
|
|
||||||
self._write_shutdown_config(
|
|
||||||
orig_mw,
|
|
||||||
orig_ts,
|
|
||||||
morning_end,
|
|
||||||
restore=True,
|
|
||||||
)
|
|
||||||
SICK_DAY_STATE_FILE.unlink()
|
|
||||||
_logger.info("Removed stale sick day state from %s", state_date)
|
|
||||||
|
|
||||||
def _restore_original_config_if_needed(self) -> None:
|
|
||||||
"""Restore original config if sick day state is from a previous day."""
|
|
||||||
if not SICK_DAY_STATE_FILE.exists():
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
loaded = self._load_sick_day_state()
|
|
||||||
if loaded is None:
|
|
||||||
return
|
|
||||||
state_date, orig_mw, orig_ts = loaded
|
|
||||||
today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d")
|
|
||||||
if state_date != today:
|
|
||||||
self._write_restored_config(orig_mw, orig_ts, state_date)
|
|
||||||
except (OSError, json.JSONDecodeError) as e:
|
|
||||||
_logger.warning("Error checking sick day state: %s", e)
|
|
||||||
|
|
||||||
def _read_shutdown_config(self) -> tuple[int, int, int] | None:
|
|
||||||
"""Read shutdown config. Returns (mw_hour, ts_hour, me_hour) or None."""
|
|
||||||
if not SHUTDOWN_CONFIG_FILE.exists():
|
|
||||||
_logger.warning("Config not found: %s", SHUTDOWN_CONFIG_FILE)
|
|
||||||
return None
|
|
||||||
parsed: dict[str, int] = {}
|
|
||||||
keys = ("MON_WED_HOUR", "THU_SUN_HOUR", "MORNING_END_HOUR")
|
|
||||||
with SHUTDOWN_CONFIG_FILE.open() as f:
|
|
||||||
for line in f:
|
|
||||||
stripped = line.strip()
|
|
||||||
for key in keys:
|
|
||||||
if stripped.startswith(f"{key}="):
|
|
||||||
parsed[key] = int(stripped.split("=")[1])
|
|
||||||
if len(parsed) < len(keys):
|
|
||||||
_logger.warning("Shutdown config missing required values")
|
|
||||||
return None
|
|
||||||
return (
|
|
||||||
parsed["MON_WED_HOUR"],
|
|
||||||
parsed["THU_SUN_HOUR"],
|
|
||||||
parsed["MORNING_END_HOUR"],
|
|
||||||
)
|
|
||||||
|
|
||||||
def _build_shutdown_cmd(
|
|
||||||
self,
|
|
||||||
mon_wed: int,
|
|
||||||
thu_sun: int,
|
|
||||||
morning: int,
|
|
||||||
*,
|
|
||||||
restore: bool,
|
|
||||||
) -> list[str]:
|
|
||||||
"""Build the shutdown adjustment command."""
|
|
||||||
cmd = ["/usr/bin/sudo", str(ADJUST_SHUTDOWN_SCRIPT)]
|
|
||||||
if restore:
|
|
||||||
cmd.append("--restore")
|
|
||||||
cmd.extend([str(mon_wed), str(thu_sun), str(morning)])
|
|
||||||
return cmd
|
|
||||||
|
|
||||||
def _write_shutdown_config(
|
|
||||||
self,
|
|
||||||
mon_wed_hour: int,
|
|
||||||
thu_sun_hour: int,
|
|
||||||
morning_end_hour: int,
|
|
||||||
*,
|
|
||||||
restore: bool = False,
|
|
||||||
) -> bool:
|
|
||||||
"""Write new shutdown config values using helper script.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
mon_wed_hour: Shutdown hour for Monday-Wednesday.
|
|
||||||
thu_sun_hour: Shutdown hour for Thursday-Sunday.
|
|
||||||
morning_end_hour: Morning end hour.
|
|
||||||
restore: If True, allows restoring to later times.
|
|
||||||
|
|
||||||
Returns True if successful, False otherwise.
|
|
||||||
"""
|
|
||||||
if not ADJUST_SHUTDOWN_SCRIPT.exists():
|
|
||||||
_logger.warning(
|
|
||||||
"Script not found: %s",
|
|
||||||
ADJUST_SHUTDOWN_SCRIPT,
|
|
||||||
)
|
|
||||||
return False
|
|
||||||
cmd = self._build_shutdown_cmd(
|
|
||||||
mon_wed_hour,
|
|
||||||
thu_sun_hour,
|
|
||||||
morning_end_hour,
|
|
||||||
restore=restore,
|
|
||||||
)
|
|
||||||
return self._run_shutdown_cmd(cmd, mon_wed_hour, thu_sun_hour)
|
|
||||||
|
|
||||||
def _run_shutdown_cmd(
|
|
||||||
self,
|
|
||||||
cmd: list[str],
|
|
||||||
mon_wed_hour: int,
|
|
||||||
thu_sun_hour: int,
|
|
||||||
) -> bool:
|
|
||||||
"""Execute the shutdown adjustment command."""
|
|
||||||
try:
|
|
||||||
result = subprocess.run(
|
|
||||||
cmd,
|
|
||||||
check=True,
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
)
|
|
||||||
except subprocess.SubprocessError as e:
|
|
||||||
_logger.warning("Failed to adjust shutdown config: %s", e)
|
|
||||||
return False
|
|
||||||
_logger.info(
|
|
||||||
"Adjusted shutdown: Mon-Wed=%d, Thu-Sun=%d. %s",
|
|
||||||
mon_wed_hour,
|
|
||||||
thu_sun_hour,
|
|
||||||
result.stdout.strip(),
|
|
||||||
)
|
|
||||||
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 disk`` to hibernate immediately while programming
|
|
||||||
the RTC to restore power at wake_epoch. Hibernate is completely
|
|
||||||
silent and dark (state written to swap file), making it suitable
|
|
||||||
when the PC is in a bedroom.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if rtcwake was set successfully, False otherwise.
|
|
||||||
"""
|
|
||||||
wake_epoch = ShutdownMixin._compute_wake_timestamp()
|
|
||||||
cmd = [
|
|
||||||
"/usr/bin/sudo",
|
|
||||||
RTCWAKE_BIN,
|
|
||||||
"-m",
|
|
||||||
"disk",
|
|
||||||
"-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()
|
|
||||||
@ -1,292 +0,0 @@
|
|||||||
"""Sick-day justification + commitment dialog mixin for the screen locker."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import contextlib
|
|
||||||
import logging
|
|
||||||
import tkinter as tk
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
from python_pkg.screen_locker import _sick_tracker
|
|
||||||
from python_pkg.screen_locker._constants import (
|
|
||||||
COMMITMENT_PROMPT_TIMEOUT_SECONDS,
|
|
||||||
SICK_COMMITMENT_FORCED_READ_SECONDS,
|
|
||||||
SICK_JUSTIFICATION_MIN_CHARS,
|
|
||||||
)
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from collections.abc import Callable
|
|
||||||
|
|
||||||
from python_pkg.screen_locker._sick_tracker import SickHistory
|
|
||||||
|
|
||||||
_logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
def _disable_paste(widget: tk.Widget) -> None:
|
|
||||||
"""Disable paste in a Tk Entry/Text widget.
|
|
||||||
|
|
||||||
Friction-only: a determined user can still bypass via xdotool, but the
|
|
||||||
point is removing the trivial Ctrl+V shortcut so the user must
|
|
||||||
actually type their justification.
|
|
||||||
"""
|
|
||||||
for sequence in ("<<Paste>>", "<Control-v>", "<Control-V>", "<Button-2>"):
|
|
||||||
with contextlib.suppress(tk.TclError, AttributeError):
|
|
||||||
widget.bind(sequence, lambda _e: "break")
|
|
||||||
|
|
||||||
|
|
||||||
class SickDialogMixin:
|
|
||||||
"""Renders the sick-day justification screen and commitment prompts."""
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Sick-day justification dialog
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def _show_sick_justification(self) -> None:
|
|
||||||
"""Render the structured sick-day justification screen."""
|
|
||||||
history = _sick_tracker.load_history()
|
|
||||||
self._sick_history_cache: SickHistory = history
|
|
||||||
self.clear_container()
|
|
||||||
self._label("Sick Day Request", color="#cc6600", pady=10)
|
|
||||||
self._text(_sick_tracker.budget_summary(history), color="#ffaa00")
|
|
||||||
|
|
||||||
recent = _sick_tracker.format_recent_justifications(history)
|
|
||||||
if recent:
|
|
||||||
self._text("Recent sick days:", font_size=14, color="#888888", pady=5)
|
|
||||||
self._text(recent, font_size=14, color="#cccccc", pady=5)
|
|
||||||
|
|
||||||
had_commitment = _sick_tracker.had_commitment_for_today(history)
|
|
||||||
if had_commitment:
|
|
||||||
self._text(
|
|
||||||
"⚠ Yesterday you committed to working out today.",
|
|
||||||
font_size=18,
|
|
||||||
color="#ff6666",
|
|
||||||
)
|
|
||||||
self._text(
|
|
||||||
"Breaking the commitment costs 2 sick-budget days.",
|
|
||||||
font_size=14,
|
|
||||||
color="#ff6666",
|
|
||||||
)
|
|
||||||
|
|
||||||
self._build_justification_form(had_commitment=had_commitment)
|
|
||||||
|
|
||||||
def _build_justification_form(self, *, had_commitment: bool) -> None:
|
|
||||||
"""Add justification form fields and submit button to the container."""
|
|
||||||
form = tk.Frame(self.container, bg="#1a1a1a")
|
|
||||||
form.pack(pady=10)
|
|
||||||
|
|
||||||
self._sick_symptom_var = tk.StringVar()
|
|
||||||
self._sick_onset_var = tk.StringVar()
|
|
||||||
self._sick_severity_var = tk.IntVar(value=5)
|
|
||||||
self._sick_text_widget = self._add_form_widgets(form)
|
|
||||||
|
|
||||||
self._sick_error_label = self._text("", color="#ff4444", pady=5)
|
|
||||||
|
|
||||||
button_row = self._button_row()
|
|
||||||
self._sick_submit_button = self._button(
|
|
||||||
button_row,
|
|
||||||
"SUBMIT",
|
|
||||||
bg="#666666",
|
|
||||||
command=self._submit_sick_justification,
|
|
||||||
width=12,
|
|
||||||
)
|
|
||||||
self._sick_submit_button.pack(side="left", padx=10)
|
|
||||||
self._button(
|
|
||||||
button_row,
|
|
||||||
"BACK",
|
|
||||||
bg="#aa0000",
|
|
||||||
command=self._start_phone_check,
|
|
||||||
width=12,
|
|
||||||
).pack(side="left", padx=10)
|
|
||||||
|
|
||||||
if had_commitment:
|
|
||||||
self._sick_submit_button.config(state="disabled")
|
|
||||||
self._commitment_forced_remaining = SICK_COMMITMENT_FORCED_READ_SECONDS
|
|
||||||
self._update_commitment_forced_delay()
|
|
||||||
|
|
||||||
def _add_form_widgets(self, parent: tk.Widget) -> tk.Text:
|
|
||||||
"""Create symptom/onset/severity/text widgets. Returns the text widget."""
|
|
||||||
self._add_label_entry(
|
|
||||||
parent,
|
|
||||||
label="Symptom (e.g. fever, nausea):",
|
|
||||||
variable=self._sick_symptom_var,
|
|
||||||
)
|
|
||||||
self._add_label_entry(
|
|
||||||
parent,
|
|
||||||
label="When did it start? (e.g. last night):",
|
|
||||||
variable=self._sick_onset_var,
|
|
||||||
)
|
|
||||||
sev_row = tk.Frame(parent, bg="#1a1a1a")
|
|
||||||
sev_row.pack(pady=5)
|
|
||||||
tk.Label(
|
|
||||||
sev_row,
|
|
||||||
text="Severity (1-10):",
|
|
||||||
font=("Arial", 14),
|
|
||||||
fg="white",
|
|
||||||
bg="#1a1a1a",
|
|
||||||
).pack(side="left", padx=5)
|
|
||||||
tk.Spinbox(
|
|
||||||
sev_row,
|
|
||||||
from_=1,
|
|
||||||
to=10,
|
|
||||||
textvariable=self._sick_severity_var,
|
|
||||||
width=4,
|
|
||||||
font=("Arial", 14),
|
|
||||||
).pack(side="left", padx=5)
|
|
||||||
|
|
||||||
tk.Label(
|
|
||||||
parent,
|
|
||||||
text=(f"Describe how you feel (min {SICK_JUSTIFICATION_MIN_CHARS} chars):"),
|
|
||||||
font=("Arial", 14),
|
|
||||||
fg="white",
|
|
||||||
bg="#1a1a1a",
|
|
||||||
).pack(pady=5)
|
|
||||||
text_widget = tk.Text(
|
|
||||||
parent,
|
|
||||||
width=60,
|
|
||||||
height=6,
|
|
||||||
font=("Arial", 12),
|
|
||||||
bg="#2a2a2a",
|
|
||||||
fg="white",
|
|
||||||
insertbackground="white",
|
|
||||||
)
|
|
||||||
text_widget.pack(pady=5)
|
|
||||||
_disable_paste(text_widget)
|
|
||||||
return text_widget
|
|
||||||
|
|
||||||
def _add_label_entry(
|
|
||||||
self,
|
|
||||||
parent: tk.Widget,
|
|
||||||
*,
|
|
||||||
label: str,
|
|
||||||
variable: tk.StringVar,
|
|
||||||
) -> None:
|
|
||||||
"""Add a label + single-line entry pair, with paste disabled."""
|
|
||||||
row = tk.Frame(parent, bg="#1a1a1a")
|
|
||||||
row.pack(pady=5, fill="x")
|
|
||||||
tk.Label(
|
|
||||||
row,
|
|
||||||
text=label,
|
|
||||||
font=("Arial", 14),
|
|
||||||
fg="white",
|
|
||||||
bg="#1a1a1a",
|
|
||||||
anchor="w",
|
|
||||||
).pack(side="top", anchor="w")
|
|
||||||
entry = tk.Entry(
|
|
||||||
row,
|
|
||||||
textvariable=variable,
|
|
||||||
width=50,
|
|
||||||
font=("Arial", 14),
|
|
||||||
bg="#2a2a2a",
|
|
||||||
fg="white",
|
|
||||||
insertbackground="white",
|
|
||||||
)
|
|
||||||
entry.pack(side="top", anchor="w", pady=2)
|
|
||||||
_disable_paste(entry)
|
|
||||||
|
|
||||||
def _update_commitment_forced_delay(self) -> None:
|
|
||||||
"""Tick down the forced-read delay then enable the submit button."""
|
|
||||||
if self._commitment_forced_remaining > 0:
|
|
||||||
self._sick_submit_button.config(
|
|
||||||
text=f"WAIT {self._commitment_forced_remaining}s",
|
|
||||||
)
|
|
||||||
self._commitment_forced_remaining -= 1
|
|
||||||
self.root.after(1000, self._update_commitment_forced_delay)
|
|
||||||
else:
|
|
||||||
self._sick_submit_button.config(text="SUBMIT", state="normal")
|
|
||||||
|
|
||||||
def _submit_sick_justification(self) -> None:
|
|
||||||
"""Validate the form and either show an error or proceed to countdown."""
|
|
||||||
symptom = self._sick_symptom_var.get()
|
|
||||||
onset = self._sick_onset_var.get()
|
|
||||||
try:
|
|
||||||
severity = int(self._sick_severity_var.get())
|
|
||||||
except (tk.TclError, ValueError):
|
|
||||||
severity = 0
|
|
||||||
text = self._sick_text_widget.get("1.0", "end").strip()
|
|
||||||
draft = _sick_tracker.JustificationDraft(
|
|
||||||
symptom=symptom,
|
|
||||||
onset=onset,
|
|
||||||
severity=severity,
|
|
||||||
text=text,
|
|
||||||
)
|
|
||||||
error = _sick_tracker.validate_justification(draft)
|
|
||||||
if error is not None:
|
|
||||||
self._sick_error_label.config(text=error)
|
|
||||||
return
|
|
||||||
|
|
||||||
history = self._sick_history_cache
|
|
||||||
_sick_tracker.add_justification(history, draft)
|
|
||||||
if not _sick_tracker.save_history(history):
|
|
||||||
self._sick_error_label.config(
|
|
||||||
text="Could not persist sick history — try again",
|
|
||||||
)
|
|
||||||
return
|
|
||||||
self._proceed_to_sick_countdown()
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Commitment prompt (after a verified workout)
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def _show_commitment_prompt(self, *, on_done: Callable[[], None]) -> None:
|
|
||||||
"""Ask the user to commit to working out tomorrow.
|
|
||||||
|
|
||||||
Calls ``on_done()`` once the user answers or the timeout elapses.
|
|
||||||
"""
|
|
||||||
self.clear_container()
|
|
||||||
self._label(
|
|
||||||
"Commit to working out tomorrow?",
|
|
||||||
font_size=32,
|
|
||||||
color="#ffaa00",
|
|
||||||
pady=20,
|
|
||||||
)
|
|
||||||
self._text(
|
|
||||||
"If you say YES and skip via 'I'm sick' tomorrow, "
|
|
||||||
"the sick day costs 2x normal.",
|
|
||||||
font_size=16,
|
|
||||||
)
|
|
||||||
self._commitment_done_fn = on_done
|
|
||||||
self._commitment_remaining = COMMITMENT_PROMPT_TIMEOUT_SECONDS
|
|
||||||
self._commitment_timer_label = self._text(
|
|
||||||
f"Auto-skipping in {COMMITMENT_PROMPT_TIMEOUT_SECONDS}s",
|
|
||||||
color="#888888",
|
|
||||||
)
|
|
||||||
row = self._button_row()
|
|
||||||
self._button(
|
|
||||||
row,
|
|
||||||
"YES",
|
|
||||||
bg="#00aa00",
|
|
||||||
command=lambda: self._answer_commitment(commit=True),
|
|
||||||
width=12,
|
|
||||||
).pack(side="left", padx=10)
|
|
||||||
self._button(
|
|
||||||
row,
|
|
||||||
"NO",
|
|
||||||
bg="#aa0000",
|
|
||||||
command=lambda: self._answer_commitment(commit=False),
|
|
||||||
width=12,
|
|
||||||
).pack(side="left", padx=10)
|
|
||||||
self._tick_commitment_timeout()
|
|
||||||
|
|
||||||
def _tick_commitment_timeout(self) -> None:
|
|
||||||
"""Advance commitment auto-skip timer; default to NO when it expires."""
|
|
||||||
if self._commitment_remaining <= 0:
|
|
||||||
self._answer_commitment(commit=False)
|
|
||||||
return
|
|
||||||
self._commitment_timer_label.config(
|
|
||||||
text=f"Auto-skipping in {self._commitment_remaining}s",
|
|
||||||
)
|
|
||||||
self._commitment_remaining -= 1
|
|
||||||
self.root.after(1000, self._tick_commitment_timeout)
|
|
||||||
|
|
||||||
def _answer_commitment(self, *, commit: bool) -> None:
|
|
||||||
"""Persist the commitment answer and call the completion callback."""
|
|
||||||
# Disable timer re-entry by zeroing remaining.
|
|
||||||
self._commitment_remaining = -1
|
|
||||||
if commit:
|
|
||||||
history = _sick_tracker.load_history()
|
|
||||||
_sick_tracker.record_commitment_for_tomorrow(history)
|
|
||||||
_sick_tracker.save_history(history)
|
|
||||||
done = getattr(self, "_commitment_done_fn", None)
|
|
||||||
if done is not None:
|
|
||||||
self._commitment_done_fn = None
|
|
||||||
done()
|
|
||||||
@ -1,304 +0,0 @@
|
|||||||
"""Sick-day rate-limiting, workout debt, commitment, and justification tracking.
|
|
||||||
|
|
||||||
Pure logic — no Tk imports. The UI calls into these helpers and persists
|
|
||||||
state via :func:`save_history`.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from datetime import datetime, timedelta, timezone
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from python_pkg.screen_locker._constants import (
|
|
||||||
SICK_BUDGET_PER_7_DAYS,
|
|
||||||
SICK_BUDGET_PER_30_DAYS,
|
|
||||||
SICK_BUDGET_PER_90_DAYS,
|
|
||||||
SICK_COMMITMENT_PENALTY_DAYS,
|
|
||||||
SICK_HISTORY_FILE,
|
|
||||||
SICK_HISTORY_REVIEW_COUNT,
|
|
||||||
SICK_JUSTIFICATION_MIN_CHARS,
|
|
||||||
SICK_LOCKOUT_MULTIPLIER_PER_RECENT,
|
|
||||||
SICK_LOCKOUT_SECONDS,
|
|
||||||
)
|
|
||||||
from python_pkg.shared.log_integrity import compute_entry_hmac
|
|
||||||
|
|
||||||
_logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class SickHistory:
|
|
||||||
"""Persistent sick-day bookkeeping."""
|
|
||||||
|
|
||||||
sick_days: list[str] = field(default_factory=list)
|
|
||||||
debt: int = 0
|
|
||||||
commitments: dict[str, bool] = field(default_factory=dict)
|
|
||||||
broken_commitments: list[str] = field(default_factory=list)
|
|
||||||
justifications: list[dict[str, Any]] = field(default_factory=list)
|
|
||||||
|
|
||||||
|
|
||||||
def _today_iso() -> str:
|
|
||||||
"""Return today's date as ``YYYY-MM-DD`` (UTC)."""
|
|
||||||
return datetime.now(tz=timezone.utc).strftime("%Y-%m-%d")
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_iso(date_str: str) -> datetime | None:
|
|
||||||
"""Parse ``YYYY-MM-DD`` into a UTC datetime, or return None."""
|
|
||||||
try:
|
|
||||||
return datetime.strptime(date_str, "%Y-%m-%d").replace(tzinfo=timezone.utc)
|
|
||||||
except ValueError:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def load_history() -> SickHistory:
|
|
||||||
"""Read the persistent sick-day history file.
|
|
||||||
|
|
||||||
Missing or unreadable files yield an empty :class:`SickHistory`.
|
|
||||||
"""
|
|
||||||
if not SICK_HISTORY_FILE.exists():
|
|
||||||
return SickHistory()
|
|
||||||
try:
|
|
||||||
with SICK_HISTORY_FILE.open() as f:
|
|
||||||
data = json.load(f)
|
|
||||||
except (OSError, json.JSONDecodeError):
|
|
||||||
_logger.warning("Could not read sick history; starting fresh")
|
|
||||||
return SickHistory()
|
|
||||||
return SickHistory(
|
|
||||||
sick_days=list(data.get("sick_days", [])),
|
|
||||||
debt=int(data.get("debt", 0)),
|
|
||||||
commitments=dict(data.get("commitments", {})),
|
|
||||||
broken_commitments=list(data.get("broken_commitments", [])),
|
|
||||||
justifications=list(data.get("justifications", [])),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def save_history(history: SickHistory) -> bool:
|
|
||||||
"""Persist ``history``. Returns True on success."""
|
|
||||||
payload = {
|
|
||||||
"sick_days": history.sick_days,
|
|
||||||
"debt": history.debt,
|
|
||||||
"commitments": history.commitments,
|
|
||||||
"broken_commitments": history.broken_commitments,
|
|
||||||
"justifications": history.justifications,
|
|
||||||
}
|
|
||||||
try:
|
|
||||||
with SICK_HISTORY_FILE.open("w") as f:
|
|
||||||
json.dump(payload, f, indent=2)
|
|
||||||
except OSError as exc:
|
|
||||||
_logger.warning("Failed to save sick history: %s", exc)
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def count_in_window(
|
|
||||||
history: SickHistory,
|
|
||||||
days: int,
|
|
||||||
*,
|
|
||||||
today: str | None = None,
|
|
||||||
) -> int:
|
|
||||||
"""Return how many ``sick_days`` fall in the trailing ``days`` window."""
|
|
||||||
today_str = today or _today_iso()
|
|
||||||
today_dt = _parse_iso(today_str)
|
|
||||||
if today_dt is None:
|
|
||||||
return 0
|
|
||||||
cutoff = today_dt - timedelta(days=days)
|
|
||||||
count = 0
|
|
||||||
for entry in history.sick_days:
|
|
||||||
parsed = _parse_iso(entry)
|
|
||||||
if parsed is None:
|
|
||||||
continue
|
|
||||||
if cutoff < parsed <= today_dt:
|
|
||||||
count += 1
|
|
||||||
return count
|
|
||||||
|
|
||||||
|
|
||||||
def is_budget_exhausted(
|
|
||||||
history: SickHistory,
|
|
||||||
*,
|
|
||||||
today: str | None = None,
|
|
||||||
) -> bool:
|
|
||||||
"""Return True if any rolling window has reached its sick budget."""
|
|
||||||
return (
|
|
||||||
count_in_window(history, 7, today=today) >= SICK_BUDGET_PER_7_DAYS
|
|
||||||
or count_in_window(history, 30, today=today) >= SICK_BUDGET_PER_30_DAYS
|
|
||||||
or count_in_window(history, 90, today=today) >= SICK_BUDGET_PER_90_DAYS
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def compute_lockout_seconds(
|
|
||||||
history: SickHistory,
|
|
||||||
*,
|
|
||||||
today: str | None = None,
|
|
||||||
) -> int:
|
|
||||||
"""Escalating sick countdown: ``base * 2 ** recent_count_in_30d``."""
|
|
||||||
recent = count_in_window(history, 30, today=today)
|
|
||||||
multiplier = SICK_LOCKOUT_MULTIPLIER_PER_RECENT**recent
|
|
||||||
return SICK_LOCKOUT_SECONDS * multiplier
|
|
||||||
|
|
||||||
|
|
||||||
def budget_summary(
|
|
||||||
history: SickHistory,
|
|
||||||
*,
|
|
||||||
today: str | None = None,
|
|
||||||
) -> str:
|
|
||||||
"""One-line UI summary string for budget + debt."""
|
|
||||||
week = count_in_window(history, 7, today=today)
|
|
||||||
month = count_in_window(history, 30, today=today)
|
|
||||||
quarter = count_in_window(history, 90, today=today)
|
|
||||||
return (
|
|
||||||
f"Sick: {week}/{SICK_BUDGET_PER_7_DAYS}w · "
|
|
||||||
f"{month}/{SICK_BUDGET_PER_30_DAYS}m · "
|
|
||||||
f"{quarter}/{SICK_BUDGET_PER_90_DAYS}q · "
|
|
||||||
f"Debt: {history.debt}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def add_sick_day(history: SickHistory, *, today: str | None = None) -> int:
|
|
||||||
"""Append today's date and increment debt. Returns new debt.
|
|
||||||
|
|
||||||
If today appears in ``broken_commitments`` the debt grows by
|
|
||||||
:data:`SICK_COMMITMENT_PENALTY_DAYS` instead of 1.
|
|
||||||
"""
|
|
||||||
today_str = today or _today_iso()
|
|
||||||
if today_str not in history.sick_days:
|
|
||||||
history.sick_days.append(today_str)
|
|
||||||
increment = (
|
|
||||||
SICK_COMMITMENT_PENALTY_DAYS if today_str in history.broken_commitments else 1
|
|
||||||
)
|
|
||||||
history.debt += increment
|
|
||||||
return history.debt
|
|
||||||
|
|
||||||
|
|
||||||
def clear_one_debt(history: SickHistory) -> int:
|
|
||||||
"""Decrement debt by one (clamped at zero). Returns new debt."""
|
|
||||||
if history.debt > 0:
|
|
||||||
history.debt -= 1
|
|
||||||
return history.debt
|
|
||||||
|
|
||||||
|
|
||||||
def record_commitment_for_tomorrow(
|
|
||||||
history: SickHistory,
|
|
||||||
*,
|
|
||||||
today: str | None = None,
|
|
||||||
) -> str:
|
|
||||||
"""Record that the user committed to working out tomorrow.
|
|
||||||
|
|
||||||
Returns the ISO date for tomorrow.
|
|
||||||
"""
|
|
||||||
today_str = today or _today_iso()
|
|
||||||
today_dt = _parse_iso(today_str)
|
|
||||||
if today_dt is None:
|
|
||||||
return today_str
|
|
||||||
tomorrow = (today_dt + timedelta(days=1)).strftime("%Y-%m-%d")
|
|
||||||
history.commitments[tomorrow] = True
|
|
||||||
return tomorrow
|
|
||||||
|
|
||||||
|
|
||||||
def had_commitment_for_today(
|
|
||||||
history: SickHistory,
|
|
||||||
*,
|
|
||||||
today: str | None = None,
|
|
||||||
) -> bool:
|
|
||||||
"""Return True if a commitment exists for today."""
|
|
||||||
today_str = today or _today_iso()
|
|
||||||
return bool(history.commitments.get(today_str, False))
|
|
||||||
|
|
||||||
|
|
||||||
def mark_commitment_broken(
|
|
||||||
history: SickHistory,
|
|
||||||
*,
|
|
||||||
today: str | None = None,
|
|
||||||
) -> None:
|
|
||||||
"""Mark today's commitment as broken (idempotent)."""
|
|
||||||
today_str = today or _today_iso()
|
|
||||||
if today_str in history.commitments and today_str not in history.broken_commitments:
|
|
||||||
history.broken_commitments.append(today_str)
|
|
||||||
|
|
||||||
|
|
||||||
SICK_SEVERITY_MIN = 1
|
|
||||||
SICK_SEVERITY_MAX = 10
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class JustificationDraft:
|
|
||||||
"""User-supplied justification fields for a sick-day request."""
|
|
||||||
|
|
||||||
symptom: str
|
|
||||||
onset: str
|
|
||||||
severity: int
|
|
||||||
text: str
|
|
||||||
|
|
||||||
|
|
||||||
def validate_justification(draft: JustificationDraft) -> str | None:
|
|
||||||
"""Return an error message if the justification is invalid, else None."""
|
|
||||||
if not draft.symptom.strip():
|
|
||||||
return "Symptom is required"
|
|
||||||
if not draft.onset.strip():
|
|
||||||
return "Onset time is required"
|
|
||||||
if not SICK_SEVERITY_MIN <= draft.severity <= SICK_SEVERITY_MAX:
|
|
||||||
return f"Severity must be between {SICK_SEVERITY_MIN} and {SICK_SEVERITY_MAX}"
|
|
||||||
if len(draft.text.strip()) < SICK_JUSTIFICATION_MIN_CHARS:
|
|
||||||
return (
|
|
||||||
f"Description must be at least "
|
|
||||||
f"{SICK_JUSTIFICATION_MIN_CHARS} characters "
|
|
||||||
f"(currently {len(draft.text.strip())})"
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def add_justification(
|
|
||||||
history: SickHistory,
|
|
||||||
draft: JustificationDraft,
|
|
||||||
*,
|
|
||||||
today: str | None = None,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""HMAC-sign and append a sick-day justification.
|
|
||||||
|
|
||||||
Returns the stored entry (with ``hmac`` field if a key was available).
|
|
||||||
"""
|
|
||||||
today_str = today or _today_iso()
|
|
||||||
entry: dict[str, Any] = {
|
|
||||||
"date": today_str,
|
|
||||||
"timestamp": datetime.now(tz=timezone.utc).isoformat(),
|
|
||||||
"symptom": draft.symptom.strip(),
|
|
||||||
"onset": draft.onset.strip(),
|
|
||||||
"severity": int(draft.severity),
|
|
||||||
"text": draft.text.strip(),
|
|
||||||
}
|
|
||||||
signature = compute_entry_hmac(entry)
|
|
||||||
if signature is not None:
|
|
||||||
entry["hmac"] = signature
|
|
||||||
history.justifications.append(entry)
|
|
||||||
return entry
|
|
||||||
|
|
||||||
|
|
||||||
def recent_justifications(
|
|
||||||
history: SickHistory,
|
|
||||||
n: int = SICK_HISTORY_REVIEW_COUNT,
|
|
||||||
) -> list[dict[str, Any]]:
|
|
||||||
"""Return the last ``n`` justifications (oldest first)."""
|
|
||||||
if n <= 0:
|
|
||||||
return []
|
|
||||||
return list(history.justifications[-n:])
|
|
||||||
|
|
||||||
|
|
||||||
def format_recent_justifications(
|
|
||||||
history: SickHistory,
|
|
||||||
n: int = SICK_HISTORY_REVIEW_COUNT,
|
|
||||||
) -> str:
|
|
||||||
"""Human-readable multi-line summary of recent justifications.
|
|
||||||
|
|
||||||
Empty string when there are no past entries.
|
|
||||||
"""
|
|
||||||
entries = recent_justifications(history, n)
|
|
||||||
if not entries:
|
|
||||||
return ""
|
|
||||||
lines: list[str] = []
|
|
||||||
for entry in entries:
|
|
||||||
date_str = entry.get("date", "?")
|
|
||||||
symptom = entry.get("symptom", "?")
|
|
||||||
severity = entry.get("severity", "?")
|
|
||||||
lines.append(f"{date_str} sev {severity}/10 — {symptom}")
|
|
||||||
return "\n".join(lines)
|
|
||||||
@ -1,79 +0,0 @@
|
|||||||
"""System clock skew detection via NTP."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import socket
|
|
||||||
import struct
|
|
||||||
import time
|
|
||||||
|
|
||||||
from python_pkg.screen_locker._constants import MAX_CLOCK_SKEW_SECONDS
|
|
||||||
|
|
||||||
_logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
_NTP_EPOCH_OFFSET = 2208988800 # Seconds between 1900-01-01 and 1970-01-01
|
|
||||||
_NTP_PORT = 123
|
|
||||||
_NTP_TIMEOUT = 5
|
|
||||||
_NTP_MIN_PACKET_SIZE = 48
|
|
||||||
|
|
||||||
|
|
||||||
def _query_ntp_offset(server: str = "pool.ntp.org") -> float | None:
|
|
||||||
"""Query an NTP server and return the clock offset in seconds.
|
|
||||||
|
|
||||||
Uses a minimal SNTP (RFC 4330) client-mode request.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Offset in seconds (positive = local clock is ahead), or None on error.
|
|
||||||
"""
|
|
||||||
# NTP v3, mode 3 (client), transmit timestamp at bytes 40-47
|
|
||||||
packet = b"\x1b" + b"\0" * 47
|
|
||||||
try:
|
|
||||||
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
|
|
||||||
sock.settimeout(_NTP_TIMEOUT)
|
|
||||||
t1 = time.time()
|
|
||||||
sock.sendto(packet, (server, _NTP_PORT))
|
|
||||||
data, _ = sock.recvfrom(1024)
|
|
||||||
t4 = time.time()
|
|
||||||
except OSError as exc:
|
|
||||||
_logger.info("NTP query to %s failed: %s", server, exc)
|
|
||||||
return None
|
|
||||||
|
|
||||||
if len(data) < _NTP_MIN_PACKET_SIZE:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Transmit timestamp from server (bytes 40-47)
|
|
||||||
tx_seconds = struct.unpack("!I", data[40:44])[0] - _NTP_EPOCH_OFFSET
|
|
||||||
tx_fraction = struct.unpack("!I", data[44:48])[0] / (2**32)
|
|
||||||
server_time = tx_seconds + tx_fraction
|
|
||||||
|
|
||||||
# Simplified offset: server_time should be close to (t1 + t4) / 2
|
|
||||||
local_mid = (t1 + t4) / 2
|
|
||||||
return server_time - local_mid
|
|
||||||
|
|
||||||
|
|
||||||
def check_clock_skew() -> tuple[bool, str]:
|
|
||||||
"""Check if system clock is within acceptable skew of NTP time.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Tuple of (ok, message).
|
|
||||||
ok is True if clock is within MAX_CLOCK_SKEW_SECONDS or NTP is unreachable.
|
|
||||||
When NTP is unreachable, we allow through (fail-open for network issues).
|
|
||||||
"""
|
|
||||||
offset = _query_ntp_offset()
|
|
||||||
if offset is None:
|
|
||||||
_logger.info("NTP unreachable — allowing through")
|
|
||||||
return True, "NTP check skipped (server unreachable)"
|
|
||||||
|
|
||||||
abs_offset = abs(offset)
|
|
||||||
if abs_offset > MAX_CLOCK_SKEW_SECONDS:
|
|
||||||
direction = "ahead" if offset < 0 else "behind"
|
|
||||||
_logger.warning(
|
|
||||||
"Clock skew detected: %.0f seconds %s",
|
|
||||||
abs_offset,
|
|
||||||
direction,
|
|
||||||
)
|
|
||||||
return False, (
|
|
||||||
f"System clock is {abs_offset:.0f}s {direction} of NTP time. "
|
|
||||||
f"Max allowed skew: {MAX_CLOCK_SKEW_SECONDS}s."
|
|
||||||
)
|
|
||||||
return True, f"Clock OK (offset: {offset:+.1f}s)"
|
|
||||||
@ -1,452 +0,0 @@
|
|||||||
"""UI flow methods mixin for the screen locker."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from concurrent.futures import ThreadPoolExecutor # pylint: disable=no-name-in-module
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
from python_pkg.screen_locker import _sick_tracker
|
|
||||||
from python_pkg.screen_locker._constants import (
|
|
||||||
NO_PHONE_EXTRA_LOCKOUT_SECONDS,
|
|
||||||
PHONE_PENALTY_DELAY_DEMO,
|
|
||||||
PHONE_PENALTY_DELAY_PRODUCTION,
|
|
||||||
)
|
|
||||||
from python_pkg.screen_locker._weekly_check import (
|
|
||||||
WEEKLY_WORKOUT_MINIMUM,
|
|
||||||
count_weekly_workouts,
|
|
||||||
)
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from collections.abc import Callable
|
|
||||||
|
|
||||||
|
|
||||||
class UIFlowsMixin:
|
|
||||||
"""Mixin providing UI flow logic for the screen locker."""
|
|
||||||
|
|
||||||
def _start_phone_check(self) -> None:
|
|
||||||
"""Check phone for today's workout immediately at startup."""
|
|
||||||
self.clear_container()
|
|
||||||
self._label("Checking phone...", font_size=36, color="#ffaa00", pady=30)
|
|
||||||
self._text("Looking for today's workout in StrongLifts...", font_size=18)
|
|
||||||
executor = ThreadPoolExecutor(max_workers=1)
|
|
||||||
self._phone_future = executor.submit(self._verify_phone_workout)
|
|
||||||
executor.shutdown(wait=False)
|
|
||||||
self._poll_phone_check()
|
|
||||||
|
|
||||||
def _poll_phone_check(self) -> None:
|
|
||||||
"""Poll background phone check and route to result handler when done."""
|
|
||||||
if self._phone_future is not None and self._phone_future.done():
|
|
||||||
status, message = self._phone_future.result()
|
|
||||||
self._handle_startup_phone_result(status, message)
|
|
||||||
else:
|
|
||||||
self.root.after(500, self._poll_phone_check)
|
|
||||||
|
|
||||||
def _show_retry_and_sick(self, message: str) -> None:
|
|
||||||
"""Show TRY AGAIN and (if budget allows) I'm sick after a failed check."""
|
|
||||||
self.clear_container()
|
|
||||||
self._label("No Workout Found", font_size=36, color="#ff4444", pady=20)
|
|
||||||
self._text(message, color="#ffaa00")
|
|
||||||
history = _sick_tracker.load_history()
|
|
||||||
self._text(_sick_tracker.budget_summary(history), color="#888888")
|
|
||||||
frame = self._button_row()
|
|
||||||
self._button(
|
|
||||||
frame,
|
|
||||||
"TRY AGAIN",
|
|
||||||
bg="#0066cc",
|
|
||||||
command=self._start_phone_check,
|
|
||||||
width=12,
|
|
||||||
).pack(side="left", padx=10)
|
|
||||||
if _sick_tracker.is_budget_exhausted(history):
|
|
||||||
self._text(
|
|
||||||
"Sick budget exhausted. No 'I'm sick' option available.",
|
|
||||||
color="#ff6666",
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
self._button(
|
|
||||||
frame,
|
|
||||||
"I'm sick",
|
|
||||||
bg="#cc6600",
|
|
||||||
command=self.ask_if_sick,
|
|
||||||
width=12,
|
|
||||||
).pack(side="left", padx=10)
|
|
||||||
|
|
||||||
def _handle_startup_phone_result(self, status: str, message: str) -> None:
|
|
||||||
"""Route to appropriate screen based on startup phone check result."""
|
|
||||||
if status == "verified":
|
|
||||||
self.workout_data["type"] = "phone_verified"
|
|
||||||
self.workout_data["source"] = message
|
|
||||||
self.clear_container()
|
|
||||||
self._label("✓ Workout Verified!", font_size=42, color="#00cc44", pady=30)
|
|
||||||
self._text(message, font_size=20, color="#aaffaa")
|
|
||||||
self._text("Unlocking...", font_size=18, color="#888888")
|
|
||||||
unlock_delay = 1500 if self.demo_mode else 2000
|
|
||||||
self.root.after(unlock_delay, self.unlock_screen)
|
|
||||||
elif status == "too_short":
|
|
||||||
self._show_retry_and_sick(
|
|
||||||
f"❌ {message}\n\n"
|
|
||||||
"Your workout was too short!\n"
|
|
||||||
"Actually do the full workout, don't just\n"
|
|
||||||
"spam through the exercises.",
|
|
||||||
)
|
|
||||||
elif status in ("stale", "no_exercises"):
|
|
||||||
self._show_retry_and_sick(
|
|
||||||
f"❌ {message}\n\nReason: {status}",
|
|
||||||
)
|
|
||||||
elif status == "clock_tampered":
|
|
||||||
self._show_retry_and_sick(
|
|
||||||
f"❌ {message}\n\n"
|
|
||||||
"System clock appears to be manipulated.\n"
|
|
||||||
"Fix your system time and try again.",
|
|
||||||
)
|
|
||||||
elif status == "not_verified":
|
|
||||||
self._show_retry_and_sick(
|
|
||||||
f"❌ {message}\n\n"
|
|
||||||
"StrongLifts shows no workout today.\n"
|
|
||||||
"Go do your workout first!",
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
# no_phone or error — penalty timer, then retry+sick screen
|
|
||||||
self._show_phone_penalty(message)
|
|
||||||
|
|
||||||
def ask_if_sick(self) -> None:
|
|
||||||
"""Display the structured sick-day justification dialog."""
|
|
||||||
self._show_sick_justification()
|
|
||||||
|
|
||||||
def _get_sick_day_status(self) -> tuple[str, str]:
|
|
||||||
"""Determine sick day status text and color."""
|
|
||||||
if self._sick_mode_used_today():
|
|
||||||
return "Shutdown time already adjusted today", "#ffaa00"
|
|
||||||
if self._adjust_shutdown_time_earlier():
|
|
||||||
return (
|
|
||||||
"Shutdown time moved 1.5 hours earlier ✓\n(Will revert tomorrow)"
|
|
||||||
), "#00aa00"
|
|
||||||
return "Could not adjust shutdown time (check permissions)", "#ff4444"
|
|
||||||
|
|
||||||
def _proceed_to_sick_countdown(self) -> None:
|
|
||||||
"""Start the (escalated) sick day countdown after justification."""
|
|
||||||
history = getattr(
|
|
||||||
self,
|
|
||||||
"_sick_history_cache",
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
if history is None:
|
|
||||||
history = _sick_tracker.load_history()
|
|
||||||
self._sick_history_cache = history
|
|
||||||
countdown = _sick_tracker.compute_lockout_seconds(history)
|
|
||||||
self.clear_container()
|
|
||||||
status_text, status_color = self._get_sick_day_status()
|
|
||||||
self._show_sick_day_ui(status_text, status_color, countdown)
|
|
||||||
self.sick_remaining_time = countdown
|
|
||||||
self._update_sick_countdown()
|
|
||||||
|
|
||||||
def _show_sick_day_ui(
|
|
||||||
self,
|
|
||||||
status_text: str,
|
|
||||||
status_color: str,
|
|
||||||
countdown: int,
|
|
||||||
) -> None:
|
|
||||||
"""Display sick day UI labels and countdown."""
|
|
||||||
self._label("Sick Day Mode", color="#cc6600", pady=20)
|
|
||||||
self._text(status_text, color=status_color)
|
|
||||||
minutes = countdown // 60
|
|
||||||
self._text(
|
|
||||||
f"Please wait ~{minutes} min before unlocking...",
|
|
||||||
font_size=24,
|
|
||||||
pady=20,
|
|
||||||
)
|
|
||||||
self.sick_countdown_label = self._label(
|
|
||||||
str(countdown),
|
|
||||||
font_size=80,
|
|
||||||
pady=30,
|
|
||||||
)
|
|
||||||
|
|
||||||
def _update_sick_countdown(self) -> None:
|
|
||||||
"""Update the sick day countdown timer."""
|
|
||||||
if self.sick_remaining_time > 0:
|
|
||||||
self.sick_countdown_label.config(text=str(self.sick_remaining_time))
|
|
||||||
self.sick_remaining_time -= 1
|
|
||||||
self.root.after(1000, self._update_sick_countdown)
|
|
||||||
else:
|
|
||||||
self._finalize_sick_day()
|
|
||||||
|
|
||||||
def _finalize_sick_day(self) -> None:
|
|
||||||
"""Persist sick-day history and unlock the screen."""
|
|
||||||
history = getattr(self, "_sick_history_cache", None)
|
|
||||||
if history is None:
|
|
||||||
history = _sick_tracker.load_history()
|
|
||||||
if _sick_tracker.had_commitment_for_today(history):
|
|
||||||
_sick_tracker.mark_commitment_broken(history)
|
|
||||||
self.workout_data["broke_commitment"] = "true"
|
|
||||||
new_debt = _sick_tracker.add_sick_day(history)
|
|
||||||
_sick_tracker.save_history(history)
|
|
||||||
self.workout_data["type"] = "sick_day"
|
|
||||||
self.workout_data["note"] = "Sick day - shutdown moved earlier"
|
|
||||||
self.workout_data["debt"] = str(new_debt)
|
|
||||||
self.unlock_screen()
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Lockout flow
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def lockout(self) -> None:
|
|
||||||
"""Display lockout screen with countdown timer."""
|
|
||||||
self.clear_container()
|
|
||||||
self.lockout_label = self._label(
|
|
||||||
f"Go work out!\nLocked for {self.lockout_time} seconds",
|
|
||||||
font_size=48,
|
|
||||||
color="#ff4444",
|
|
||||||
pady=30,
|
|
||||||
)
|
|
||||||
self.countdown_label = self._label(
|
|
||||||
str(self.lockout_time),
|
|
||||||
font_size=120,
|
|
||||||
pady=30,
|
|
||||||
)
|
|
||||||
self.remaining_time = self.lockout_time
|
|
||||||
self.update_lockout_countdown()
|
|
||||||
|
|
||||||
def update_lockout_countdown(self) -> None:
|
|
||||||
"""Update the lockout countdown timer display."""
|
|
||||||
if self.remaining_time > 0:
|
|
||||||
self.countdown_label.config(text=str(self.remaining_time))
|
|
||||||
self.remaining_time -= 1
|
|
||||||
self.root.after(1000, self.update_lockout_countdown)
|
|
||||||
else:
|
|
||||||
self._start_phone_check()
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Phone penalty
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def _show_phone_penalty(
|
|
||||||
self, message: str, *, on_done: Callable[[], None] | None = None
|
|
||||||
) -> None:
|
|
||||||
"""Show penalty countdown when phone verification is unavailable."""
|
|
||||||
self.clear_container()
|
|
||||||
self._phone_penalty_done_fn: Callable[[], None] = (
|
|
||||||
on_done
|
|
||||||
if on_done is not None
|
|
||||||
else lambda: self._show_retry_and_sick(message)
|
|
||||||
)
|
|
||||||
base_delay = (
|
|
||||||
PHONE_PENALTY_DELAY_DEMO
|
|
||||||
if self.demo_mode
|
|
||||||
else PHONE_PENALTY_DELAY_PRODUCTION
|
|
||||||
)
|
|
||||||
# Disconnecting the phone shouldn't be a fast path into sick mode.
|
|
||||||
delay = (
|
|
||||||
base_delay
|
|
||||||
if self.demo_mode
|
|
||||||
else base_delay + NO_PHONE_EXTRA_LOCKOUT_SECONDS
|
|
||||||
)
|
|
||||||
self._label(
|
|
||||||
"Cannot Verify Workout",
|
|
||||||
font_size=36,
|
|
||||||
color="#ff8800",
|
|
||||||
pady=20,
|
|
||||||
)
|
|
||||||
self._text(message, color="#ffaa00")
|
|
||||||
self._text(
|
|
||||||
"Connect phone via ADB to skip this wait,\n"
|
|
||||||
"or wait for the penalty timer.\n\n"
|
|
||||||
"Note: Phone must be rooted and StrongLifts installed.",
|
|
||||||
font_size=18,
|
|
||||||
)
|
|
||||||
self.phone_penalty_remaining = delay
|
|
||||||
self.phone_penalty_label = self._label(
|
|
||||||
str(delay),
|
|
||||||
font_size=80,
|
|
||||||
pady=20,
|
|
||||||
)
|
|
||||||
self._update_phone_penalty()
|
|
||||||
|
|
||||||
def _update_phone_penalty(self) -> None:
|
|
||||||
"""Update phone penalty countdown."""
|
|
||||||
if self.phone_penalty_remaining > 0:
|
|
||||||
self.phone_penalty_label.config(
|
|
||||||
text=str(self.phone_penalty_remaining),
|
|
||||||
)
|
|
||||||
self.phone_penalty_remaining -= 1
|
|
||||||
self.root.after(1000, self._update_phone_penalty)
|
|
||||||
else:
|
|
||||||
self._phone_penalty_done_fn()
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Verify-workout flow (post-sick-day)
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def _start_verify_workout_check(self) -> None:
|
|
||||||
"""Start phone check for post-sick-day workout verification."""
|
|
||||||
self.clear_container()
|
|
||||||
self._label(
|
|
||||||
"Verifying Workout",
|
|
||||||
font_size=36,
|
|
||||||
color="#ffaa00",
|
|
||||||
pady=30,
|
|
||||||
)
|
|
||||||
self._text(
|
|
||||||
"Checking phone for today's workout...",
|
|
||||||
font_size=18,
|
|
||||||
)
|
|
||||||
executor = ThreadPoolExecutor(max_workers=1)
|
|
||||||
self._phone_future = executor.submit(self._verify_phone_workout)
|
|
||||||
executor.shutdown(wait=False)
|
|
||||||
self._poll_verify_workout_check()
|
|
||||||
|
|
||||||
def _poll_verify_workout_check(self) -> None:
|
|
||||||
"""Poll background phone check for verify-workout mode."""
|
|
||||||
if self._phone_future is not None and self._phone_future.done():
|
|
||||||
status, message = self._phone_future.result()
|
|
||||||
self._handle_verify_workout_result(status, message)
|
|
||||||
else:
|
|
||||||
self.root.after(500, self._poll_verify_workout_check)
|
|
||||||
|
|
||||||
def _handle_verify_workout_result(
|
|
||||||
self,
|
|
||||||
status: str,
|
|
||||||
message: str,
|
|
||||||
) -> None:
|
|
||||||
"""Route phone check result in verify-workout mode."""
|
|
||||||
if status == "verified":
|
|
||||||
self.workout_data["type"] = "phone_verified"
|
|
||||||
self.workout_data["source"] = message
|
|
||||||
self.workout_data["after_sick_day"] = "true"
|
|
||||||
adjusted = self._adjust_shutdown_time_later()
|
|
||||||
self.save_workout_log()
|
|
||||||
self.clear_container()
|
|
||||||
self._label(
|
|
||||||
"✓ Workout Verified!",
|
|
||||||
font_size=42,
|
|
||||||
color="#00cc44",
|
|
||||||
pady=30,
|
|
||||||
)
|
|
||||||
self._text(message, font_size=20, color="#aaffaa")
|
|
||||||
if adjusted:
|
|
||||||
self._text(
|
|
||||||
"Shutdown time moved later!",
|
|
||||||
font_size=20,
|
|
||||||
color="#ffaa00",
|
|
||||||
)
|
|
||||||
self.root.after(2000, self.close)
|
|
||||||
else:
|
|
||||||
self._show_verify_retry(message)
|
|
||||||
|
|
||||||
def _show_verify_retry(self, message: str) -> None:
|
|
||||||
"""Show retry/close buttons when workout not found in verify mode."""
|
|
||||||
self.clear_container()
|
|
||||||
self._label(
|
|
||||||
"Workout Not Found",
|
|
||||||
font_size=36,
|
|
||||||
color="#ff4444",
|
|
||||||
pady=20,
|
|
||||||
)
|
|
||||||
self._text(message, color="#ffaa00")
|
|
||||||
frame = self._button_row()
|
|
||||||
self._button(
|
|
||||||
frame,
|
|
||||||
"TRY AGAIN",
|
|
||||||
bg="#0066cc",
|
|
||||||
command=self._start_verify_workout_check,
|
|
||||||
width=12,
|
|
||||||
).pack(side="left", padx=10)
|
|
||||||
self._button(
|
|
||||||
frame,
|
|
||||||
"Close",
|
|
||||||
bg="#aa0000",
|
|
||||||
command=self.close,
|
|
||||||
width=12,
|
|
||||||
).pack(side="left", padx=10)
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Relaxed-day flow (Tue/Wed/Thu — optional, no penalty for skipping)
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def _start_relaxed_day_flow(self) -> None:
|
|
||||||
"""Show optional workout prompt for relaxed days (Tue-Thu).
|
|
||||||
|
|
||||||
The screen is not locked — the user can skip freely or voluntarily
|
|
||||||
import a Stronglift workout that counts toward the weekly minimum.
|
|
||||||
"""
|
|
||||||
count = count_weekly_workouts(self.log_file)
|
|
||||||
self.clear_container()
|
|
||||||
self._label(
|
|
||||||
"Optional Day (Tue / Wed / Thu)",
|
|
||||||
font_size=30,
|
|
||||||
color="#ffaa00",
|
|
||||||
pady=20,
|
|
||||||
)
|
|
||||||
self._text(
|
|
||||||
f"Weekly workouts: {count} / {WEEKLY_WORKOUT_MINIMUM}\n"
|
|
||||||
"No penalty for skipping today.",
|
|
||||||
font_size=20,
|
|
||||||
color="#aaaaaa",
|
|
||||||
pady=10,
|
|
||||||
)
|
|
||||||
frame = self._button_row()
|
|
||||||
self._button(
|
|
||||||
frame,
|
|
||||||
"Skip — No Penalty",
|
|
||||||
bg="#006600",
|
|
||||||
command=self.close,
|
|
||||||
width=18,
|
|
||||||
).pack(side="left", padx=10)
|
|
||||||
self._button(
|
|
||||||
frame,
|
|
||||||
"Log Stronglift Workout",
|
|
||||||
bg="#0066cc",
|
|
||||||
command=self._start_relaxed_phone_check,
|
|
||||||
width=20,
|
|
||||||
).pack(side="left", padx=10)
|
|
||||||
|
|
||||||
def _start_relaxed_phone_check(self) -> None:
|
|
||||||
"""Run Stronglift check in relaxed mode (no screen grab, no sick option)."""
|
|
||||||
self.clear_container()
|
|
||||||
self._label("Checking phone...", font_size=36, color="#ffaa00", pady=30)
|
|
||||||
self._text("Looking for today's workout in StrongLifts...", font_size=18)
|
|
||||||
executor = ThreadPoolExecutor(max_workers=1)
|
|
||||||
self._phone_future = executor.submit(self._verify_phone_workout)
|
|
||||||
executor.shutdown(wait=False)
|
|
||||||
self._poll_relaxed_phone_check()
|
|
||||||
|
|
||||||
def _poll_relaxed_phone_check(self) -> None:
|
|
||||||
"""Poll background phone check in relaxed-day mode."""
|
|
||||||
if self._phone_future is not None and self._phone_future.done():
|
|
||||||
status, message = self._phone_future.result()
|
|
||||||
self._handle_relaxed_phone_result(status, message)
|
|
||||||
else:
|
|
||||||
self.root.after(500, self._poll_relaxed_phone_check)
|
|
||||||
|
|
||||||
def _handle_relaxed_phone_result(self, status: str, message: str) -> None:
|
|
||||||
"""Route phone check result in relaxed-day mode.
|
|
||||||
|
|
||||||
On success saves the workout (counts toward weekly total) then closes.
|
|
||||||
On failure shows retry and close — no sick option since skipping is free.
|
|
||||||
"""
|
|
||||||
if status == "verified":
|
|
||||||
self.workout_data["type"] = "phone_verified"
|
|
||||||
self.workout_data["source"] = message
|
|
||||||
unlock_delay = 1500 if self.demo_mode else 2000
|
|
||||||
self.root.after(unlock_delay, self.unlock_screen)
|
|
||||||
else:
|
|
||||||
self._show_relaxed_retry(message, status)
|
|
||||||
|
|
||||||
def _show_relaxed_retry(self, message: str, status: str) -> None:
|
|
||||||
"""Show retry and skip-close when workout not found in relaxed mode."""
|
|
||||||
self.clear_container()
|
|
||||||
self._label("No Workout Found", font_size=36, color="#ff4444", pady=20)
|
|
||||||
self._text(f"❌ {message}\n\nReason: {status}", color="#ffaa00")
|
|
||||||
frame = self._button_row()
|
|
||||||
self._button(
|
|
||||||
frame,
|
|
||||||
"TRY AGAIN",
|
|
||||||
bg="#0066cc",
|
|
||||||
command=self._start_relaxed_phone_check,
|
|
||||||
width=12,
|
|
||||||
).pack(side="left", padx=10)
|
|
||||||
self._button(
|
|
||||||
frame,
|
|
||||||
"Close (Skip)",
|
|
||||||
bg="#006600",
|
|
||||||
command=self.close,
|
|
||||||
width=14,
|
|
||||||
).pack(side="left", padx=10)
|
|
||||||
@ -1,108 +0,0 @@
|
|||||||
"""Weekly workout count and day-of-week mode detection for the screen locker.
|
|
||||||
|
|
||||||
On Tue/Wed/Thu (relaxed days) the lock is optional: the user can skip
|
|
||||||
without any penalty, or voluntarily import a Stronglift workout which
|
|
||||||
will count toward the weekly minimum.
|
|
||||||
|
|
||||||
On Fri/Sat/Sun/Mon (enforced days) the lock fires unless the user has
|
|
||||||
already logged at least WEEKLY_WORKOUT_MINIMUM verified workouts in the
|
|
||||||
current ISO week (Mon-Sun).
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import datetime, timedelta, timezone
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
from typing import TYPE_CHECKING, Any
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
_logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
WEEKLY_WORKOUT_MINIMUM: int = 4
|
|
||||||
|
|
||||||
# Python weekday(): Mon=0, Tue=1, Wed=2, Thu=3, Fri=4, Sat=5, Sun=6
|
|
||||||
_RELAXED_WEEKDAYS: frozenset[int] = frozenset({1, 2, 3}) # Tue, Wed, Thu
|
|
||||||
|
|
||||||
# Only phone-verified workouts count toward the weekly minimum.
|
|
||||||
_COUNTED_WORKOUT_TYPES: frozenset[str] = frozenset({"phone_verified"})
|
|
||||||
|
|
||||||
|
|
||||||
def is_relaxed_day(*, today: datetime | None = None) -> bool:
|
|
||||||
"""Return True if today is a relaxed day (Tue, Wed, or Thu).
|
|
||||||
|
|
||||||
Args:
|
|
||||||
today: Override for the current local datetime (for testing).
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True when the current weekday is Tuesday, Wednesday, or Thursday.
|
|
||||||
"""
|
|
||||||
dt = today if today is not None else datetime.now(tz=timezone.utc).astimezone()
|
|
||||||
return dt.weekday() in _RELAXED_WEEKDAYS
|
|
||||||
|
|
||||||
|
|
||||||
def count_weekly_workouts(
|
|
||||||
log_file: Path,
|
|
||||||
*,
|
|
||||||
today: datetime | None = None,
|
|
||||||
) -> int:
|
|
||||||
"""Count phone-verified workouts logged in the current ISO week (Mon-Sun).
|
|
||||||
|
|
||||||
Args:
|
|
||||||
log_file: Path to ``workout_log.json``.
|
|
||||||
today: Override for the current local datetime (for testing).
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Number of ``phone_verified`` entries whose date falls within the
|
|
||||||
current ISO week, up to and including today.
|
|
||||||
"""
|
|
||||||
dt = today if today is not None else datetime.now(tz=timezone.utc).astimezone()
|
|
||||||
week_start = (dt - timedelta(days=dt.weekday())).date()
|
|
||||||
today_date = dt.date()
|
|
||||||
|
|
||||||
if not log_file.exists():
|
|
||||||
return 0
|
|
||||||
try:
|
|
||||||
with log_file.open() as f:
|
|
||||||
logs: dict[str, Any] = json.load(f)
|
|
||||||
except (OSError, json.JSONDecodeError):
|
|
||||||
_logger.warning("Could not read workout log for weekly count")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
count = 0
|
|
||||||
for date_str, entry in logs.items():
|
|
||||||
try:
|
|
||||||
entry_date = (
|
|
||||||
datetime.strptime(date_str, "%Y-%m-%d")
|
|
||||||
.replace(tzinfo=timezone.utc)
|
|
||||||
.date()
|
|
||||||
)
|
|
||||||
except ValueError:
|
|
||||||
continue
|
|
||||||
if not (week_start <= entry_date <= today_date):
|
|
||||||
continue
|
|
||||||
if not isinstance(entry, dict):
|
|
||||||
continue
|
|
||||||
wtype = entry.get("workout_data", {}).get("type", "")
|
|
||||||
if wtype in _COUNTED_WORKOUT_TYPES:
|
|
||||||
count += 1
|
|
||||||
return count
|
|
||||||
|
|
||||||
|
|
||||||
def has_weekly_minimum(
|
|
||||||
log_file: Path,
|
|
||||||
*,
|
|
||||||
today: datetime | None = None,
|
|
||||||
) -> bool:
|
|
||||||
"""Return True if the weekly workout minimum has already been reached.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
log_file: Path to ``workout_log.json``.
|
|
||||||
today: Override for the current local datetime (for testing).
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True when ``count_weekly_workouts`` >= ``WEEKLY_WORKOUT_MINIMUM``.
|
|
||||||
"""
|
|
||||||
return count_weekly_workouts(log_file, today=today) >= WEEKLY_WORKOUT_MINIMUM
|
|
||||||
@ -1,86 +0,0 @@
|
|||||||
"""Window configuration and input-grab helpers for ScreenLocker."""
|
|
||||||
|
|
||||||
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."""
|
|
||||||
|
|
||||||
def _disable_vt_switching(self) -> None:
|
|
||||||
"""Disable VT switching in X11 while the lock is active.
|
|
||||||
|
|
||||||
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 _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 _setup_verify_window(self) -> None:
|
|
||||||
"""Configure window for post-sick-day workout verification."""
|
|
||||||
self.root.geometry("600x400")
|
|
||||||
self.root.configure(bg="#1a1a1a", cursor="arrow")
|
|
||||||
self.root.protocol("WM_DELETE_WINDOW", self.close)
|
|
||||||
|
|
||||||
def _setup_demo_close_button(self) -> None:
|
|
||||||
"""Add close button for demo mode."""
|
|
||||||
close_btn = tk.Button(
|
|
||||||
self.root,
|
|
||||||
text="✕ Close Demo",
|
|
||||||
font=("Arial", 12),
|
|
||||||
bg="#ff4444",
|
|
||||||
fg="white",
|
|
||||||
command=self.close,
|
|
||||||
cursor="hand2",
|
|
||||||
)
|
|
||||||
close_btn.place(x=10, y=10)
|
|
||||||
|
|
||||||
def _setup_relaxed_day_window(self) -> None:
|
|
||||||
"""Configure a small non-locking window for the optional Tue-Thu prompt."""
|
|
||||||
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()
|
|
||||||
@ -1,87 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
# Helper script to adjust shutdown schedule
|
|
||||||
# This script should be allowed via sudoers for the workout locker
|
|
||||||
#
|
|
||||||
# Usage: sudo adjust_shutdown_schedule.sh [--restore] <mon_wed_hour> <thu_sun_hour> <morning_end_hour>
|
|
||||||
#
|
|
||||||
# --restore: Allow restoring to original (possibly later) times
|
|
||||||
# Without this flag, only stricter (earlier) times are allowed
|
|
||||||
#
|
|
||||||
# Add to /etc/sudoers.d/workout-locker:
|
|
||||||
# <username> ALL=(root) NOPASSWD: /home/kuhy/testsAndMisc/python_pkg/screen_locker/adjust_shutdown_schedule.sh
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
CONFIG_FILE="/etc/shutdown-schedule.conf"
|
|
||||||
CANONICAL_FILE="/usr/local/share/locked-shutdown-schedule.conf"
|
|
||||||
|
|
||||||
# Check for --restore flag
|
|
||||||
RESTORE_MODE=false
|
|
||||||
if [[ "${1:-}" == "--restore" ]]; then
|
|
||||||
RESTORE_MODE=true
|
|
||||||
shift
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Validate arguments
|
|
||||||
if [[ $# -ne 3 ]]; then
|
|
||||||
echo "Usage: $0 [--restore] <mon_wed_hour> <thu_sun_hour> <morning_end_hour>" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
MON_WED_HOUR="$1"
|
|
||||||
THU_SUN_HOUR="$2"
|
|
||||||
MORNING_END_HOUR="$3"
|
|
||||||
|
|
||||||
# Validate hours are integers between 0-23
|
|
||||||
for hour in "$MON_WED_HOUR" "$THU_SUN_HOUR" "$MORNING_END_HOUR"; do
|
|
||||||
if ! [[ "$hour" =~ ^[0-9]+$ ]] || [[ "$hour" -lt 0 ]] || [[ "$hour" -gt 24 ]]; then
|
|
||||||
echo "Error: Hours must be integers between 0 and 23" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
# Read current values to check if we're making schedule stricter
|
|
||||||
if [[ -f "$CONFIG_FILE" ]] && [[ "$RESTORE_MODE" == false ]]; then
|
|
||||||
# shellcheck source=/dev/null
|
|
||||||
source "$CONFIG_FILE" 2>/dev/null || true
|
|
||||||
OLD_MON_WED="${MON_WED_HOUR:-24}"
|
|
||||||
OLD_THU_SUN="${THU_SUN_HOUR:-24}"
|
|
||||||
|
|
||||||
# Reset variables to new values for comparison
|
|
||||||
# shellcheck disable=SC2034
|
|
||||||
MON_WED_HOUR_OLD="$OLD_MON_WED"
|
|
||||||
# shellcheck disable=SC2034
|
|
||||||
THU_SUN_HOUR_OLD="$OLD_THU_SUN"
|
|
||||||
|
|
||||||
# Only allow making schedule stricter (earlier shutdown) unless in restore mode
|
|
||||||
if [[ "$1" -gt "${MON_WED_HOUR_OLD:-24}" ]] || [[ "$2" -gt "${THU_SUN_HOUR_OLD:-24}" ]]; then
|
|
||||||
echo "Error: Can only make schedule stricter (earlier shutdown times)" >&2
|
|
||||||
echo "Use --restore flag to restore original times" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
NEW_CONFIG="# Shutdown schedule configuration
|
|
||||||
# Modified by screen_locker sick day feature at $(date)
|
|
||||||
MON_WED_HOUR=$1
|
|
||||||
THU_SUN_HOUR=$2
|
|
||||||
MORNING_END_HOUR=$3
|
|
||||||
"
|
|
||||||
|
|
||||||
# Remove immutable attributes
|
|
||||||
chattr -i "$CONFIG_FILE" 2>/dev/null || true
|
|
||||||
chattr -i "$CANONICAL_FILE" 2>/dev/null || true
|
|
||||||
|
|
||||||
# Write canonical copy FIRST (before the watched config) to avoid
|
|
||||||
# a race with shutdown-schedule-guard.path which triggers on CONFIG_FILE
|
|
||||||
# changes and restores from CANONICAL_FILE.
|
|
||||||
echo "$NEW_CONFIG" > "$CANONICAL_FILE"
|
|
||||||
chmod 644 "$CANONICAL_FILE"
|
|
||||||
chattr +i "$CANONICAL_FILE" || echo "Warning: Could not set immutable on $CANONICAL_FILE" >&2
|
|
||||||
|
|
||||||
# Now write the watched config — guard will see content matches canonical
|
|
||||||
echo "$NEW_CONFIG" > "$CONFIG_FILE"
|
|
||||||
chmod 644 "$CONFIG_FILE"
|
|
||||||
chattr +i "$CONFIG_FILE" || echo "Warning: Could not set immutable on $CONFIG_FILE" >&2
|
|
||||||
|
|
||||||
echo "Shutdown schedule updated: Mon-Wed=${1}:00, Thu-Sun=${2}:00, Morning end=${3}:00"
|
|
||||||
@ -1,14 +0,0 @@
|
|||||||
[Unit]
|
|
||||||
Description=Re-check workout after early bird grace period expires at 08:30
|
|
||||||
After=graphical-session.target
|
|
||||||
|
|
||||||
[Timer]
|
|
||||||
# Fires every day at 08:30 to verify workout if user logged in during 5–8:30 window
|
|
||||||
OnCalendar=*-*-* 08:30:00
|
|
||||||
Unit=workout-locker.service
|
|
||||||
Persistent=false
|
|
||||||
AccuracySec=1s
|
|
||||||
RandomizedDelaySec=0
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=timers.target
|
|
||||||
@ -1,73 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
# Script to add screen locker to i3 autostart
|
|
||||||
# This will run the workout screen locker on system startup
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
SCREEN_LOCK_PATH="$SCRIPT_DIR/screen_lock.py"
|
|
||||||
I3_CONFIG="$HOME/.config/i3/config"
|
|
||||||
|
|
||||||
# Check if screen_lock.py exists
|
|
||||||
if [ ! -f "$SCREEN_LOCK_PATH" ]; then
|
|
||||||
echo "Error: screen_lock.py not found at $SCREEN_LOCK_PATH"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Make sure screen_lock.py is executable
|
|
||||||
chmod +x "$SCREEN_LOCK_PATH"
|
|
||||||
|
|
||||||
# Check if i3 config exists
|
|
||||||
if [ ! -f "$I3_CONFIG" ]; then
|
|
||||||
echo "Error: i3 config not found at $I3_CONFIG"
|
|
||||||
echo "Please create i3 config first or specify correct path"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Check if autostart line already exists
|
|
||||||
if grep -q "exec.*screen_lock.py" "$I3_CONFIG"; then
|
|
||||||
echo "Screen locker autostart already configured in i3 config"
|
|
||||||
echo "Current line:"
|
|
||||||
grep "exec.*screen_lock.py" "$I3_CONFIG"
|
|
||||||
read -p "Do you want to replace it? (y/n) " -n 1 -r
|
|
||||||
echo
|
|
||||||
if [[ $REPLY =~ ^[Yy]$ ]]; then
|
|
||||||
# Remove old line
|
|
||||||
sed -i '/exec.*screen_lock\.py/d' "$I3_CONFIG"
|
|
||||||
else
|
|
||||||
echo "Keeping existing configuration"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Add autostart line to i3 config
|
|
||||||
echo "" >>"$I3_CONFIG"
|
|
||||||
echo "# Workout screen locker on startup (production mode)" >>"$I3_CONFIG"
|
|
||||||
echo "exec --no-startup-id python3 $SCREEN_LOCK_PATH --production" >>"$I3_CONFIG"
|
|
||||||
|
|
||||||
echo "✓ Screen locker added to i3 autostart (production mode)"
|
|
||||||
echo "✓ Configuration added to: $I3_CONFIG"
|
|
||||||
echo ""
|
|
||||||
echo "The screen locker will run on next i3 restart/login"
|
|
||||||
echo ""
|
|
||||||
echo "To test now, run: i3-msg restart"
|
|
||||||
echo "To run in demo mode, remove --production flag from $I3_CONFIG"
|
|
||||||
|
|
||||||
# Check autostart installation status
|
|
||||||
echo ""
|
|
||||||
echo "=== Autostart Status ==="
|
|
||||||
if grep -q "exec.*screen_lock.py" "$I3_CONFIG"; then
|
|
||||||
echo "✓ i3 autostart: INSTALLED"
|
|
||||||
grep "exec.*screen_lock.py" "$I3_CONFIG"
|
|
||||||
else
|
|
||||||
echo "✗ i3 autostart: NOT INSTALLED"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if systemctl --user is-enabled workout-locker.service &>/dev/null; then
|
|
||||||
echo "✓ systemd service: INSTALLED and enabled"
|
|
||||||
else
|
|
||||||
echo " systemd service: not installed"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Immediately check if today's workout is done; block if not
|
|
||||||
echo ""
|
|
||||||
echo "=== Checking today's workout status ==="
|
|
||||||
python3 "$SCREEN_LOCK_PATH" --production
|
|
||||||
@ -1,92 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
# Install workout locker as a systemd user service
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
SCREEN_LOCK_PATH="$SCRIPT_DIR/screen_lock.py"
|
|
||||||
SERVICE_FILE="$SCRIPT_DIR/workout-locker.service"
|
|
||||||
EARLY_BIRD_TIMER_FILE="$SCRIPT_DIR/early-bird-workout-check.timer"
|
|
||||||
USER_SERVICE_DIR="$HOME/.config/systemd/user"
|
|
||||||
SERVICE_NAME="workout-locker.service"
|
|
||||||
EARLY_BIRD_TIMER_NAME="early-bird-workout-check.timer"
|
|
||||||
|
|
||||||
# Check if service is already installed
|
|
||||||
if [ -f "$USER_SERVICE_DIR/$SERVICE_NAME" ]; then
|
|
||||||
echo "Screen locker systemd service is already installed."
|
|
||||||
echo "Current status:"
|
|
||||||
systemctl --user status "$SERVICE_NAME" --no-pager || true
|
|
||||||
echo ""
|
|
||||||
read -p "Do you want to reinstall/update it? (y/n) " -n 1 -r
|
|
||||||
echo
|
|
||||||
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
|
||||||
echo "Keeping existing installation"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Create user systemd directory if it doesn't exist
|
|
||||||
mkdir -p "$USER_SERVICE_DIR"
|
|
||||||
|
|
||||||
# Remove old timer if it was previously installed
|
|
||||||
if systemctl --user is-active "workout-locker.timer" &>/dev/null; then
|
|
||||||
systemctl --user disable --now "workout-locker.timer" 2>/dev/null || true
|
|
||||||
fi
|
|
||||||
rm -f "$USER_SERVICE_DIR/workout-locker.timer"
|
|
||||||
|
|
||||||
# Copy service file to user systemd directory
|
|
||||||
cp "$SERVICE_FILE" "$USER_SERVICE_DIR/$SERVICE_NAME"
|
|
||||||
|
|
||||||
# Copy early bird timer
|
|
||||||
cp "$EARLY_BIRD_TIMER_FILE" "$USER_SERVICE_DIR/$EARLY_BIRD_TIMER_NAME"
|
|
||||||
|
|
||||||
# Update paths in the service file to use absolute paths
|
|
||||||
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
|
||||||
sed -i "s|WorkingDirectory=.*|WorkingDirectory=$REPO_ROOT|" "$USER_SERVICE_DIR/$SERVICE_NAME"
|
|
||||||
sed -i "s|Environment=PYTHONPATH=.*|Environment=PYTHONPATH=$REPO_ROOT|" "$USER_SERVICE_DIR/$SERVICE_NAME"
|
|
||||||
sed -i "s|ExecStart=/usr/bin/python3.*|ExecStart=/usr/bin/python3 -m python_pkg.screen_locker.screen_lock --production|" "$USER_SERVICE_DIR/$SERVICE_NAME"
|
|
||||||
|
|
||||||
# Reload systemd daemon
|
|
||||||
systemctl --user daemon-reload
|
|
||||||
|
|
||||||
# Enable the service to start on login (one-shot, no periodic timer)
|
|
||||||
systemctl --user enable "$SERVICE_NAME"
|
|
||||||
|
|
||||||
# Enable the early bird re-check timer
|
|
||||||
systemctl --user enable --now "$EARLY_BIRD_TIMER_NAME"
|
|
||||||
|
|
||||||
echo "✓ Workout locker service installed"
|
|
||||||
echo "✓ Early bird re-check timer installed (fires daily at 08:30)"
|
|
||||||
echo "✓ Service will start automatically on next login"
|
|
||||||
echo ""
|
|
||||||
echo "To start now: systemctl --user start workout-locker"
|
|
||||||
echo "To check status: systemctl --user status workout-locker"
|
|
||||||
echo "To stop: systemctl --user stop workout-locker"
|
|
||||||
echo "To disable autostart: systemctl --user disable workout-locker"
|
|
||||||
|
|
||||||
# Check autostart installation status
|
|
||||||
echo ""
|
|
||||||
echo "=== Autostart Status ==="
|
|
||||||
if systemctl --user is-enabled "$SERVICE_NAME" &>/dev/null; then
|
|
||||||
echo "✓ systemd service: INSTALLED and enabled"
|
|
||||||
else
|
|
||||||
echo "✗ systemd service: NOT enabled"
|
|
||||||
fi
|
|
||||||
if systemctl --user is-enabled "$EARLY_BIRD_TIMER_NAME" &>/dev/null; then
|
|
||||||
echo "✓ early bird timer: INSTALLED and enabled"
|
|
||||||
else
|
|
||||||
echo "✗ early bird timer: NOT enabled"
|
|
||||||
fi
|
|
||||||
|
|
||||||
I3_CONFIG="$HOME/.config/i3/config"
|
|
||||||
if [ -f "$I3_CONFIG" ] && grep -q "exec.*screen_lock.py" "$I3_CONFIG"; then
|
|
||||||
echo "✓ i3 autostart: INSTALLED"
|
|
||||||
else
|
|
||||||
echo " i3 autostart: not installed"
|
|
||||||
echo ""
|
|
||||||
echo "To add i3 startup hook (recommended), add this line to $I3_CONFIG:"
|
|
||||||
echo " exec --no-startup-id /usr/bin/python3 -m python_pkg.screen_locker.screen_lock --production"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Immediately check if today's workout is done; block if not
|
|
||||||
echo ""
|
|
||||||
echo "=== Checking today's workout status ==="
|
|
||||||
PYTHONPATH="$(cd "$SCRIPT_DIR/../.." && pwd)" python3 "$SCREEN_LOCK_PATH" --production
|
|
||||||
@ -1,33 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
# Script to remove screen locker from i3 autostart
|
|
||||||
|
|
||||||
I3_CONFIG="$HOME/.config/i3/config"
|
|
||||||
|
|
||||||
# Check if i3 config exists
|
|
||||||
if [ ! -f "$I3_CONFIG" ]; then
|
|
||||||
echo "Error: i3 config not found at $I3_CONFIG"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Check if autostart line exists
|
|
||||||
if ! grep -q "exec.*screen_lock.py" "$I3_CONFIG"; then
|
|
||||||
echo "Screen locker autostart not found in i3 config"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Show what will be removed
|
|
||||||
echo "Found screen locker configuration:"
|
|
||||||
grep -B1 "exec.*screen_lock.py" "$I3_CONFIG"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
read -p "Remove screen locker from autostart? (y/n) " -n 1 -r
|
|
||||||
echo
|
|
||||||
if [[ $REPLY =~ ^[Yy]$ ]]; then
|
|
||||||
# Remove the autostart lines
|
|
||||||
sed -i '/# Workout screen locker on startup/d' "$I3_CONFIG"
|
|
||||||
sed -i '/exec.*screen_lock\.py/d' "$I3_CONFIG"
|
|
||||||
echo "✓ Screen locker removed from i3 autostart"
|
|
||||||
echo "Changes will take effect on next i3 restart"
|
|
||||||
else
|
|
||||||
echo "Cancelled"
|
|
||||||
fi
|
|
||||||
@ -1,23 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
# Remove workout locker systemd service
|
|
||||||
|
|
||||||
SERVICE_NAME="workout-locker.service"
|
|
||||||
TIMER_NAME="workout-locker.timer"
|
|
||||||
USER_SERVICE_DIR="$HOME/.config/systemd/user"
|
|
||||||
|
|
||||||
# Stop the service and timer if running
|
|
||||||
systemctl --user stop "$TIMER_NAME" 2>/dev/null
|
|
||||||
systemctl --user stop "$SERVICE_NAME" 2>/dev/null
|
|
||||||
|
|
||||||
# Disable the service and timer
|
|
||||||
systemctl --user disable "$TIMER_NAME" 2>/dev/null
|
|
||||||
systemctl --user disable "$SERVICE_NAME" 2>/dev/null
|
|
||||||
|
|
||||||
# Remove service and timer files
|
|
||||||
rm -f "$USER_SERVICE_DIR/$SERVICE_NAME"
|
|
||||||
rm -f "$USER_SERVICE_DIR/$TIMER_NAME"
|
|
||||||
|
|
||||||
# Reload systemd daemon
|
|
||||||
systemctl --user daemon-reload
|
|
||||||
|
|
||||||
echo "✓ Workout locker service and timer removed"
|
|
||||||
@ -1,11 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -e
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
|
||||||
VENV="$REPO_ROOT/.venv"
|
|
||||||
[[ ! -d "$VENV" ]] && python3 -m venv "$VENV"
|
|
||||||
# tkinter is from Python stdlib; install python-tk system package if missing:
|
|
||||||
# Arch: sudo pacman -S python-tk
|
|
||||||
# Debian: sudo apt-get install python3-tk
|
|
||||||
cd "$REPO_ROOT"
|
|
||||||
"$VENV/bin/python" -m python_pkg.screen_locker.screen_lock "$@"
|
|
||||||
@ -1 +0,0 @@
|
|||||||
["2026-05-19", "2026-05-20", "2026-05-21"]
|
|
||||||
@ -1,454 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Screen locker with workout verification for Arch Linux / i3wm.
|
|
||||||
|
|
||||||
Requires user to log their workout to unlock the screen.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
from pathlib import Path
|
|
||||||
import sys
|
|
||||||
import tkinter as tk
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
from python_pkg.screen_locker import _sick_tracker
|
|
||||||
from python_pkg.screen_locker._constants import (
|
|
||||||
EARLY_BIRD_END_HOUR,
|
|
||||||
EARLY_BIRD_END_MINUTE,
|
|
||||||
EARLY_BIRD_START_HOUR,
|
|
||||||
HMAC_KEY_FILE,
|
|
||||||
MAX_CLOCK_SKEW_SECONDS,
|
|
||||||
MIN_WORKOUT_DURATION_MINUTES,
|
|
||||||
PHONE_PENALTY_DELAY_DEMO,
|
|
||||||
PHONE_PENALTY_DELAY_PRODUCTION,
|
|
||||||
SCHEDULED_SKIPS_FILE,
|
|
||||||
SICK_LOCKOUT_SECONDS,
|
|
||||||
STRONGLIFTS_DB_REMOTE,
|
|
||||||
)
|
|
||||||
from python_pkg.screen_locker._early_bird import EarlyBirdMixin
|
|
||||||
from python_pkg.screen_locker._log_integrity import (
|
|
||||||
_load_hmac_key,
|
|
||||||
compute_entry_hmac,
|
|
||||||
verify_entry_hmac,
|
|
||||||
)
|
|
||||||
from python_pkg.screen_locker._phone_verification import PhoneVerificationMixin
|
|
||||||
from python_pkg.screen_locker._shutdown import ShutdownMixin
|
|
||||||
from python_pkg.screen_locker._sick_dialog import SickDialogMixin
|
|
||||||
from python_pkg.screen_locker._ui_flows import UIFlowsMixin
|
|
||||||
from python_pkg.screen_locker._weekly_check import (
|
|
||||||
WEEKLY_WORKOUT_MINIMUM,
|
|
||||||
has_weekly_minimum,
|
|
||||||
is_relaxed_day,
|
|
||||||
)
|
|
||||||
from python_pkg.screen_locker._window_setup import WindowSetupMixin
|
|
||||||
from python_pkg.wake_alarm._state import has_workout_skip_today
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from collections.abc import Callable
|
|
||||||
from concurrent.futures import Future
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"EARLY_BIRD_END_HOUR",
|
|
||||||
"EARLY_BIRD_END_MINUTE",
|
|
||||||
"EARLY_BIRD_START_HOUR",
|
|
||||||
"HMAC_KEY_FILE",
|
|
||||||
"MAX_CLOCK_SKEW_SECONDS",
|
|
||||||
"MIN_WORKOUT_DURATION_MINUTES",
|
|
||||||
"PHONE_PENALTY_DELAY_DEMO",
|
|
||||||
"PHONE_PENALTY_DELAY_PRODUCTION",
|
|
||||||
"SCHEDULED_SKIPS_FILE",
|
|
||||||
"SICK_LOCKOUT_SECONDS",
|
|
||||||
"STRONGLIFTS_DB_REMOTE",
|
|
||||||
"WEEKLY_WORKOUT_MINIMUM",
|
|
||||||
"ScreenLocker",
|
|
||||||
]
|
|
||||||
|
|
||||||
_logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
def _assert_not_under_pytest() -> None:
|
|
||||||
"""Raise if the screen locker is being created inside a pytest run.
|
|
||||||
|
|
||||||
Defence-in-depth: prevents a real fullscreen Tk window from locking
|
|
||||||
the user's screen when tests forget to mock ``tk.Tk``.
|
|
||||||
The check is cheap (one dict lookup) and only fires during testing.
|
|
||||||
"""
|
|
||||||
if "pytest" in sys.modules and getattr(tk, "__name__", "") == "tkinter":
|
|
||||||
msg = (
|
|
||||||
"SAFETY: ScreenLocker.__init__ called under pytest with "
|
|
||||||
"real tkinter — tk.Tk is not mocked"
|
|
||||||
)
|
|
||||||
raise RuntimeError(msg)
|
|
||||||
|
|
||||||
|
|
||||||
class ScreenLocker(
|
|
||||||
EarlyBirdMixin,
|
|
||||||
WindowSetupMixin,
|
|
||||||
ShutdownMixin,
|
|
||||||
PhoneVerificationMixin,
|
|
||||||
SickDialogMixin,
|
|
||||||
UIFlowsMixin,
|
|
||||||
):
|
|
||||||
"""Screen locker that requires workout logging to unlock."""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
demo_mode: bool = True,
|
|
||||||
verify_only: bool = False,
|
|
||||||
) -> None:
|
|
||||||
"""Initialize screen locker with optional demo mode."""
|
|
||||||
_assert_not_under_pytest()
|
|
||||||
script_dir = Path(__file__).resolve().parent
|
|
||||||
self.log_file = script_dir / "workout_log.json"
|
|
||||||
self.verify_only = verify_only
|
|
||||||
self.workout_data: dict[str, str] = {}
|
|
||||||
self._relaxed_day_mode: bool = False
|
|
||||||
self._check_early_exits(verify_only=verify_only)
|
|
||||||
self.root = tk.Tk()
|
|
||||||
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
|
|
||||||
if verify_only:
|
|
||||||
self._setup_verify_window()
|
|
||||||
elif self._relaxed_day_mode:
|
|
||||||
self._setup_relaxed_day_window()
|
|
||||||
else:
|
|
||||||
self._setup_window()
|
|
||||||
if demo_mode:
|
|
||||||
self._setup_demo_close_button()
|
|
||||||
self.container = tk.Frame(self.root, bg="#1a1a1a")
|
|
||||||
self.container.place(relx=0.5, rely=0.5, anchor="center")
|
|
||||||
self._phone_future: Future[tuple[str, str]] | None = None
|
|
||||||
if verify_only:
|
|
||||||
self._start_verify_workout_check()
|
|
||||||
elif self._relaxed_day_mode:
|
|
||||||
self._start_relaxed_day_flow()
|
|
||||||
else:
|
|
||||||
self._start_phone_check()
|
|
||||||
self._grab_input()
|
|
||||||
|
|
||||||
def _is_sick_day_log(self) -> bool:
|
|
||||||
"""Check if today's workout log is a sick day (not yet verified)."""
|
|
||||||
if not self.log_file.exists():
|
|
||||||
return False
|
|
||||||
try:
|
|
||||||
with self.log_file.open() as f:
|
|
||||||
logs = json.load(f)
|
|
||||||
except (OSError, json.JSONDecodeError):
|
|
||||||
return False
|
|
||||||
today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d")
|
|
||||||
entry = logs.get(today)
|
|
||||||
if entry is None:
|
|
||||||
return False
|
|
||||||
return entry.get("workout_data", {}).get("type") == "sick_day"
|
|
||||||
|
|
||||||
def _check_early_exits(self, *, verify_only: bool) -> None:
|
|
||||||
"""Check startup conditions and exit early when appropriate."""
|
|
||||||
if verify_only:
|
|
||||||
if not self._is_sick_day_log():
|
|
||||||
_logger.info(
|
|
||||||
"No sick day logged today. Nothing to verify.",
|
|
||||||
)
|
|
||||||
sys.exit(0)
|
|
||||||
return
|
|
||||||
self._check_non_verify_exits()
|
|
||||||
|
|
||||||
def _check_today_state_exits(self) -> bool:
|
|
||||||
"""Handle early-bird and today's log states. Return True to stop startup."""
|
|
||||||
if self._is_early_bird_log() and not self._is_early_bird_time():
|
|
||||||
if self._try_auto_upgrade_early_bird():
|
|
||||||
_logger.info("Auto-upgraded early_bird entry to phone_verified.")
|
|
||||||
sys.exit(0)
|
|
||||||
return True
|
|
||||||
return False # Expired early bird, upgrade unavailable — full lock.
|
|
||||||
if self._is_early_bird_log():
|
|
||||||
_logger.info("Early bird window still active — skipping lock.")
|
|
||||||
elif self._is_sick_day_log() and self._try_auto_upgrade_sick_day():
|
|
||||||
_logger.info("Auto-upgraded today's sick_day entry to phone_verified.")
|
|
||||||
elif self.has_logged_today():
|
|
||||||
_logger.info("Workout already logged today. Skipping screen lock.")
|
|
||||||
elif has_workout_skip_today():
|
|
||||||
_logger.info("Wake alarm earned workout skip. Skipping screen lock.")
|
|
||||||
elif self._is_early_bird_time():
|
|
||||||
self._save_early_bird_log()
|
|
||||||
_logger.info("Early bird time — skipping lock, will re-check at 08:30.")
|
|
||||||
else:
|
|
||||||
return False
|
|
||||||
sys.exit(0)
|
|
||||||
return True
|
|
||||||
|
|
||||||
def _check_non_verify_exits(self) -> None:
|
|
||||||
"""Check all normal (non-verify) startup early-exit conditions."""
|
|
||||||
if self._is_scheduled_skip_today():
|
|
||||||
_logger.info("Today is a scheduled skip day. Skipping screen lock.")
|
|
||||||
sys.exit(0)
|
|
||||||
return
|
|
||||||
if self._check_today_state_exits():
|
|
||||||
return
|
|
||||||
# Day-of-week routing: Tue/Wed/Thu relaxed (optional), Fri-Mon enforced.
|
|
||||||
if is_relaxed_day():
|
|
||||||
_logger.info("Relaxed day (Tue-Thu) - showing optional workout prompt.")
|
|
||||||
self._relaxed_day_mode = True
|
|
||||||
return
|
|
||||||
# Fri-Mon: skip lock when weekly minimum is already met.
|
|
||||||
if has_weekly_minimum(self.log_file):
|
|
||||||
_logger.info(
|
|
||||||
"Weekly minimum of %d workouts met. Skipping screen lock.",
|
|
||||||
WEEKLY_WORKOUT_MINIMUM,
|
|
||||||
)
|
|
||||||
sys.exit(0)
|
|
||||||
return
|
|
||||||
|
|
||||||
def _try_auto_upgrade_sick_day(self) -> bool:
|
|
||||||
"""Silently upgrade today's sick_day entry if phone shows a workout."""
|
|
||||||
try:
|
|
||||||
status, message = self._verify_phone_workout()
|
|
||||||
except (OSError, RuntimeError) as exc:
|
|
||||||
_logger.info("Auto-upgrade phone check failed: %s", exc)
|
|
||||||
return False
|
|
||||||
if status != "verified":
|
|
||||||
_logger.info(
|
|
||||||
"Auto-upgrade skipped (phone status=%s): %s",
|
|
||||||
status,
|
|
||||||
message,
|
|
||||||
)
|
|
||||||
return False
|
|
||||||
self.workout_data["type"] = "phone_verified"
|
|
||||||
self.workout_data["source"] = message
|
|
||||||
self.workout_data["after_sick_day"] = "true"
|
|
||||||
self._adjust_shutdown_time_later()
|
|
||||||
self.save_workout_log()
|
|
||||||
return True
|
|
||||||
|
|
||||||
def clear_container(self) -> None:
|
|
||||||
"""Remove all widgets from the main container."""
|
|
||||||
for widget in self.container.winfo_children():
|
|
||||||
widget.destroy()
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# UI helper methods
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def _label(
|
|
||||||
self,
|
|
||||||
text: str,
|
|
||||||
*,
|
|
||||||
font_size: int = 36,
|
|
||||||
color: str = "white",
|
|
||||||
pady: int = 20,
|
|
||||||
) -> tk.Label:
|
|
||||||
"""Create and pack a bold label in the container."""
|
|
||||||
label = tk.Label(
|
|
||||||
self.container,
|
|
||||||
text=text,
|
|
||||||
font=("Arial", font_size, "bold"),
|
|
||||||
fg=color,
|
|
||||||
bg="#1a1a1a",
|
|
||||||
)
|
|
||||||
label.pack(pady=pady)
|
|
||||||
return label
|
|
||||||
|
|
||||||
def _text(
|
|
||||||
self,
|
|
||||||
text: str,
|
|
||||||
*,
|
|
||||||
font_size: int = 18,
|
|
||||||
color: str = "white",
|
|
||||||
pady: int = 10,
|
|
||||||
) -> tk.Label:
|
|
||||||
"""Create and pack a non-bold text label in the container."""
|
|
||||||
label = tk.Label(
|
|
||||||
self.container,
|
|
||||||
text=text,
|
|
||||||
font=("Arial", font_size),
|
|
||||||
fg=color,
|
|
||||||
bg="#1a1a1a",
|
|
||||||
)
|
|
||||||
label.pack(pady=pady)
|
|
||||||
return label
|
|
||||||
|
|
||||||
def _button(
|
|
||||||
self,
|
|
||||||
parent: tk.Widget,
|
|
||||||
text: str,
|
|
||||||
*,
|
|
||||||
bg: str,
|
|
||||||
command: Callable[[], None],
|
|
||||||
width: int = 10,
|
|
||||||
) -> tk.Button:
|
|
||||||
"""Create a styled button (caller must pack)."""
|
|
||||||
return tk.Button(
|
|
||||||
parent,
|
|
||||||
text=text,
|
|
||||||
font=("Arial", 24, "bold"),
|
|
||||||
bg=bg,
|
|
||||||
fg="white",
|
|
||||||
width=width,
|
|
||||||
command=command,
|
|
||||||
cursor="hand2" if self.demo_mode else "",
|
|
||||||
)
|
|
||||||
|
|
||||||
def _button_row(self) -> tk.Frame:
|
|
||||||
"""Create and pack a horizontal button container."""
|
|
||||||
frame = tk.Frame(self.container, bg="#1a1a1a")
|
|
||||||
frame.pack(pady=20)
|
|
||||||
return frame
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Unlock, logging
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def _try_adjust_shutdown_for_workout(self) -> bool:
|
|
||||||
"""Try to adjust shutdown time later for actual workouts."""
|
|
||||||
workout_type = self.workout_data.get("type", "")
|
|
||||||
if workout_type != "phone_verified":
|
|
||||||
return False
|
|
||||||
adjusted = self._adjust_shutdown_time_later()
|
|
||||||
if adjusted:
|
|
||||||
_logger.info("Shutdown time moved 1.5 hours later as workout reward")
|
|
||||||
return adjusted
|
|
||||||
|
|
||||||
def _clear_debt_on_verified_workout(self) -> int | None:
|
|
||||||
"""Decrement workout debt by one for a verified workout.
|
|
||||||
|
|
||||||
Returns the new debt count, or ``None`` when this wasn't a
|
|
||||||
phone-verified workout.
|
|
||||||
"""
|
|
||||||
if self.workout_data.get("type") != "phone_verified":
|
|
||||||
return None
|
|
||||||
history = _sick_tracker.load_history()
|
|
||||||
if history.debt <= 0:
|
|
||||||
return 0
|
|
||||||
new_debt = _sick_tracker.clear_one_debt(history)
|
|
||||||
_sick_tracker.save_history(history)
|
|
||||||
return new_debt
|
|
||||||
|
|
||||||
def unlock_screen(self) -> None:
|
|
||||||
"""Save workout log and display success message."""
|
|
||||||
self.save_workout_log()
|
|
||||||
shutdown_adjusted = self._try_adjust_shutdown_for_workout()
|
|
||||||
new_debt = self._clear_debt_on_verified_workout()
|
|
||||||
self.clear_container()
|
|
||||||
self._label("Great job! 💪", font_size=48, color="#00ff00", pady=30)
|
|
||||||
if shutdown_adjusted:
|
|
||||||
self._text(
|
|
||||||
"Shutdown time +1.5h later! 🎁",
|
|
||||||
font_size=24,
|
|
||||||
color="#ffaa00",
|
|
||||||
)
|
|
||||||
if new_debt is not None:
|
|
||||||
self._text(
|
|
||||||
f"Workout debt: {new_debt}",
|
|
||||||
font_size=20,
|
|
||||||
color="#ffaa00" if new_debt > 0 else "#888888",
|
|
||||||
)
|
|
||||||
self._text("Screen Unlocked!", font_size=36, pady=20)
|
|
||||||
if self.workout_data.get("type") == "phone_verified":
|
|
||||||
self.root.after(
|
|
||||||
1500,
|
|
||||||
lambda: self._show_commitment_prompt(on_done=self.close),
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
self.root.after(1500, self.close)
|
|
||||||
|
|
||||||
def has_logged_today(self) -> bool:
|
|
||||||
"""Check if workout has been logged today with valid HMAC."""
|
|
||||||
if not self.log_file.exists():
|
|
||||||
return False
|
|
||||||
|
|
||||||
try:
|
|
||||||
with self.log_file.open() as f:
|
|
||||||
logs = json.load(f)
|
|
||||||
except (OSError, json.JSONDecodeError):
|
|
||||||
return False
|
|
||||||
else:
|
|
||||||
today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d")
|
|
||||||
entry = logs.get(today)
|
|
||||||
if entry is None:
|
|
||||||
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:
|
|
||||||
_logger.info(
|
|
||||||
"HMAC key unavailable — accepting unsigned entry",
|
|
||||||
)
|
|
||||||
return entry.get("workout_data", {}).get("type") != "early_bird"
|
|
||||||
_logger.warning(
|
|
||||||
"HMAC verification failed for today's log entry",
|
|
||||||
)
|
|
||||||
return False
|
|
||||||
|
|
||||||
def _load_existing_logs(self) -> dict:
|
|
||||||
"""Load existing workout logs from file."""
|
|
||||||
if not self.log_file.exists():
|
|
||||||
return {}
|
|
||||||
try:
|
|
||||||
with self.log_file.open() as f:
|
|
||||||
return json.load(f)
|
|
||||||
except (OSError, json.JSONDecodeError):
|
|
||||||
return {}
|
|
||||||
|
|
||||||
def _is_scheduled_skip_today(self) -> bool:
|
|
||||||
"""Return True if today's date is listed in the scheduled skips file."""
|
|
||||||
if not SCHEDULED_SKIPS_FILE.exists():
|
|
||||||
return False
|
|
||||||
try:
|
|
||||||
with SCHEDULED_SKIPS_FILE.open() as f:
|
|
||||||
skips = json.load(f)
|
|
||||||
except (OSError, json.JSONDecodeError):
|
|
||||||
return False
|
|
||||||
today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d")
|
|
||||||
return today in skips
|
|
||||||
|
|
||||||
def save_workout_log(self) -> None:
|
|
||||||
"""Save workout data to log file with HMAC signature."""
|
|
||||||
logs = self._load_existing_logs()
|
|
||||||
today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d")
|
|
||||||
entry: dict[str, object] = {
|
|
||||||
"timestamp": datetime.now(tz=timezone.utc).isoformat(),
|
|
||||||
"workout_data": self.workout_data,
|
|
||||||
}
|
|
||||||
signature = compute_entry_hmac(entry)
|
|
||||||
if signature is not None:
|
|
||||||
entry["hmac"] = signature
|
|
||||||
else:
|
|
||||||
_logger.warning("HMAC key unavailable — saving unsigned entry")
|
|
||||||
logs[today] = entry
|
|
||||||
try:
|
|
||||||
with self.log_file.open("w") as f:
|
|
||||||
json.dump(logs, f, indent=2)
|
|
||||||
except OSError as e:
|
|
||||||
_logger.warning("Could not save workout log: %s", e)
|
|
||||||
|
|
||||||
def close(self) -> None:
|
|
||||||
"""Close the application and exit."""
|
|
||||||
if not self.demo_mode:
|
|
||||||
self._restore_vt_switching()
|
|
||||||
self.root.destroy()
|
|
||||||
sys.exit(0)
|
|
||||||
|
|
||||||
def run(self) -> None:
|
|
||||||
"""Start the Tkinter main event loop."""
|
|
||||||
self.root.mainloop()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
# Check for --production flag
|
|
||||||
demo_mode = True # Default to demo mode for safety
|
|
||||||
verify_only = "--verify-workout" in sys.argv
|
|
||||||
|
|
||||||
if "--production" in sys.argv:
|
|
||||||
demo_mode = False
|
|
||||||
|
|
||||||
locker = ScreenLocker(
|
|
||||||
demo_mode=demo_mode,
|
|
||||||
verify_only=verify_only,
|
|
||||||
)
|
|
||||||
locker.run()
|
|
||||||
@ -1 +0,0 @@
|
|||||||
"""Tests for screen_locker module."""
|
|
||||||
@ -1,273 +0,0 @@
|
|||||||
"""Shared fixtures and helpers for screen_locker tests.
|
|
||||||
|
|
||||||
Safety:
|
|
||||||
``_block_real_tk_and_exit`` (autouse) replaces the **entire** ``tk``
|
|
||||||
module reference inside ``screen_lock`` with a MagicMock 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.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
import tkinter as tk
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
from unittest.mock import MagicMock, patch
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from python_pkg.screen_locker.screen_lock import ScreenLocker
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from collections.abc import Generator, Iterator
|
|
||||||
from typing import Literal
|
|
||||||
|
|
||||||
|
|
||||||
def _make_mock_tk() -> MagicMock:
|
|
||||||
"""Build a MagicMock that stands in for the ``tkinter`` module."""
|
|
||||||
mock = MagicMock()
|
|
||||||
mock_root = MagicMock()
|
|
||||||
mock_root.winfo_screenwidth.return_value = 1920
|
|
||||||
mock_root.winfo_screenheight.return_value = 1080
|
|
||||||
mock.Tk.return_value = mock_root
|
|
||||||
|
|
||||||
mock_frame = MagicMock()
|
|
||||||
mock_frame.winfo_children.return_value = []
|
|
||||||
mock.Frame.return_value = mock_frame
|
|
||||||
|
|
||||||
# Keep real TclError so ``except tk.TclError`` still works.
|
|
||||||
mock.TclError = tk.TclError
|
|
||||||
return mock
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
|
||||||
def _block_real_tk_and_exit() -> Iterator[None]:
|
|
||||||
"""Replace the whole ``tk`` module and ``sys.exit`` for every test.
|
|
||||||
|
|
||||||
Patching the entire module (not just ``tk.Tk``) ensures that
|
|
||||||
**nothing** in tkinter can touch the real display server.
|
|
||||||
"""
|
|
||||||
mock = _make_mock_tk()
|
|
||||||
|
|
||||||
with (
|
|
||||||
patch("python_pkg.screen_locker.screen_lock.tk", mock),
|
|
||||||
patch("python_pkg.screen_locker._sick_dialog.tk", mock),
|
|
||||||
patch("python_pkg.screen_locker.screen_lock.sys.exit"),
|
|
||||||
):
|
|
||||||
yield
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
|
||||||
def mock_subprocess_run() -> Generator[MagicMock]:
|
|
||||||
"""Block real subprocess calls (e.g. setxkbmap) for every test.
|
|
||||||
|
|
||||||
Also exposed as a named fixture so individual tests can assert
|
|
||||||
on the calls made (e.g. VT switching tests).
|
|
||||||
|
|
||||||
``shutil.which`` is mocked to return a stable fake path so tests work
|
|
||||||
regardless of whether setxkbmap is installed on the host machine.
|
|
||||||
"""
|
|
||||||
with (
|
|
||||||
patch(
|
|
||||||
"python_pkg.screen_locker._window_setup.shutil.which",
|
|
||||||
return_value="/usr/bin/setxkbmap",
|
|
||||||
),
|
|
||||||
patch("python_pkg.screen_locker._window_setup.subprocess.run") as mock,
|
|
||||||
):
|
|
||||||
yield mock
|
|
||||||
|
|
||||||
|
|
||||||
@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."""
|
|
||||||
target = tmp_path / "sick_history.json"
|
|
||||||
with (
|
|
||||||
patch(
|
|
||||||
"python_pkg.screen_locker._sick_tracker.SICK_HISTORY_FILE",
|
|
||||||
target,
|
|
||||||
),
|
|
||||||
patch(
|
|
||||||
"python_pkg.screen_locker._constants.SICK_HISTORY_FILE",
|
|
||||||
target,
|
|
||||||
),
|
|
||||||
):
|
|
||||||
yield
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
|
||||||
def _isolate_scheduled_skips(tmp_path: Path) -> Iterator[None]:
|
|
||||||
"""Redirect SCHEDULED_SKIPS_FILE to tmp_path so tests use a clean file."""
|
|
||||||
target = tmp_path / "scheduled_skips.json"
|
|
||||||
with patch(
|
|
||||||
"python_pkg.screen_locker.screen_lock.SCHEDULED_SKIPS_FILE",
|
|
||||||
target,
|
|
||||||
):
|
|
||||||
yield
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
|
||||||
def _mock_weekly_logic() -> Iterator[None]:
|
|
||||||
"""Default to Fri-Mon enforcement with weekly minimum not yet met.
|
|
||||||
|
|
||||||
Without this, tests that run on a Tue/Wed/Thu would hit the relaxed-day
|
|
||||||
branch instead of the full-lock path that existing tests expect.
|
|
||||||
Setting has_weekly_minimum=False ensures the full lock is shown
|
|
||||||
(weekly quota not reached → enforce).
|
|
||||||
"""
|
|
||||||
with (
|
|
||||||
patch(
|
|
||||||
"python_pkg.screen_locker.screen_lock.is_relaxed_day",
|
|
||||||
return_value=False,
|
|
||||||
),
|
|
||||||
patch(
|
|
||||||
"python_pkg.screen_locker.screen_lock.has_weekly_minimum",
|
|
||||||
return_value=False,
|
|
||||||
),
|
|
||||||
):
|
|
||||||
yield
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def mock_tk() -> Generator[MagicMock]:
|
|
||||||
"""Mock tkinter module for testing without display."""
|
|
||||||
with patch("python_pkg.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
|
|
||||||
|
|
||||||
yield mock
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def mock_sys_exit() -> Generator[MagicMock]:
|
|
||||||
"""Mock sys.exit to prevent test termination."""
|
|
||||||
with patch("python_pkg.screen_locker.screen_lock.sys.exit") as mock:
|
|
||||||
yield mock
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def _mock_sys_exit(mock_sys_exit: MagicMock) -> MagicMock:
|
|
||||||
"""Alias for mock_sys_exit when the return value is unused."""
|
|
||||||
return mock_sys_exit
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def temp_log_file(tmp_path: Path) -> Path:
|
|
||||||
"""Create a temporary log file path."""
|
|
||||||
return tmp_path / "workout_log.json"
|
|
||||||
|
|
||||||
|
|
||||||
def create_locker(
|
|
||||||
_mock_tk: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
*,
|
|
||||||
demo_mode: bool = True,
|
|
||||||
has_logged: bool = False,
|
|
||||||
verify_only: bool = False,
|
|
||||||
is_sick_day_log: bool = False,
|
|
||||||
) -> ScreenLocker:
|
|
||||||
"""Create a ScreenLocker instance with early bird paths disabled."""
|
|
||||||
with (
|
|
||||||
patch.object(Path, "resolve", return_value=tmp_path),
|
|
||||||
patch.object(ScreenLocker, "has_logged_today", return_value=has_logged),
|
|
||||||
patch.object(
|
|
||||||
ScreenLocker,
|
|
||||||
"_is_sick_day_log",
|
|
||||||
return_value=is_sick_day_log,
|
|
||||||
),
|
|
||||||
patch.object(ScreenLocker, "_is_early_bird_log", return_value=False),
|
|
||||||
patch.object(ScreenLocker, "_is_early_bird_time", return_value=False),
|
|
||||||
patch.object(
|
|
||||||
ScreenLocker,
|
|
||||||
"_try_auto_upgrade_early_bird",
|
|
||||||
return_value=False,
|
|
||||||
),
|
|
||||||
patch.object(ScreenLocker, "_start_phone_check"),
|
|
||||||
patch.object(ScreenLocker, "_start_relaxed_day_flow"),
|
|
||||||
patch.object(ScreenLocker, "_start_verify_workout_check"),
|
|
||||||
):
|
|
||||||
return ScreenLocker(
|
|
||||||
demo_mode=demo_mode,
|
|
||||||
verify_only=verify_only,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def create_locker_relaxed_day(
|
|
||||||
_mock_tk: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
*,
|
|
||||||
demo_mode: bool = True,
|
|
||||||
has_logged: bool = False,
|
|
||||||
) -> ScreenLocker:
|
|
||||||
"""Create a ScreenLocker in relaxed-day mode (Tue/Wed/Thu).
|
|
||||||
|
|
||||||
``is_relaxed_day`` returns True so ``_relaxed_day_mode`` is set and
|
|
||||||
``_start_relaxed_day_flow`` is called instead of ``_start_phone_check``.
|
|
||||||
The autouse ``_mock_weekly_logic`` fixture is overridden here.
|
|
||||||
"""
|
|
||||||
with (
|
|
||||||
patch.object(Path, "resolve", return_value=tmp_path),
|
|
||||||
patch.object(ScreenLocker, "has_logged_today", return_value=has_logged),
|
|
||||||
patch.object(ScreenLocker, "_is_sick_day_log", return_value=False),
|
|
||||||
patch.object(ScreenLocker, "_is_early_bird_log", return_value=False),
|
|
||||||
patch.object(ScreenLocker, "_is_early_bird_time", return_value=False),
|
|
||||||
patch.object(ScreenLocker, "_try_auto_upgrade_early_bird", return_value=False),
|
|
||||||
patch("python_pkg.screen_locker.screen_lock.is_relaxed_day", return_value=True),
|
|
||||||
patch(
|
|
||||||
"python_pkg.screen_locker.screen_lock.has_weekly_minimum",
|
|
||||||
return_value=False,
|
|
||||||
),
|
|
||||||
patch.object(ScreenLocker, "_start_phone_check"),
|
|
||||||
patch.object(ScreenLocker, "_start_relaxed_day_flow"),
|
|
||||||
patch.object(ScreenLocker, "_start_verify_workout_check"),
|
|
||||||
):
|
|
||||||
return ScreenLocker(demo_mode=demo_mode)
|
|
||||||
|
|
||||||
|
|
||||||
def create_locker_early_bird(
|
|
||||||
_mock_tk: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
*,
|
|
||||||
state: Literal["none", "log_active", "log_expired"] = "none",
|
|
||||||
has_logged: bool = False,
|
|
||||||
demo_mode: bool = True,
|
|
||||||
) -> ScreenLocker:
|
|
||||||
"""Create a ScreenLocker configured for early bird path testing.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
state: One of:
|
|
||||||
- "none": outside early bird window, no early bird log.
|
|
||||||
- "log_active": early bird log exists, still in window.
|
|
||||||
- "log_expired": early bird log exists, past 8:30 AM.
|
|
||||||
has_logged: Return value for has_logged_today mock.
|
|
||||||
demo_mode: Passed to ScreenLocker constructor.
|
|
||||||
"""
|
|
||||||
is_early_bird_log = state in ("log_active", "log_expired")
|
|
||||||
is_early_bird_time = state == "log_active"
|
|
||||||
with (
|
|
||||||
patch.object(Path, "resolve", return_value=tmp_path),
|
|
||||||
patch.object(ScreenLocker, "has_logged_today", return_value=has_logged),
|
|
||||||
patch.object(ScreenLocker, "_is_sick_day_log", return_value=False),
|
|
||||||
patch.object(
|
|
||||||
ScreenLocker, "_is_early_bird_log", return_value=is_early_bird_log
|
|
||||||
),
|
|
||||||
patch.object(
|
|
||||||
ScreenLocker, "_is_early_bird_time", return_value=is_early_bird_time
|
|
||||||
),
|
|
||||||
patch.object(ScreenLocker, "_try_auto_upgrade_early_bird", return_value=False),
|
|
||||||
patch.object(ScreenLocker, "_start_phone_check"),
|
|
||||||
patch.object(ScreenLocker, "_start_relaxed_day_flow"),
|
|
||||||
patch.object(ScreenLocker, "_start_verify_workout_check"),
|
|
||||||
):
|
|
||||||
return ScreenLocker(demo_mode=demo_mode)
|
|
||||||
@ -1,476 +0,0 @@
|
|||||||
"""Tests for ADB commands, phone connection, and database operations."""
|
|
||||||
# pylint: disable=protected-access,unused-argument
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import sqlite3
|
|
||||||
import subprocess
|
|
||||||
import time
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
from unittest.mock import MagicMock, patch
|
|
||||||
|
|
||||||
from python_pkg.screen_locker.screen_lock import STRONGLIFTS_DB_REMOTE
|
|
||||||
from python_pkg.screen_locker.tests.conftest import create_locker
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
|
|
||||||
class TestRunAdb:
|
|
||||||
"""Tests for _run_adb ADB command execution."""
|
|
||||||
|
|
||||||
def test_run_adb_success(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test successful ADB command."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
mock_result = MagicMock(returncode=0, stdout="ok\n")
|
|
||||||
with patch(
|
|
||||||
"python_pkg.screen_locker._phone_verification.subprocess.run",
|
|
||||||
return_value=mock_result,
|
|
||||||
) as mock_run:
|
|
||||||
success, output = locker._run_adb(["devices"])
|
|
||||||
|
|
||||||
assert success is True
|
|
||||||
assert output == "ok\n"
|
|
||||||
mock_run.assert_called_once()
|
|
||||||
|
|
||||||
def test_run_adb_failure(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test failed ADB command."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
mock_result = MagicMock(returncode=1, stdout="")
|
|
||||||
with patch(
|
|
||||||
"python_pkg.screen_locker._phone_verification.subprocess.run",
|
|
||||||
return_value=mock_result,
|
|
||||||
):
|
|
||||||
success, _output = locker._run_adb(["devices"])
|
|
||||||
|
|
||||||
assert success is False
|
|
||||||
|
|
||||||
def test_run_adb_not_found(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test ADB binary not found."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
with patch(
|
|
||||||
"python_pkg.screen_locker._phone_verification.subprocess.run",
|
|
||||||
side_effect=FileNotFoundError("adb not found"),
|
|
||||||
):
|
|
||||||
success, output = locker._run_adb(["devices"])
|
|
||||||
|
|
||||||
assert success is False
|
|
||||||
assert not output
|
|
||||||
|
|
||||||
def test_run_adb_oserror(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test ADB OSError."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
with patch(
|
|
||||||
"python_pkg.screen_locker._phone_verification.subprocess.run",
|
|
||||||
side_effect=OSError("permission denied"),
|
|
||||||
):
|
|
||||||
success, output = locker._run_adb(["devices"])
|
|
||||||
|
|
||||||
assert success is False
|
|
||||||
assert not output
|
|
||||||
|
|
||||||
def test_run_adb_timeout(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test ADB command timeout."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
with patch(
|
|
||||||
"python_pkg.screen_locker._phone_verification.subprocess.run",
|
|
||||||
side_effect=subprocess.TimeoutExpired("adb", 15),
|
|
||||||
):
|
|
||||||
success, output = locker._run_adb(["devices"])
|
|
||||||
|
|
||||||
assert success is False
|
|
||||||
assert not output
|
|
||||||
|
|
||||||
|
|
||||||
class TestAdbShell:
|
|
||||||
"""Tests for _adb_shell method."""
|
|
||||||
|
|
||||||
def test_adb_shell_no_root(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test ADB shell without root."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
object.__setattr__(
|
|
||||||
locker,
|
|
||||||
"_run_adb",
|
|
||||||
MagicMock(
|
|
||||||
return_value=(True, "output"),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
success, output = locker._adb_shell("ls /sdcard")
|
|
||||||
|
|
||||||
locker._run_adb.assert_called_once_with(["shell", "ls /sdcard"])
|
|
||||||
assert success is True
|
|
||||||
assert output == "output"
|
|
||||||
|
|
||||||
def test_adb_shell_with_root(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test ADB shell with root."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
object.__setattr__(
|
|
||||||
locker,
|
|
||||||
"_run_adb",
|
|
||||||
MagicMock(
|
|
||||||
return_value=(True, "output"),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
success, _output = locker._adb_shell("ls /data", root=True)
|
|
||||||
|
|
||||||
locker._run_adb.assert_called_once_with(
|
|
||||||
["shell", "su", "-c", "ls /data"],
|
|
||||||
)
|
|
||||||
assert success is True
|
|
||||||
|
|
||||||
|
|
||||||
class TestIsPhoneConnected:
|
|
||||||
"""Tests for _is_phone_connected method."""
|
|
||||||
|
|
||||||
def test_phone_connected(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test phone detected as connected."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
object.__setattr__(
|
|
||||||
locker,
|
|
||||||
"_run_adb",
|
|
||||||
MagicMock(
|
|
||||||
return_value=(
|
|
||||||
True,
|
|
||||||
"List of devices attached\nABC123\tdevice\n\n",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
assert locker._is_phone_connected() is True
|
|
||||||
|
|
||||||
def test_phone_not_connected(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test no phone connected."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
object.__setattr__(
|
|
||||||
locker,
|
|
||||||
"_run_adb",
|
|
||||||
MagicMock(
|
|
||||||
return_value=(True, "List of devices attached\n\n"),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
object.__setattr__(
|
|
||||||
locker,
|
|
||||||
"_try_wireless_reconnect",
|
|
||||||
MagicMock(
|
|
||||||
return_value=False,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
assert locker._is_phone_connected() is False
|
|
||||||
|
|
||||||
def test_phone_offline(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test phone connected but offline."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
object.__setattr__(
|
|
||||||
locker,
|
|
||||||
"_run_adb",
|
|
||||||
MagicMock(
|
|
||||||
return_value=(
|
|
||||||
True,
|
|
||||||
"List of devices attached\nABC123\toffline\n\n",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
object.__setattr__(
|
|
||||||
locker,
|
|
||||||
"_try_wireless_reconnect",
|
|
||||||
MagicMock(
|
|
||||||
return_value=False,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
assert locker._is_phone_connected() is False
|
|
||||||
|
|
||||||
def test_adb_command_fails(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test ADB command failure."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
object.__setattr__(
|
|
||||||
locker,
|
|
||||||
"_run_adb",
|
|
||||||
MagicMock(
|
|
||||||
return_value=(False, ""),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
object.__setattr__(
|
|
||||||
locker,
|
|
||||||
"_try_wireless_reconnect",
|
|
||||||
MagicMock(
|
|
||||||
return_value=False,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
assert locker._is_phone_connected() is False
|
|
||||||
|
|
||||||
|
|
||||||
class TestFindHealthConnectDb:
|
|
||||||
"""Tests for _pull_stronglifts_db method."""
|
|
||||||
|
|
||||||
def test_db_pulled_successfully(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test StrongLifts DB pulled from device."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
object.__setattr__(
|
|
||||||
locker,
|
|
||||||
"_adb_shell",
|
|
||||||
MagicMock(
|
|
||||||
return_value=(True, ""),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
object.__setattr__(
|
|
||||||
locker,
|
|
||||||
"_run_adb",
|
|
||||||
MagicMock(
|
|
||||||
return_value=(True, ""),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
result = locker._pull_stronglifts_db()
|
|
||||||
|
|
||||||
assert result is not None
|
|
||||||
locker._adb_shell.assert_called_once()
|
|
||||||
locker._run_adb.assert_called_once()
|
|
||||||
call_args = locker._run_adb.call_args[0][0]
|
|
||||||
assert call_args[0] == "pull"
|
|
||||||
|
|
||||||
def test_db_cat_fails(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test returns None when cat command fails."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
object.__setattr__(
|
|
||||||
locker,
|
|
||||||
"_adb_shell",
|
|
||||||
MagicMock(
|
|
||||||
return_value=(False, ""),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
assert locker._pull_stronglifts_db() is None
|
|
||||||
|
|
||||||
def test_db_pull_fails(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test returns None when adb pull fails."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
object.__setattr__(
|
|
||||||
locker,
|
|
||||||
"_adb_shell",
|
|
||||||
MagicMock(
|
|
||||||
return_value=(True, ""),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
object.__setattr__(
|
|
||||||
locker,
|
|
||||||
"_run_adb",
|
|
||||||
MagicMock(
|
|
||||||
return_value=(False, ""),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
assert locker._pull_stronglifts_db() is None
|
|
||||||
|
|
||||||
def test_db_uses_correct_remote_path(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test uses the correct StrongLifts DB remote path."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
object.__setattr__(
|
|
||||||
locker,
|
|
||||||
"_adb_shell",
|
|
||||||
MagicMock(
|
|
||||||
return_value=(True, ""),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
object.__setattr__(
|
|
||||||
locker,
|
|
||||||
"_run_adb",
|
|
||||||
MagicMock(
|
|
||||||
return_value=(True, ""),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
locker._pull_stronglifts_db()
|
|
||||||
|
|
||||||
shell_cmd = locker._adb_shell.call_args[0][0]
|
|
||||||
assert STRONGLIFTS_DB_REMOTE in shell_cmd
|
|
||||||
|
|
||||||
|
|
||||||
class TestCountTodayWorkouts:
|
|
||||||
"""Tests for _count_today_workouts method."""
|
|
||||||
|
|
||||||
def test_workouts_found_today(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test workouts found today."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
db_file = tmp_path / "sl_test.db"
|
|
||||||
conn = sqlite3.connect(str(db_file))
|
|
||||||
conn.execute(
|
|
||||||
"CREATE TABLE workouts "
|
|
||||||
"(id TEXT PRIMARY KEY, start INTEGER, finish INTEGER)",
|
|
||||||
)
|
|
||||||
# Insert a workout with today's timestamp (ms)
|
|
||||||
now_ms = int(time.time() * 1000)
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO workouts VALUES (?, ?, ?)",
|
|
||||||
("w1", now_ms, now_ms + 3600000),
|
|
||||||
)
|
|
||||||
conn.commit()
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
assert locker._count_today_workouts(db_file) == 1
|
|
||||||
|
|
||||||
def test_no_workouts_today(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test no workouts today."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
db_file = tmp_path / "sl_test.db"
|
|
||||||
conn = sqlite3.connect(str(db_file))
|
|
||||||
conn.execute(
|
|
||||||
"CREATE TABLE workouts "
|
|
||||||
"(id TEXT PRIMARY KEY, start INTEGER, finish INTEGER)",
|
|
||||||
)
|
|
||||||
# Insert a workout from yesterday (24h+ ago)
|
|
||||||
yesterday_ms = int((time.time() - 200000) * 1000)
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO workouts VALUES (?, ?, ?)",
|
|
||||||
("w1", yesterday_ms, yesterday_ms + 3600000),
|
|
||||||
)
|
|
||||||
conn.commit()
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
assert not locker._count_today_workouts(db_file)
|
|
||||||
|
|
||||||
def test_invalid_db_returns_zero(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test returns 0 for invalid database file."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
bad_file = tmp_path / "not_a_db.db"
|
|
||||||
bad_file.write_text("not a database")
|
|
||||||
|
|
||||||
assert not locker._count_today_workouts(bad_file)
|
|
||||||
|
|
||||||
def test_missing_table_returns_zero(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test returns 0 when workouts table doesn't exist."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
db_file = tmp_path / "empty.db"
|
|
||||||
conn = sqlite3.connect(str(db_file))
|
|
||||||
conn.execute("CREATE TABLE other (id TEXT)")
|
|
||||||
conn.commit()
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
assert not locker._count_today_workouts(db_file)
|
|
||||||
|
|
||||||
def test_multiple_workouts_today(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test counts multiple workouts today correctly."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
db_file = tmp_path / "sl_test.db"
|
|
||||||
conn = sqlite3.connect(str(db_file))
|
|
||||||
conn.execute(
|
|
||||||
"CREATE TABLE workouts "
|
|
||||||
"(id TEXT PRIMARY KEY, start INTEGER, finish INTEGER)",
|
|
||||||
)
|
|
||||||
now_ms = int(time.time() * 1000)
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO workouts VALUES (?, ?, ?)",
|
|
||||||
("w1", now_ms, now_ms + 3600000),
|
|
||||||
)
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO workouts VALUES (?, ?, ?)",
|
|
||||||
("w2", now_ms + 100000, now_ms + 3700000),
|
|
||||||
)
|
|
||||||
conn.commit()
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
assert locker._count_today_workouts(db_file) == 2
|
|
||||||
@ -1,394 +0,0 @@
|
|||||||
"""Tests for ADB commands, phone connection, and database operations."""
|
|
||||||
# pylint: disable=protected-access,unused-argument
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import datetime
|
|
||||||
import json
|
|
||||||
import sqlite3
|
|
||||||
import time
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from python_pkg.screen_locker.tests.conftest import create_locker
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from pathlib import Path
|
|
||||||
from unittest.mock import MagicMock
|
|
||||||
|
|
||||||
|
|
||||||
class TestGetTodayWorkoutDurationMinutes:
|
|
||||||
"""Tests for _get_today_workout_duration_minutes method."""
|
|
||||||
|
|
||||||
def test_returns_duration_for_today_workout(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test returns correct duration for a 60-minute workout."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
db_file = tmp_path / "sl_test.db"
|
|
||||||
conn = sqlite3.connect(str(db_file))
|
|
||||||
conn.execute(
|
|
||||||
"CREATE TABLE workouts "
|
|
||||||
"(id TEXT PRIMARY KEY, start INTEGER, finish INTEGER)",
|
|
||||||
)
|
|
||||||
now_ms = int(time.time() * 1000)
|
|
||||||
duration_ms = 60 * 60 * 1000 # 60 minutes
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO workouts VALUES (?, ?, ?)",
|
|
||||||
("w1", now_ms, now_ms + duration_ms),
|
|
||||||
)
|
|
||||||
conn.commit()
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
result = locker._get_today_workout_duration_minutes(db_file)
|
|
||||||
assert result == pytest.approx(60.0, abs=1.0)
|
|
||||||
|
|
||||||
def test_returns_zero_for_no_workouts(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test returns 0.0 when no workouts today."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
db_file = tmp_path / "sl_test.db"
|
|
||||||
conn = sqlite3.connect(str(db_file))
|
|
||||||
conn.execute(
|
|
||||||
"CREATE TABLE workouts "
|
|
||||||
"(id TEXT PRIMARY KEY, start INTEGER, finish INTEGER)",
|
|
||||||
)
|
|
||||||
yesterday_ms = int((time.time() - 200000) * 1000)
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO workouts VALUES (?, ?, ?)",
|
|
||||||
("w1", yesterday_ms, yesterday_ms + 3600000),
|
|
||||||
)
|
|
||||||
conn.commit()
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
assert not locker._get_today_workout_duration_minutes(db_file)
|
|
||||||
|
|
||||||
def test_sums_multiple_workouts(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test sums durations of multiple workouts today."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
db_file = tmp_path / "sl_test.db"
|
|
||||||
conn = sqlite3.connect(str(db_file))
|
|
||||||
conn.execute(
|
|
||||||
"CREATE TABLE workouts "
|
|
||||||
"(id TEXT PRIMARY KEY, start INTEGER, finish INTEGER)",
|
|
||||||
)
|
|
||||||
now_ms = int(time.time() * 1000)
|
|
||||||
# 30 min + 25 min = 55 min total
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO workouts VALUES (?, ?, ?)",
|
|
||||||
("w1", now_ms, now_ms + 30 * 60 * 1000),
|
|
||||||
)
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO workouts VALUES (?, ?, ?)",
|
|
||||||
("w2", now_ms + 31 * 60 * 1000, now_ms + 56 * 60 * 1000),
|
|
||||||
)
|
|
||||||
conn.commit()
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
result = locker._get_today_workout_duration_minutes(db_file)
|
|
||||||
assert result == pytest.approx(55.0, abs=1.0)
|
|
||||||
|
|
||||||
def test_ignores_invalid_finish(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test ignores workouts where finish <= start."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
db_file = tmp_path / "sl_test.db"
|
|
||||||
conn = sqlite3.connect(str(db_file))
|
|
||||||
conn.execute(
|
|
||||||
"CREATE TABLE workouts "
|
|
||||||
"(id TEXT PRIMARY KEY, start INTEGER, finish INTEGER)",
|
|
||||||
)
|
|
||||||
now_ms = int(time.time() * 1000)
|
|
||||||
# finish == start (zero duration - should be excluded by WHERE)
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO workouts VALUES (?, ?, ?)",
|
|
||||||
("w1", now_ms, now_ms),
|
|
||||||
)
|
|
||||||
conn.commit()
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
assert not locker._get_today_workout_duration_minutes(db_file)
|
|
||||||
|
|
||||||
def test_invalid_db_returns_zero(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test returns 0.0 for invalid database file."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
bad_file = tmp_path / "not_a_db.db"
|
|
||||||
bad_file.write_text("not a database")
|
|
||||||
|
|
||||||
assert not locker._get_today_workout_duration_minutes(bad_file)
|
|
||||||
|
|
||||||
def test_missing_table_returns_zero(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test returns 0.0 when workouts table doesn't exist."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
db_file = tmp_path / "empty.db"
|
|
||||||
conn = sqlite3.connect(str(db_file))
|
|
||||||
conn.execute("CREATE TABLE other (id TEXT)")
|
|
||||||
conn.commit()
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
assert not locker._get_today_workout_duration_minutes(db_file)
|
|
||||||
|
|
||||||
|
|
||||||
class TestGetTodayExerciseCount:
|
|
||||||
"""Tests for _get_today_exercise_count method."""
|
|
||||||
|
|
||||||
def test_counts_exercises(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test counts distinct exercises in today's workouts."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
db_file = tmp_path / "sl_test.db"
|
|
||||||
conn = sqlite3.connect(str(db_file))
|
|
||||||
conn.execute(
|
|
||||||
"CREATE TABLE workouts "
|
|
||||||
"(id TEXT PRIMARY KEY, start INTEGER, finish INTEGER, exercises TEXT)",
|
|
||||||
)
|
|
||||||
now_ms = int(time.time() * 1000)
|
|
||||||
exercises_json = json.dumps(
|
|
||||||
[
|
|
||||||
{"id": "squat", "name": "Squat"},
|
|
||||||
{"id": "bench_press", "name": "Bench Press"},
|
|
||||||
{"id": "squat", "name": "Squat"},
|
|
||||||
{"category": "WARMUP"},
|
|
||||||
]
|
|
||||||
)
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO workouts VALUES (?, ?, ?, ?)",
|
|
||||||
("w1", now_ms, now_ms + 3600000, exercises_json),
|
|
||||||
)
|
|
||||||
conn.commit()
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
assert locker._get_today_exercise_count(db_file) == 2
|
|
||||||
|
|
||||||
def test_no_exercises_returns_zero(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test returns 0 when no exercises exist."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
db_file = tmp_path / "sl_test.db"
|
|
||||||
conn = sqlite3.connect(str(db_file))
|
|
||||||
conn.execute(
|
|
||||||
"CREATE TABLE workouts "
|
|
||||||
"(id TEXT PRIMARY KEY, start INTEGER, finish INTEGER, exercises TEXT)",
|
|
||||||
)
|
|
||||||
now_ms = int(time.time() * 1000)
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO workouts VALUES (?, ?, ?, ?)",
|
|
||||||
("w1", now_ms, now_ms + 3600000, "[]"),
|
|
||||||
)
|
|
||||||
conn.commit()
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
assert not locker._get_today_exercise_count(db_file)
|
|
||||||
|
|
||||||
def test_invalid_db_returns_zero(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test returns 0 for invalid database file."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
bad_file = tmp_path / "bad.db"
|
|
||||||
bad_file.write_text("not a db")
|
|
||||||
|
|
||||||
assert not locker._get_today_exercise_count(bad_file)
|
|
||||||
|
|
||||||
def test_missing_exercises_column_returns_zero(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test returns 0 when workouts table has no exercises column."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
db_file = tmp_path / "empty.db"
|
|
||||||
conn = sqlite3.connect(str(db_file))
|
|
||||||
conn.execute(
|
|
||||||
"CREATE TABLE workouts "
|
|
||||||
"(id TEXT PRIMARY KEY, start INTEGER, finish INTEGER)",
|
|
||||||
)
|
|
||||||
now_ms = int(time.time() * 1000)
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO workouts VALUES (?, ?, ?)",
|
|
||||||
("w1", now_ms, now_ms + 3600000),
|
|
||||||
)
|
|
||||||
conn.commit()
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
assert not locker._get_today_exercise_count(db_file)
|
|
||||||
|
|
||||||
def test_null_exercises_json_returns_zero(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test returns 0 when exercises JSON is NULL."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
db_file = tmp_path / "null_ex.db"
|
|
||||||
conn = sqlite3.connect(str(db_file))
|
|
||||||
conn.execute(
|
|
||||||
"CREATE TABLE workouts "
|
|
||||||
"(id TEXT PRIMARY KEY, start INTEGER, finish INTEGER, exercises TEXT)",
|
|
||||||
)
|
|
||||||
now_ms = int(time.time() * 1000)
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO workouts VALUES (?, ?, ?, ?)",
|
|
||||||
("w1", now_ms, now_ms + 3600000, None),
|
|
||||||
)
|
|
||||||
conn.commit()
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
assert not locker._get_today_exercise_count(db_file)
|
|
||||||
|
|
||||||
def test_malformed_exercises_json_returns_zero(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test returns 0 when exercises JSON is malformed."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
db_file = tmp_path / "bad_json.db"
|
|
||||||
conn = sqlite3.connect(str(db_file))
|
|
||||||
conn.execute(
|
|
||||||
"CREATE TABLE workouts "
|
|
||||||
"(id TEXT PRIMARY KEY, start INTEGER, finish INTEGER, exercises TEXT)",
|
|
||||||
)
|
|
||||||
now_ms = int(time.time() * 1000)
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO workouts VALUES (?, ?, ?, ?)",
|
|
||||||
("w1", now_ms, now_ms + 3600000, "not valid json"),
|
|
||||||
)
|
|
||||||
conn.commit()
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
assert not locker._get_today_exercise_count(db_file)
|
|
||||||
|
|
||||||
|
|
||||||
class TestIsWorkoutFinishRecent:
|
|
||||||
"""Tests for _is_workout_finish_recent method."""
|
|
||||||
|
|
||||||
def test_recent_workout_returns_true(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test returns True for workout that finished recently."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
db_file = tmp_path / "sl_test.db"
|
|
||||||
conn = sqlite3.connect(str(db_file))
|
|
||||||
conn.execute(
|
|
||||||
"CREATE TABLE workouts "
|
|
||||||
"(id TEXT PRIMARY KEY, start INTEGER, finish INTEGER)",
|
|
||||||
)
|
|
||||||
# Anchor to local noon to avoid midnight boundary issues: the SQL
|
|
||||||
# date() filter requires start and now to share the same local date.
|
|
||||||
local_noon = (
|
|
||||||
datetime.datetime.now(tz=datetime.timezone.utc)
|
|
||||||
.astimezone()
|
|
||||||
.replace(hour=12, minute=0, second=0, microsecond=0)
|
|
||||||
)
|
|
||||||
local_noon_ms = int(local_noon.timestamp() * 1000)
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO workouts VALUES (?, ?, ?)",
|
|
||||||
("w1", local_noon_ms, local_noon_ms + 3_600_000),
|
|
||||||
)
|
|
||||||
conn.commit()
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
assert locker._is_workout_finish_recent(db_file) is True
|
|
||||||
|
|
||||||
def test_old_workout_returns_false(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test returns False for workout that finished >24 hours ago."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
db_file = tmp_path / "sl_test.db"
|
|
||||||
conn = sqlite3.connect(str(db_file))
|
|
||||||
conn.execute(
|
|
||||||
"CREATE TABLE workouts "
|
|
||||||
"(id TEXT PRIMARY KEY, start INTEGER, finish INTEGER)",
|
|
||||||
)
|
|
||||||
# Finished 25 hours ago (not "today" in local time either)
|
|
||||||
now_ms = int(time.time() * 1000)
|
|
||||||
old_finish = now_ms - 25 * 3600 * 1000 # beyond 24h window
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO workouts VALUES (?, ?, ?)",
|
|
||||||
("w1", old_finish - 3600000, old_finish),
|
|
||||||
)
|
|
||||||
conn.commit()
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
assert locker._is_workout_finish_recent(db_file) is False
|
|
||||||
|
|
||||||
def test_no_workouts_returns_false(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test returns False when no workouts exist."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
db_file = tmp_path / "sl_test.db"
|
|
||||||
conn = sqlite3.connect(str(db_file))
|
|
||||||
conn.execute(
|
|
||||||
"CREATE TABLE workouts "
|
|
||||||
"(id TEXT PRIMARY KEY, start INTEGER, finish INTEGER)",
|
|
||||||
)
|
|
||||||
conn.commit()
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
assert locker._is_workout_finish_recent(db_file) is False
|
|
||||||
|
|
||||||
def test_invalid_db_returns_false(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test returns False for invalid database file."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
bad_file = tmp_path / "bad.db"
|
|
||||||
bad_file.write_text("not a db")
|
|
||||||
|
|
||||||
assert locker._is_workout_finish_recent(bad_file) is False
|
|
||||||
@ -1,430 +0,0 @@
|
|||||||
"""Tests for early bird carrot feature in screen locker."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
import json
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any
|
|
||||||
from unittest.mock import MagicMock, patch
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from python_pkg.screen_locker.screen_lock import ScreenLocker
|
|
||||||
from python_pkg.screen_locker.tests.conftest import (
|
|
||||||
create_locker,
|
|
||||||
create_locker_early_bird,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestGetLocalTimeMinutes:
|
|
||||||
"""Tests for _get_local_time_minutes helper."""
|
|
||||||
|
|
||||||
def test_returns_int_within_day_range(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Returns an integer between 0 and 1439 (minutes in a day)."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
result = locker._get_local_time_minutes()
|
|
||||||
assert isinstance(result, int)
|
|
||||||
assert 0 <= result < 24 * 60
|
|
||||||
|
|
||||||
|
|
||||||
class TestIsEarlyBirdTime:
|
|
||||||
"""Tests for _is_early_bird_time based on local clock."""
|
|
||||||
|
|
||||||
def _locker(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
minutes: int,
|
|
||||||
) -> ScreenLocker:
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
object.__setattr__(
|
|
||||||
locker,
|
|
||||||
"_get_local_time_minutes",
|
|
||||||
MagicMock(return_value=minutes),
|
|
||||||
)
|
|
||||||
return locker
|
|
||||||
|
|
||||||
def test_within_window(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""6:00 AM (360 min) is within the early bird window."""
|
|
||||||
locker = self._locker(mock_tk, tmp_path, 360)
|
|
||||||
assert locker._is_early_bird_time() is True
|
|
||||||
|
|
||||||
def test_at_start_of_window(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""5:00 AM (300 min) is the inclusive start of the window."""
|
|
||||||
locker = self._locker(mock_tk, tmp_path, 300)
|
|
||||||
assert locker._is_early_bird_time() is True
|
|
||||||
|
|
||||||
def test_just_before_start(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""4:59 AM (299 min) is before the window."""
|
|
||||||
locker = self._locker(mock_tk, tmp_path, 299)
|
|
||||||
assert locker._is_early_bird_time() is False
|
|
||||||
|
|
||||||
def test_just_before_end(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""8:29 AM (509 min) is still within the window."""
|
|
||||||
locker = self._locker(mock_tk, tmp_path, 509)
|
|
||||||
assert locker._is_early_bird_time() is True
|
|
||||||
|
|
||||||
def test_at_end_of_window(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""8:30 AM (510 min) is the exclusive end — not in window."""
|
|
||||||
locker = self._locker(mock_tk, tmp_path, 510)
|
|
||||||
assert locker._is_early_bird_time() is False
|
|
||||||
|
|
||||||
def test_after_window(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""9:00 AM (540 min) is past the window."""
|
|
||||||
locker = self._locker(mock_tk, tmp_path, 540)
|
|
||||||
assert locker._is_early_bird_time() is False
|
|
||||||
|
|
||||||
def test_midnight(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Midnight (0 min) is outside the window."""
|
|
||||||
locker = self._locker(mock_tk, tmp_path, 0)
|
|
||||||
assert locker._is_early_bird_time() is False
|
|
||||||
|
|
||||||
|
|
||||||
class TestIsEarlyBirdLog:
|
|
||||||
"""Tests for _is_early_bird_log method."""
|
|
||||||
|
|
||||||
def test_no_log_file(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Return False when log file does not exist."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
locker.log_file = tmp_path / "workout_log.json"
|
|
||||||
assert locker._is_early_bird_log() is False
|
|
||||||
|
|
||||||
def test_invalid_json(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Return False when log file contains invalid JSON."""
|
|
||||||
log_file = tmp_path / "workout_log.json"
|
|
||||||
log_file.write_text("{bad json}")
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
locker.log_file = log_file
|
|
||||||
assert locker._is_early_bird_log() is False
|
|
||||||
|
|
||||||
def test_os_error_on_open(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Return False when opening the log file raises OSError."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
mock_file = MagicMock()
|
|
||||||
mock_file.exists.return_value = True
|
|
||||||
mock_file.open.side_effect = OSError("permission denied")
|
|
||||||
locker.log_file = mock_file
|
|
||||||
assert locker._is_early_bird_log() is False
|
|
||||||
|
|
||||||
def test_no_entry_today(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Return False when no entry exists for today."""
|
|
||||||
log_file = tmp_path / "workout_log.json"
|
|
||||||
log_file.write_text(json.dumps({"2020-01-01": {}}))
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
locker.log_file = log_file
|
|
||||||
assert locker._is_early_bird_log() is False
|
|
||||||
|
|
||||||
def test_today_is_phone_verified(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Return False when today's entry is phone_verified."""
|
|
||||||
log_file = tmp_path / "workout_log.json"
|
|
||||||
today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d")
|
|
||||||
log_file.write_text(
|
|
||||||
json.dumps({today: {"workout_data": {"type": "phone_verified"}}})
|
|
||||||
)
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
locker.log_file = log_file
|
|
||||||
assert locker._is_early_bird_log() is False
|
|
||||||
|
|
||||||
def test_today_is_early_bird(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Return True when today's entry type is early_bird."""
|
|
||||||
log_file = tmp_path / "workout_log.json"
|
|
||||||
today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d")
|
|
||||||
log_file.write_text(
|
|
||||||
json.dumps({today: {"workout_data": {"type": "early_bird"}}})
|
|
||||||
)
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
locker.log_file = log_file
|
|
||||||
assert locker._is_early_bird_log() is True
|
|
||||||
|
|
||||||
|
|
||||||
class TestSaveEarlyBirdLog:
|
|
||||||
"""Tests for _save_early_bird_log method."""
|
|
||||||
|
|
||||||
def test_saves_early_bird_entry(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Saves an entry with type early_bird to the log file."""
|
|
||||||
log_file = tmp_path / "workout_log.json"
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
locker.log_file = log_file
|
|
||||||
with patch(
|
|
||||||
"python_pkg.screen_locker.screen_lock.compute_entry_hmac",
|
|
||||||
return_value=None,
|
|
||||||
):
|
|
||||||
locker._save_early_bird_log()
|
|
||||||
|
|
||||||
assert log_file.exists()
|
|
||||||
with log_file.open() as f:
|
|
||||||
data: dict[str, Any] = json.load(f)
|
|
||||||
today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d")
|
|
||||||
assert data[today]["workout_data"]["type"] == "early_bird"
|
|
||||||
|
|
||||||
|
|
||||||
class TestTryAutoUpgradeEarlyBird:
|
|
||||||
"""Tests for _try_auto_upgrade_early_bird method."""
|
|
||||||
|
|
||||||
def test_upgrade_succeeds_when_verified(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Returns True, saves phone_verified entry, adjusts shutdown."""
|
|
||||||
log_file = tmp_path / "workout_log.json"
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
locker.log_file = log_file
|
|
||||||
object.__setattr__(
|
|
||||||
locker,
|
|
||||||
"_verify_phone_workout",
|
|
||||||
MagicMock(return_value=("verified", "Workout verified! (67 min)")),
|
|
||||||
)
|
|
||||||
object.__setattr__(
|
|
||||||
locker,
|
|
||||||
"_adjust_shutdown_time_later",
|
|
||||||
MagicMock(return_value=True),
|
|
||||||
)
|
|
||||||
with patch(
|
|
||||||
"python_pkg.screen_locker.screen_lock.compute_entry_hmac",
|
|
||||||
return_value=None,
|
|
||||||
):
|
|
||||||
result = locker._try_auto_upgrade_early_bird()
|
|
||||||
|
|
||||||
assert result is True
|
|
||||||
with log_file.open() as f:
|
|
||||||
data: dict[str, Any] = json.load(f)
|
|
||||||
today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d")
|
|
||||||
assert data[today]["workout_data"]["type"] == "phone_verified"
|
|
||||||
assert data[today]["workout_data"]["after_early_bird"] == "true"
|
|
||||||
|
|
||||||
def test_upgrade_fails_when_not_verified(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Returns False when phone shows no workout."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
object.__setattr__(
|
|
||||||
locker,
|
|
||||||
"_verify_phone_workout",
|
|
||||||
MagicMock(return_value=("no_phone", "No phone connected")),
|
|
||||||
)
|
|
||||||
assert locker._try_auto_upgrade_early_bird() is False
|
|
||||||
|
|
||||||
def test_upgrade_fails_on_os_error(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Returns False when _verify_phone_workout raises OSError."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
object.__setattr__(
|
|
||||||
locker,
|
|
||||||
"_verify_phone_workout",
|
|
||||||
MagicMock(side_effect=OSError("adb fail")),
|
|
||||||
)
|
|
||||||
assert locker._try_auto_upgrade_early_bird() is False
|
|
||||||
|
|
||||||
def test_upgrade_fails_on_runtime_error(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Returns False when _verify_phone_workout raises RuntimeError."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
object.__setattr__(
|
|
||||||
locker,
|
|
||||||
"_verify_phone_workout",
|
|
||||||
MagicMock(side_effect=RuntimeError("unexpected")),
|
|
||||||
)
|
|
||||||
assert locker._try_auto_upgrade_early_bird() is False
|
|
||||||
|
|
||||||
|
|
||||||
class TestHasLoggedTodayEarlyBird:
|
|
||||||
"""Tests that has_logged_today returns False for early_bird entries."""
|
|
||||||
|
|
||||||
def test_early_bird_entry_not_counted_as_logged(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""early_bird entries must not satisfy has_logged_today."""
|
|
||||||
log_file = tmp_path / "workout_log.json"
|
|
||||||
today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d")
|
|
||||||
log_file.write_text(
|
|
||||||
json.dumps({today: {"workout_data": {"type": "early_bird"}}})
|
|
||||||
)
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
locker.log_file = log_file
|
|
||||||
with patch(
|
|
||||||
"python_pkg.screen_locker.screen_lock.verify_entry_hmac",
|
|
||||||
return_value=True,
|
|
||||||
):
|
|
||||||
assert locker.has_logged_today() is False
|
|
||||||
|
|
||||||
|
|
||||||
class TestInitEarlyBirdFlow:
|
|
||||||
"""Integration tests for early bird branches in __init__."""
|
|
||||||
|
|
||||||
def test_init_saves_log_and_exits_during_early_bird_window(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""First login during 5-8:30 window: save early_bird log, exit."""
|
|
||||||
mock_sys_exit.side_effect = SystemExit(0)
|
|
||||||
with (
|
|
||||||
patch.object(Path, "resolve", return_value=tmp_path),
|
|
||||||
patch.object(ScreenLocker, "has_logged_today", return_value=False),
|
|
||||||
patch.object(ScreenLocker, "_is_sick_day_log", return_value=False),
|
|
||||||
patch.object(ScreenLocker, "_is_early_bird_log", return_value=False),
|
|
||||||
patch.object(ScreenLocker, "_is_early_bird_time", return_value=True),
|
|
||||||
patch.object(
|
|
||||||
ScreenLocker,
|
|
||||||
"_try_auto_upgrade_early_bird",
|
|
||||||
return_value=False,
|
|
||||||
),
|
|
||||||
patch.object(ScreenLocker, "_save_early_bird_log") as mock_save,
|
|
||||||
patch.object(ScreenLocker, "_start_phone_check"),
|
|
||||||
patch.object(ScreenLocker, "_start_verify_workout_check"),
|
|
||||||
patch(
|
|
||||||
"python_pkg.screen_locker.screen_lock.has_workout_skip_today",
|
|
||||||
return_value=False,
|
|
||||||
),
|
|
||||||
pytest.raises(SystemExit),
|
|
||||||
):
|
|
||||||
ScreenLocker(demo_mode=True)
|
|
||||||
|
|
||||||
mock_save.assert_called_once()
|
|
||||||
mock_sys_exit.assert_called_with(0)
|
|
||||||
|
|
||||||
def test_init_exits_when_early_bird_log_still_in_window(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Early bird log exists and window still active: skip lock, exit."""
|
|
||||||
mock_sys_exit.side_effect = SystemExit(0)
|
|
||||||
|
|
||||||
with pytest.raises(SystemExit):
|
|
||||||
create_locker_early_bird(mock_tk, tmp_path, state="log_active")
|
|
||||||
|
|
||||||
mock_sys_exit.assert_called_with(0)
|
|
||||||
|
|
||||||
def test_init_exits_when_early_bird_log_upgrades_successfully(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Early bird log + past 8:30 + workout done: upgrade, exit."""
|
|
||||||
mock_sys_exit.side_effect = SystemExit(0)
|
|
||||||
with (
|
|
||||||
patch.object(Path, "resolve", return_value=tmp_path),
|
|
||||||
patch.object(ScreenLocker, "has_logged_today", return_value=False),
|
|
||||||
patch.object(ScreenLocker, "_is_sick_day_log", return_value=False),
|
|
||||||
patch.object(ScreenLocker, "_is_early_bird_log", return_value=True),
|
|
||||||
patch.object(ScreenLocker, "_is_early_bird_time", return_value=False),
|
|
||||||
patch.object(
|
|
||||||
ScreenLocker, "_try_auto_upgrade_early_bird", return_value=True
|
|
||||||
),
|
|
||||||
patch.object(ScreenLocker, "_start_phone_check"),
|
|
||||||
patch.object(ScreenLocker, "_start_verify_workout_check"),
|
|
||||||
pytest.raises(SystemExit),
|
|
||||||
):
|
|
||||||
ScreenLocker(demo_mode=True)
|
|
||||||
|
|
||||||
mock_sys_exit.assert_called_with(0)
|
|
||||||
|
|
||||||
def test_init_shows_lock_when_early_bird_log_no_workout(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Early bird log + past 8:30 + no workout: show lock, no early exit."""
|
|
||||||
locker = create_locker_early_bird(mock_tk, tmp_path, state="log_expired")
|
|
||||||
|
|
||||||
# _try_auto_upgrade_early_bird returns False (default in create_locker)
|
|
||||||
# so __init__ falls through to show the lock without calling sys.exit
|
|
||||||
mock_sys_exit.assert_not_called()
|
|
||||||
assert locker.demo_mode is True
|
|
||||||
@ -1,342 +0,0 @@
|
|||||||
"""Tests for screen_locker initialization, logging, and basic operations."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
import json
|
|
||||||
import tkinter as tk
|
|
||||||
from typing import TYPE_CHECKING, Any
|
|
||||||
from unittest.mock import MagicMock, patch
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from python_pkg.screen_locker.screen_lock import _assert_not_under_pytest
|
|
||||||
from python_pkg.screen_locker.tests.conftest import create_locker
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
|
|
||||||
class TestAssertNotUnderPytest:
|
|
||||||
"""Tests for the _assert_not_under_pytest runtime guard."""
|
|
||||||
|
|
||||||
def test_raises_when_tk_is_real(self) -> None:
|
|
||||||
"""Guard fires if tk.Tk is the real tkinter class under pytest."""
|
|
||||||
with (
|
|
||||||
patch("python_pkg.screen_locker.screen_lock.tk", tk),
|
|
||||||
pytest.raises(RuntimeError, match="SAFETY"),
|
|
||||||
):
|
|
||||||
_assert_not_under_pytest()
|
|
||||||
|
|
||||||
def test_silent_when_tk_is_mocked(self) -> None:
|
|
||||||
"""Guard stays silent when tk is already mocked (normal test run)."""
|
|
||||||
_assert_not_under_pytest()
|
|
||||||
|
|
||||||
|
|
||||||
class TestScreenLockerInit:
|
|
||||||
"""Tests for ScreenLocker initialization."""
|
|
||||||
|
|
||||||
def test_init_demo_mode(
|
|
||||||
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
|
|
||||||
) -> None:
|
|
||||||
"""Test initialization in demo mode."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path, demo_mode=True)
|
|
||||||
|
|
||||||
assert locker.demo_mode is True
|
|
||||||
assert locker.lockout_time == 10
|
|
||||||
mock_sys_exit.assert_not_called()
|
|
||||||
|
|
||||||
def test_init_production_mode(
|
|
||||||
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
|
|
||||||
) -> None:
|
|
||||||
"""Test initialization in production mode."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path, demo_mode=False)
|
|
||||||
|
|
||||||
assert locker.demo_mode is False
|
|
||||||
assert locker.lockout_time == 1800
|
|
||||||
mock_sys_exit.assert_not_called()
|
|
||||||
|
|
||||||
def test_init_exits_if_logged_today(
|
|
||||||
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
|
|
||||||
) -> None:
|
|
||||||
"""Test that init exits early if workout logged today."""
|
|
||||||
mock_sys_exit.side_effect = SystemExit(0)
|
|
||||||
|
|
||||||
with pytest.raises(SystemExit):
|
|
||||||
create_locker(mock_tk, tmp_path, has_logged=True)
|
|
||||||
|
|
||||||
mock_sys_exit.assert_called_once_with(0)
|
|
||||||
|
|
||||||
|
|
||||||
class TestHasLoggedToday:
|
|
||||||
"""Tests for has_logged_today method."""
|
|
||||||
|
|
||||||
def test_no_log_file(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test when log file doesn't exist."""
|
|
||||||
log_file = tmp_path / "workout_log.json"
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
|
|
||||||
locker.log_file = log_file
|
|
||||||
assert locker.has_logged_today() is False
|
|
||||||
|
|
||||||
def test_empty_log_file(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test when log file is empty/invalid JSON."""
|
|
||||||
log_file = tmp_path / "workout_log.json"
|
|
||||||
log_file.write_text("")
|
|
||||||
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
locker.log_file = log_file
|
|
||||||
assert locker.has_logged_today() is False
|
|
||||||
|
|
||||||
def test_invalid_json(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test when log file contains invalid JSON."""
|
|
||||||
log_file = tmp_path / "workout_log.json"
|
|
||||||
log_file.write_text("{invalid json}")
|
|
||||||
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
locker.log_file = log_file
|
|
||||||
assert locker.has_logged_today() is False
|
|
||||||
|
|
||||||
def test_today_logged(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test when today's workout is logged with valid HMAC."""
|
|
||||||
log_file = tmp_path / "workout_log.json"
|
|
||||||
today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d")
|
|
||||||
log_file.write_text(
|
|
||||||
json.dumps({today: {"workout": "data", "hmac": "valid"}}),
|
|
||||||
)
|
|
||||||
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
locker.log_file = log_file
|
|
||||||
with patch(
|
|
||||||
"python_pkg.screen_locker.screen_lock.verify_entry_hmac",
|
|
||||||
return_value=True,
|
|
||||||
):
|
|
||||||
assert locker.has_logged_today() is True
|
|
||||||
|
|
||||||
def test_today_logged_invalid_hmac(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test rejects entry when HMAC verification fails."""
|
|
||||||
log_file = tmp_path / "workout_log.json"
|
|
||||||
today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d")
|
|
||||||
log_file.write_text(
|
|
||||||
json.dumps({today: {"workout": "data", "hmac": "tampered"}}),
|
|
||||||
)
|
|
||||||
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
locker.log_file = log_file
|
|
||||||
with patch(
|
|
||||||
"python_pkg.screen_locker.screen_lock.verify_entry_hmac",
|
|
||||||
return_value=False,
|
|
||||||
):
|
|
||||||
assert locker.has_logged_today() is False
|
|
||||||
|
|
||||||
def test_today_unsigned_entry_no_hmac_key(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Accept unsigned entry when HMAC key is unavailable."""
|
|
||||||
log_file = tmp_path / "workout_log.json"
|
|
||||||
today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d")
|
|
||||||
log_file.write_text(
|
|
||||||
json.dumps({today: {"workout": "data"}}),
|
|
||||||
)
|
|
||||||
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
locker.log_file = log_file
|
|
||||||
with (
|
|
||||||
patch(
|
|
||||||
"python_pkg.screen_locker.screen_lock.verify_entry_hmac",
|
|
||||||
return_value=False,
|
|
||||||
),
|
|
||||||
patch(
|
|
||||||
"python_pkg.screen_locker.screen_lock._load_hmac_key",
|
|
||||||
return_value=None,
|
|
||||||
),
|
|
||||||
):
|
|
||||||
assert locker.has_logged_today() is True
|
|
||||||
|
|
||||||
def test_today_unsigned_entry_with_hmac_key(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Reject unsigned entry when HMAC key IS available."""
|
|
||||||
log_file = tmp_path / "workout_log.json"
|
|
||||||
today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d")
|
|
||||||
log_file.write_text(
|
|
||||||
json.dumps({today: {"workout": "data"}}),
|
|
||||||
)
|
|
||||||
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
locker.log_file = log_file
|
|
||||||
with (
|
|
||||||
patch(
|
|
||||||
"python_pkg.screen_locker.screen_lock.verify_entry_hmac",
|
|
||||||
return_value=False,
|
|
||||||
),
|
|
||||||
patch(
|
|
||||||
"python_pkg.screen_locker.screen_lock._load_hmac_key",
|
|
||||||
return_value=b"secret-key",
|
|
||||||
),
|
|
||||||
):
|
|
||||||
assert locker.has_logged_today() is False
|
|
||||||
|
|
||||||
def test_other_day_logged(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test when only other days are logged."""
|
|
||||||
log_file = tmp_path / "workout_log.json"
|
|
||||||
log_file.write_text(json.dumps({"2020-01-01": {"workout": "data"}}))
|
|
||||||
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
locker.log_file = log_file
|
|
||||||
assert locker.has_logged_today() is False
|
|
||||||
|
|
||||||
|
|
||||||
class TestSaveWorkoutLog:
|
|
||||||
"""Tests for save_workout_log method."""
|
|
||||||
|
|
||||||
def test_save_to_new_file(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test saving to a new log file includes HMAC."""
|
|
||||||
log_file = tmp_path / "workout_log.json"
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
locker.log_file = log_file
|
|
||||||
locker.workout_data = {"type": "running"}
|
|
||||||
with patch(
|
|
||||||
"python_pkg.screen_locker.screen_lock.compute_entry_hmac",
|
|
||||||
return_value="abc123",
|
|
||||||
):
|
|
||||||
locker.save_workout_log()
|
|
||||||
|
|
||||||
assert log_file.exists()
|
|
||||||
with log_file.open() as f:
|
|
||||||
data: dict[str, Any] = json.load(f)
|
|
||||||
today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d")
|
|
||||||
assert today in data
|
|
||||||
assert data[today]["workout_data"]["type"] == "running"
|
|
||||||
assert data[today]["hmac"] == "abc123"
|
|
||||||
|
|
||||||
def test_save_to_new_file_no_hmac_key(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test saving without HMAC key produces unsigned entry."""
|
|
||||||
log_file = tmp_path / "workout_log.json"
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
locker.log_file = log_file
|
|
||||||
locker.workout_data = {"type": "running"}
|
|
||||||
with patch(
|
|
||||||
"python_pkg.screen_locker.screen_lock.compute_entry_hmac",
|
|
||||||
return_value=None,
|
|
||||||
):
|
|
||||||
locker.save_workout_log()
|
|
||||||
|
|
||||||
with log_file.open() as f:
|
|
||||||
data: dict[str, Any] = json.load(f)
|
|
||||||
today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d")
|
|
||||||
assert "hmac" not in data[today]
|
|
||||||
|
|
||||||
def test_save_to_existing_file(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test saving appends to existing log file."""
|
|
||||||
log_file = tmp_path / "workout_log.json"
|
|
||||||
log_file.write_text(json.dumps({"2020-01-01": {"old": "data"}}))
|
|
||||||
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
locker.log_file = log_file
|
|
||||||
locker.workout_data = {"type": "strength"}
|
|
||||||
with patch(
|
|
||||||
"python_pkg.screen_locker.screen_lock.compute_entry_hmac",
|
|
||||||
return_value="sig",
|
|
||||||
):
|
|
||||||
locker.save_workout_log()
|
|
||||||
|
|
||||||
with log_file.open() as f:
|
|
||||||
data: dict[str, Any] = json.load(f)
|
|
||||||
assert "2020-01-01" in data
|
|
||||||
today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d")
|
|
||||||
assert today in data
|
|
||||||
|
|
||||||
def test_save_with_corrupted_existing_file(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test saving when existing file is corrupted."""
|
|
||||||
log_file = tmp_path / "workout_log.json"
|
|
||||||
log_file.write_text("not valid json")
|
|
||||||
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
locker.log_file = log_file
|
|
||||||
locker.workout_data = {"type": "running"}
|
|
||||||
with patch(
|
|
||||||
"python_pkg.screen_locker.screen_lock.compute_entry_hmac",
|
|
||||||
return_value="sig",
|
|
||||||
):
|
|
||||||
locker.save_workout_log()
|
|
||||||
|
|
||||||
with log_file.open() as f:
|
|
||||||
data: dict[str, Any] = json.load(f)
|
|
||||||
today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d")
|
|
||||||
assert today in data
|
|
||||||
|
|
||||||
def test_save_with_write_error(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test saving handles write errors gracefully."""
|
|
||||||
log_file = tmp_path / "nonexistent_dir" / "workout_log.json"
|
|
||||||
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
locker.log_file = log_file
|
|
||||||
locker.workout_data = {"type": "running"}
|
|
||||||
with patch(
|
|
||||||
"python_pkg.screen_locker.screen_lock.compute_entry_hmac",
|
|
||||||
return_value="sig",
|
|
||||||
):
|
|
||||||
# Should not raise, just log warning
|
|
||||||
locker.save_workout_log()
|
|
||||||
@ -1,241 +0,0 @@
|
|||||||
"""Tests for screen_locker initialization, logging, and basic operations."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
import json
|
|
||||||
import tkinter as tk
|
|
||||||
from typing import TYPE_CHECKING, Any
|
|
||||||
from unittest.mock import MagicMock, patch
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from python_pkg.screen_locker.screen_lock import ScreenLocker
|
|
||||||
from python_pkg.screen_locker.tests.conftest import create_locker
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
|
|
||||||
class TestRun:
|
|
||||||
"""Tests for run method."""
|
|
||||||
|
|
||||||
def test_run_starts_mainloop(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test run starts the tkinter mainloop."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
|
|
||||||
locker.run()
|
|
||||||
|
|
||||||
locker.root.mainloop.assert_called_once()
|
|
||||||
|
|
||||||
|
|
||||||
class TestAutoUpgradeSickDay:
|
|
||||||
"""Tests for sick_day → phone_verified silent upgrade helpers."""
|
|
||||||
|
|
||||||
def test_upgrade_succeeds_when_phone_verified(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Verified phone workout overwrites today's sick_day entry."""
|
|
||||||
log_file = tmp_path / "workout_log.json"
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
locker.log_file = log_file
|
|
||||||
with (
|
|
||||||
patch.object(
|
|
||||||
locker,
|
|
||||||
"_verify_phone_workout",
|
|
||||||
return_value=("verified", "Workout verified! (1 session)"),
|
|
||||||
),
|
|
||||||
patch.object(
|
|
||||||
locker,
|
|
||||||
"_adjust_shutdown_time_later",
|
|
||||||
return_value=True,
|
|
||||||
) as mock_adjust,
|
|
||||||
patch(
|
|
||||||
"python_pkg.screen_locker.screen_lock.compute_entry_hmac",
|
|
||||||
return_value="sig",
|
|
||||||
),
|
|
||||||
):
|
|
||||||
assert locker._try_auto_upgrade_sick_day() is True
|
|
||||||
mock_adjust.assert_called_once()
|
|
||||||
|
|
||||||
today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d")
|
|
||||||
with log_file.open() as f:
|
|
||||||
data: dict[str, Any] = json.load(f)
|
|
||||||
assert data[today]["workout_data"]["type"] == "phone_verified"
|
|
||||||
assert data[today]["workout_data"]["after_sick_day"] == "true"
|
|
||||||
|
|
||||||
def test_upgrade_skipped_when_not_verified(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Non-verified statuses leave the sick_day entry untouched."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
with patch.object(
|
|
||||||
locker,
|
|
||||||
"_verify_phone_workout",
|
|
||||||
return_value=("no_phone", "No phone connected"),
|
|
||||||
):
|
|
||||||
assert locker._try_auto_upgrade_sick_day() is False
|
|
||||||
assert locker.workout_data == {}
|
|
||||||
|
|
||||||
def test_upgrade_skipped_on_exception(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Transient OSError/RuntimeError during check is non-fatal."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
with patch.object(
|
|
||||||
locker,
|
|
||||||
"_verify_phone_workout",
|
|
||||||
side_effect=OSError("transient"),
|
|
||||||
):
|
|
||||||
assert locker._try_auto_upgrade_sick_day() is False
|
|
||||||
|
|
||||||
def test_init_exits_when_sick_day_upgrade_succeeds(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Startup exits 0 after a successful silent sick_day upgrade."""
|
|
||||||
mock_sys_exit.side_effect = SystemExit(0)
|
|
||||||
with (
|
|
||||||
patch.object(
|
|
||||||
ScreenLocker,
|
|
||||||
"_try_auto_upgrade_sick_day",
|
|
||||||
return_value=True,
|
|
||||||
) as mock_upgrade,
|
|
||||||
pytest.raises(SystemExit),
|
|
||||||
):
|
|
||||||
create_locker(mock_tk, tmp_path, is_sick_day_log=True)
|
|
||||||
mock_upgrade.assert_called_once()
|
|
||||||
mock_sys_exit.assert_called_once_with(0)
|
|
||||||
|
|
||||||
|
|
||||||
class TestMainEntry:
|
|
||||||
"""Tests for main entry point."""
|
|
||||||
|
|
||||||
def test_main_demo_mode_default(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test main defaults to demo mode."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path, demo_mode=True)
|
|
||||||
|
|
||||||
assert locker.demo_mode is True
|
|
||||||
|
|
||||||
def test_main_production_mode_flag(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test main with --production flag."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path, demo_mode=False)
|
|
||||||
|
|
||||||
assert locker.demo_mode is False
|
|
||||||
|
|
||||||
|
|
||||||
class TestAdjustShutdownTimeLater:
|
|
||||||
"""Tests for _adjust_shutdown_time_later method."""
|
|
||||||
|
|
||||||
def test_adjust_shutdown_time_later_success(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test _adjust_shutdown_time_later adds hours successfully."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
object.__setattr__(
|
|
||||||
locker, "_read_shutdown_config", MagicMock(return_value=(21, 22, 8))
|
|
||||||
)
|
|
||||||
object.__setattr__(
|
|
||||||
locker, "_write_shutdown_config", MagicMock(return_value=True)
|
|
||||||
)
|
|
||||||
|
|
||||||
result = locker._adjust_shutdown_time_later()
|
|
||||||
|
|
||||||
assert result is True
|
|
||||||
locker._write_shutdown_config.assert_called_once_with(23, 23, 8, restore=True)
|
|
||||||
|
|
||||||
def test_adjust_shutdown_time_later_caps_at_23(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test _adjust_shutdown_time_later caps hours at 23."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
object.__setattr__(
|
|
||||||
locker, "_read_shutdown_config", MagicMock(return_value=(22, 23, 8))
|
|
||||||
)
|
|
||||||
object.__setattr__(
|
|
||||||
locker, "_write_shutdown_config", MagicMock(return_value=True)
|
|
||||||
)
|
|
||||||
|
|
||||||
result = locker._adjust_shutdown_time_later()
|
|
||||||
|
|
||||||
assert result is True
|
|
||||||
# 22+2=24 capped to 23, 23+2=25 capped to 23
|
|
||||||
locker._write_shutdown_config.assert_called_once_with(23, 23, 8, restore=True)
|
|
||||||
|
|
||||||
def test_adjust_shutdown_time_later_no_config(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test _adjust_shutdown_time_later returns False if config missing."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
object.__setattr__(
|
|
||||||
locker, "_read_shutdown_config", MagicMock(return_value=None)
|
|
||||||
)
|
|
||||||
|
|
||||||
result = locker._adjust_shutdown_time_later()
|
|
||||||
|
|
||||||
assert result is False
|
|
||||||
|
|
||||||
def test_adjust_shutdown_time_later_oserror(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test _adjust_shutdown_time_later handles OSError."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
object.__setattr__(
|
|
||||||
locker,
|
|
||||||
"_read_shutdown_config",
|
|
||||||
MagicMock(side_effect=OSError("permission denied")),
|
|
||||||
)
|
|
||||||
|
|
||||||
result = locker._adjust_shutdown_time_later()
|
|
||||||
|
|
||||||
assert result is False
|
|
||||||
|
|
||||||
|
|
||||||
class TestGrabInput:
|
|
||||||
"""Tests for _grab_input method."""
|
|
||||||
|
|
||||||
def test_production_global_grab_tcl_error(
|
|
||||||
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
|
|
||||||
) -> None:
|
|
||||||
"""Test production mode falls back when global grab fails."""
|
|
||||||
mock_tk.Tk.return_value.grab_set_global.side_effect = tk.TclError("grab failed")
|
|
||||||
locker = create_locker(mock_tk, tmp_path, demo_mode=False)
|
|
||||||
assert locker.demo_mode 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 python_pkg.screen_locker._log_integrity import (
|
|
||||||
_generate_hmac_key,
|
|
||||||
_load_hmac_key,
|
|
||||||
compute_entry_hmac,
|
|
||||||
verify_entry_hmac,
|
|
||||||
)
|
|
||||||
|
|
||||||
_HMAC_KEY_FILE_PATH = "python_pkg.shared.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
|
|
||||||
@ -1,488 +0,0 @@
|
|||||||
"""Tests for phone workout verification, phone check, and unlock operations."""
|
|
||||||
# pylint: disable=protected-access,unused-argument
|
|
||||||
|
|
||||||
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 TestVerifyPhoneWorkout:
|
|
||||||
"""Tests for _verify_phone_workout method."""
|
|
||||||
|
|
||||||
def test_verified(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test workout verified on phone with sufficient duration."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
object.__setattr__(
|
|
||||||
locker,
|
|
||||||
"_is_phone_connected",
|
|
||||||
MagicMock(return_value=True),
|
|
||||||
)
|
|
||||||
object.__setattr__(
|
|
||||||
locker,
|
|
||||||
"_pull_stronglifts_db",
|
|
||||||
MagicMock(return_value=tmp_path / "sl.db"),
|
|
||||||
)
|
|
||||||
object.__setattr__(
|
|
||||||
locker,
|
|
||||||
"_count_today_workouts",
|
|
||||||
MagicMock(return_value=2),
|
|
||||||
)
|
|
||||||
object.__setattr__(
|
|
||||||
locker,
|
|
||||||
"_is_workout_finish_recent",
|
|
||||||
MagicMock(return_value=True),
|
|
||||||
)
|
|
||||||
object.__setattr__(
|
|
||||||
locker,
|
|
||||||
"_get_today_exercise_count",
|
|
||||||
MagicMock(return_value=3),
|
|
||||||
)
|
|
||||||
object.__setattr__(
|
|
||||||
locker,
|
|
||||||
"_get_today_workout_duration_minutes",
|
|
||||||
MagicMock(return_value=65.0),
|
|
||||||
)
|
|
||||||
|
|
||||||
with patch(
|
|
||||||
"python_pkg.screen_locker._phone_verification.check_clock_skew",
|
|
||||||
return_value=(True, "Clock OK"),
|
|
||||||
):
|
|
||||||
status, message = locker._verify_phone_workout()
|
|
||||||
|
|
||||||
assert status == "verified"
|
|
||||||
assert "2 session" in message
|
|
||||||
assert "65 min" in message
|
|
||||||
assert "3 exercise" in message
|
|
||||||
|
|
||||||
def test_not_verified(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test no workout found on phone."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
object.__setattr__(
|
|
||||||
locker,
|
|
||||||
"_is_phone_connected",
|
|
||||||
MagicMock(return_value=True),
|
|
||||||
)
|
|
||||||
object.__setattr__(
|
|
||||||
locker,
|
|
||||||
"_pull_stronglifts_db",
|
|
||||||
MagicMock(return_value=tmp_path / "sl.db"),
|
|
||||||
)
|
|
||||||
object.__setattr__(
|
|
||||||
locker,
|
|
||||||
"_count_today_workouts",
|
|
||||||
MagicMock(return_value=0),
|
|
||||||
)
|
|
||||||
|
|
||||||
with patch(
|
|
||||||
"python_pkg.screen_locker._phone_verification.check_clock_skew",
|
|
||||||
return_value=(True, "Clock OK"),
|
|
||||||
):
|
|
||||||
status, message = locker._verify_phone_workout()
|
|
||||||
|
|
||||||
assert status == "not_verified"
|
|
||||||
assert "No workout" in message
|
|
||||||
|
|
||||||
def test_too_short(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test workout found but too short."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
object.__setattr__(
|
|
||||||
locker,
|
|
||||||
"_is_phone_connected",
|
|
||||||
MagicMock(return_value=True),
|
|
||||||
)
|
|
||||||
object.__setattr__(
|
|
||||||
locker,
|
|
||||||
"_pull_stronglifts_db",
|
|
||||||
MagicMock(return_value=tmp_path / "sl.db"),
|
|
||||||
)
|
|
||||||
object.__setattr__(
|
|
||||||
locker,
|
|
||||||
"_count_today_workouts",
|
|
||||||
MagicMock(return_value=1),
|
|
||||||
)
|
|
||||||
object.__setattr__(
|
|
||||||
locker,
|
|
||||||
"_is_workout_finish_recent",
|
|
||||||
MagicMock(return_value=True),
|
|
||||||
)
|
|
||||||
object.__setattr__(
|
|
||||||
locker,
|
|
||||||
"_get_today_exercise_count",
|
|
||||||
MagicMock(return_value=3),
|
|
||||||
)
|
|
||||||
object.__setattr__(
|
|
||||||
locker,
|
|
||||||
"_get_today_workout_duration_minutes",
|
|
||||||
MagicMock(return_value=25.0),
|
|
||||||
)
|
|
||||||
|
|
||||||
with patch(
|
|
||||||
"python_pkg.screen_locker._phone_verification.check_clock_skew",
|
|
||||||
return_value=(True, "Clock OK"),
|
|
||||||
):
|
|
||||||
status, message = locker._verify_phone_workout()
|
|
||||||
|
|
||||||
assert status == "too_short"
|
|
||||||
assert "25 min" in message
|
|
||||||
assert "50 min" in message
|
|
||||||
|
|
||||||
def test_no_phone(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test no phone connected."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
object.__setattr__(
|
|
||||||
locker,
|
|
||||||
"_is_phone_connected",
|
|
||||||
MagicMock(return_value=False),
|
|
||||||
)
|
|
||||||
|
|
||||||
with patch(
|
|
||||||
"python_pkg.screen_locker._phone_verification.check_clock_skew",
|
|
||||||
return_value=(True, "Clock OK"),
|
|
||||||
):
|
|
||||||
status, _ = locker._verify_phone_workout()
|
|
||||||
|
|
||||||
assert status == "no_phone"
|
|
||||||
|
|
||||||
def test_error_no_db(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test error when StrongLifts DB cannot be pulled."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
object.__setattr__(
|
|
||||||
locker,
|
|
||||||
"_is_phone_connected",
|
|
||||||
MagicMock(return_value=True),
|
|
||||||
)
|
|
||||||
object.__setattr__(
|
|
||||||
locker,
|
|
||||||
"_pull_stronglifts_db",
|
|
||||||
MagicMock(return_value=None),
|
|
||||||
)
|
|
||||||
|
|
||||||
with patch(
|
|
||||||
"python_pkg.screen_locker._phone_verification.check_clock_skew",
|
|
||||||
return_value=(True, "Clock OK"),
|
|
||||||
):
|
|
||||||
status, message = locker._verify_phone_workout()
|
|
||||||
|
|
||||||
assert status == "error"
|
|
||||||
assert "database" in message.lower()
|
|
||||||
|
|
||||||
def test_clock_tampered(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test clock_tampered when NTP check fails."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
|
|
||||||
with patch(
|
|
||||||
"python_pkg.screen_locker._phone_verification.check_clock_skew",
|
|
||||||
return_value=(False, "System clock is 600s ahead"),
|
|
||||||
):
|
|
||||||
status, message = locker._verify_phone_workout()
|
|
||||||
|
|
||||||
assert status == "clock_tampered"
|
|
||||||
assert "600s" in message
|
|
||||||
|
|
||||||
def test_stale_workout(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test stale status when workout finish is not recent."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
object.__setattr__(
|
|
||||||
locker,
|
|
||||||
"_is_phone_connected",
|
|
||||||
MagicMock(return_value=True),
|
|
||||||
)
|
|
||||||
object.__setattr__(
|
|
||||||
locker,
|
|
||||||
"_pull_stronglifts_db",
|
|
||||||
MagicMock(return_value=tmp_path / "sl.db"),
|
|
||||||
)
|
|
||||||
object.__setattr__(
|
|
||||||
locker,
|
|
||||||
"_count_today_workouts",
|
|
||||||
MagicMock(return_value=1),
|
|
||||||
)
|
|
||||||
object.__setattr__(
|
|
||||||
locker,
|
|
||||||
"_is_workout_finish_recent",
|
|
||||||
MagicMock(return_value=False),
|
|
||||||
)
|
|
||||||
|
|
||||||
with patch(
|
|
||||||
"python_pkg.screen_locker._phone_verification.check_clock_skew",
|
|
||||||
return_value=(True, "Clock OK"),
|
|
||||||
):
|
|
||||||
status, message = locker._verify_phone_workout()
|
|
||||||
|
|
||||||
assert status == "stale"
|
|
||||||
assert "old" in message.lower()
|
|
||||||
|
|
||||||
def test_no_exercises(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test no_exercises when workout has no exercise data."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
object.__setattr__(
|
|
||||||
locker,
|
|
||||||
"_is_phone_connected",
|
|
||||||
MagicMock(return_value=True),
|
|
||||||
)
|
|
||||||
object.__setattr__(
|
|
||||||
locker,
|
|
||||||
"_pull_stronglifts_db",
|
|
||||||
MagicMock(return_value=tmp_path / "sl.db"),
|
|
||||||
)
|
|
||||||
object.__setattr__(
|
|
||||||
locker,
|
|
||||||
"_count_today_workouts",
|
|
||||||
MagicMock(return_value=1),
|
|
||||||
)
|
|
||||||
object.__setattr__(
|
|
||||||
locker,
|
|
||||||
"_is_workout_finish_recent",
|
|
||||||
MagicMock(return_value=True),
|
|
||||||
)
|
|
||||||
object.__setattr__(
|
|
||||||
locker,
|
|
||||||
"_get_today_exercise_count",
|
|
||||||
MagicMock(return_value=0),
|
|
||||||
)
|
|
||||||
|
|
||||||
with patch(
|
|
||||||
"python_pkg.screen_locker._phone_verification.check_clock_skew",
|
|
||||||
return_value=(True, "Clock OK"),
|
|
||||||
):
|
|
||||||
status, message = locker._verify_phone_workout()
|
|
||||||
|
|
||||||
assert status == "no_exercises"
|
|
||||||
assert "exercise" in message.lower()
|
|
||||||
|
|
||||||
|
|
||||||
class TestStartPhoneCheck:
|
|
||||||
"""Tests for _start_phone_check and _handle_startup_phone_result."""
|
|
||||||
|
|
||||||
def test_start_phone_check_shows_checking_screen(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test _start_phone_check shows checking message and starts check."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
object.__setattr__(locker, "clear_container", MagicMock())
|
|
||||||
object.__setattr__(
|
|
||||||
locker,
|
|
||||||
"_verify_phone_workout",
|
|
||||||
MagicMock(
|
|
||||||
return_value=("no_phone", "No phone"),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
object.__setattr__(locker, "_poll_phone_check", MagicMock())
|
|
||||||
|
|
||||||
locker._start_phone_check()
|
|
||||||
|
|
||||||
locker.clear_container.assert_called()
|
|
||||||
locker._poll_phone_check.assert_called_once()
|
|
||||||
assert locker._phone_future is not None
|
|
||||||
|
|
||||||
def test_handle_startup_verified_unlocks_directly(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test verified result shows success screen then unlocks via after()."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
object.__setattr__(locker, "unlock_screen", MagicMock())
|
|
||||||
object.__setattr__(locker.root, "after", MagicMock())
|
|
||||||
|
|
||||||
locker._handle_startup_phone_result("verified", "Workout verified! (1 session)")
|
|
||||||
|
|
||||||
# unlock_screen is deferred via root.after, not called directly
|
|
||||||
locker.unlock_screen.assert_not_called()
|
|
||||||
assert locker.workout_data["type"] == "phone_verified"
|
|
||||||
locker.root.after.assert_called_once_with(1500, locker.unlock_screen)
|
|
||||||
|
|
||||||
def test_handle_startup_not_verified_shows_retry_and_sick(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test not_verified result shows retry and sick buttons."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
object.__setattr__(locker, "_show_retry_and_sick", MagicMock())
|
|
||||||
locker._handle_startup_phone_result(
|
|
||||||
"not_verified", "No workout found on phone today"
|
|
||||||
)
|
|
||||||
|
|
||||||
locker._show_retry_and_sick.assert_called_once()
|
|
||||||
|
|
||||||
def test_handle_startup_too_short_shows_retry_and_sick(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test too_short result shows retry and sick buttons."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
object.__setattr__(locker, "_show_retry_and_sick", MagicMock())
|
|
||||||
locker._handle_startup_phone_result(
|
|
||||||
"too_short", "Workout too short! 25 min logged, need at least 50 min."
|
|
||||||
)
|
|
||||||
|
|
||||||
locker._show_retry_and_sick.assert_called_once()
|
|
||||||
call_args = locker._show_retry_and_sick.call_args[0][0]
|
|
||||||
assert "too short" in call_args.lower()
|
|
||||||
|
|
||||||
def test_handle_startup_stale_shows_retry_and_sick(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test stale result shows retry and sick buttons."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
object.__setattr__(locker, "_show_retry_and_sick", MagicMock())
|
|
||||||
locker._handle_startup_phone_result("stale", "Workout too old")
|
|
||||||
|
|
||||||
locker._show_retry_and_sick.assert_called_once()
|
|
||||||
call_args = locker._show_retry_and_sick.call_args[0][0]
|
|
||||||
assert "reason: stale" in call_args.lower()
|
|
||||||
|
|
||||||
def test_handle_startup_no_exercises_shows_retry_and_sick(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test no_exercises result shows retry and sick buttons."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
object.__setattr__(locker, "_show_retry_and_sick", MagicMock())
|
|
||||||
locker._handle_startup_phone_result("no_exercises", "No exercises found")
|
|
||||||
|
|
||||||
locker._show_retry_and_sick.assert_called_once()
|
|
||||||
call_args = locker._show_retry_and_sick.call_args[0][0]
|
|
||||||
assert "reason: no_exercises" in call_args.lower()
|
|
||||||
|
|
||||||
def test_handle_startup_clock_tampered_shows_retry_and_sick(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test clock_tampered result shows retry and sick buttons."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
object.__setattr__(locker, "_show_retry_and_sick", MagicMock())
|
|
||||||
locker._handle_startup_phone_result(
|
|
||||||
"clock_tampered",
|
|
||||||
"System clock is 600s ahead",
|
|
||||||
)
|
|
||||||
|
|
||||||
locker._show_retry_and_sick.assert_called_once()
|
|
||||||
call_args = locker._show_retry_and_sick.call_args[0][0]
|
|
||||||
assert "clock" in call_args.lower()
|
|
||||||
|
|
||||||
def test_handle_startup_no_phone_shows_penalty(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test no_phone result triggers penalty with default retry+sick callback."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
object.__setattr__(locker, "_show_phone_penalty", MagicMock())
|
|
||||||
|
|
||||||
locker._handle_startup_phone_result("no_phone", "No phone")
|
|
||||||
|
|
||||||
locker._show_phone_penalty.assert_called_once_with("No phone")
|
|
||||||
|
|
||||||
def test_handle_startup_error_shows_penalty(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test error result triggers penalty with default retry+sick callback."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
object.__setattr__(locker, "_show_phone_penalty", MagicMock())
|
|
||||||
|
|
||||||
locker._handle_startup_phone_result("error", "DB not found")
|
|
||||||
|
|
||||||
locker._show_phone_penalty.assert_called_once_with("DB not found")
|
|
||||||
|
|
||||||
def test_poll_phone_check_schedules_retry_when_pending(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test _poll_phone_check reschedules itself when future is not done."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
mock_future: MagicMock = MagicMock()
|
|
||||||
mock_future.done.return_value = False
|
|
||||||
object.__setattr__(locker, "_phone_future", mock_future)
|
|
||||||
object.__setattr__(locker.root, "after", MagicMock())
|
|
||||||
|
|
||||||
locker._poll_phone_check()
|
|
||||||
|
|
||||||
locker.root.after.assert_called_once_with(500, locker._poll_phone_check)
|
|
||||||
|
|
||||||
def test_poll_phone_check_routes_when_done(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test _poll_phone_check calls result handler when future is done."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
mock_future: MagicMock = MagicMock()
|
|
||||||
mock_future.done.return_value = True
|
|
||||||
mock_future.result.return_value = ("no_phone", "No phone")
|
|
||||||
object.__setattr__(locker, "_phone_future", mock_future)
|
|
||||||
object.__setattr__(locker, "_handle_startup_phone_result", MagicMock())
|
|
||||||
|
|
||||||
locker._poll_phone_check()
|
|
||||||
|
|
||||||
locker._handle_startup_phone_result.assert_called_once_with(
|
|
||||||
"no_phone", "No phone"
|
|
||||||
)
|
|
||||||
@ -1,180 +0,0 @@
|
|||||||
"""Tests for phone workout verification, phone check, and unlock operations."""
|
|
||||||
# pylint: disable=protected-access,unused-argument
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
from unittest.mock import MagicMock
|
|
||||||
|
|
||||||
from python_pkg.screen_locker._constants import NO_PHONE_EXTRA_LOCKOUT_SECONDS
|
|
||||||
from python_pkg.screen_locker.screen_lock import (
|
|
||||||
PHONE_PENALTY_DELAY_DEMO,
|
|
||||||
PHONE_PENALTY_DELAY_PRODUCTION,
|
|
||||||
)
|
|
||||||
from python_pkg.screen_locker.tests.conftest import create_locker
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
|
|
||||||
class TestShowPhonePenalty:
|
|
||||||
"""Tests for _show_phone_penalty and _update_phone_penalty methods."""
|
|
||||||
|
|
||||||
def test_show_phone_penalty_demo_delay(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test demo mode uses short penalty delay."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path, demo_mode=True)
|
|
||||||
object.__setattr__(locker, "clear_container", MagicMock())
|
|
||||||
|
|
||||||
locker._show_phone_penalty("test message")
|
|
||||||
|
|
||||||
# _update_phone_penalty is called once, decrementing by 1
|
|
||||||
assert locker.phone_penalty_remaining == PHONE_PENALTY_DELAY_DEMO - 1
|
|
||||||
|
|
||||||
def test_show_phone_penalty_production_delay(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test production mode uses long penalty delay (base + no-phone bump)."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path, demo_mode=False)
|
|
||||||
object.__setattr__(locker, "clear_container", MagicMock())
|
|
||||||
|
|
||||||
locker._show_phone_penalty("test message")
|
|
||||||
|
|
||||||
expected = PHONE_PENALTY_DELAY_PRODUCTION + NO_PHONE_EXTRA_LOCKOUT_SECONDS - 1
|
|
||||||
assert locker.phone_penalty_remaining == expected
|
|
||||||
|
|
||||||
def test_update_phone_penalty_countdown(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test phone penalty countdown decrements."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
locker.phone_penalty_remaining = 5
|
|
||||||
locker.phone_penalty_label = MagicMock()
|
|
||||||
|
|
||||||
locker._update_phone_penalty()
|
|
||||||
|
|
||||||
assert locker.phone_penalty_remaining == 4
|
|
||||||
locker.phone_penalty_label.config.assert_called_once_with(text="5")
|
|
||||||
locker.root.after.assert_called()
|
|
||||||
|
|
||||||
def test_update_phone_penalty_at_zero(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test phone penalty calls done function when timer reaches zero."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
locker.phone_penalty_remaining = 0
|
|
||||||
locker.phone_penalty_label = MagicMock()
|
|
||||||
mock_done = MagicMock()
|
|
||||||
locker._phone_penalty_done_fn = mock_done
|
|
||||||
|
|
||||||
locker._update_phone_penalty()
|
|
||||||
|
|
||||||
mock_done.assert_called_once()
|
|
||||||
|
|
||||||
def test_show_phone_penalty_default_callback_shows_retry(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test default phone penalty callback shows retry+sick screen."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path, demo_mode=True)
|
|
||||||
object.__setattr__(locker, "clear_container", MagicMock())
|
|
||||||
object.__setattr__(locker, "_show_retry_and_sick", MagicMock())
|
|
||||||
|
|
||||||
locker._show_phone_penalty("No phone connected")
|
|
||||||
|
|
||||||
# Simulate timer reaching zero by calling the done function
|
|
||||||
locker._phone_penalty_done_fn()
|
|
||||||
locker._show_retry_and_sick.assert_called_once_with("No phone connected")
|
|
||||||
|
|
||||||
|
|
||||||
class TestUnlockScreenShutdownAdjustment:
|
|
||||||
"""Tests for unlock_screen shutdown time adjustment."""
|
|
||||||
|
|
||||||
def test_unlock_screen_adjusts_for_phone_verified(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test unlock_screen adjusts shutdown for phone-verified workout."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
locker.log_file = tmp_path / "workout_log.json"
|
|
||||||
locker.workout_data = {"type": "phone_verified"}
|
|
||||||
object.__setattr__(
|
|
||||||
locker, "_adjust_shutdown_time_later", MagicMock(return_value=True)
|
|
||||||
)
|
|
||||||
|
|
||||||
locker.unlock_screen()
|
|
||||||
|
|
||||||
locker._adjust_shutdown_time_later.assert_called_once()
|
|
||||||
|
|
||||||
def test_unlock_screen_skips_adjustment_for_sick_day(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test unlock_screen does not adjust for sick day."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
locker.log_file = tmp_path / "workout_log.json"
|
|
||||||
locker.workout_data = {"type": "sick_day"}
|
|
||||||
object.__setattr__(
|
|
||||||
locker, "_adjust_shutdown_time_later", MagicMock(return_value=True)
|
|
||||||
)
|
|
||||||
|
|
||||||
locker.unlock_screen()
|
|
||||||
|
|
||||||
locker._adjust_shutdown_time_later.assert_not_called()
|
|
||||||
|
|
||||||
def test_unlock_screen_skips_adjustment_no_type(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test unlock_screen does not adjust when no workout type."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
locker.log_file = tmp_path / "workout_log.json"
|
|
||||||
locker.workout_data = {}
|
|
||||||
object.__setattr__(
|
|
||||||
locker, "_adjust_shutdown_time_later", MagicMock(return_value=True)
|
|
||||||
)
|
|
||||||
|
|
||||||
locker.unlock_screen()
|
|
||||||
|
|
||||||
locker._adjust_shutdown_time_later.assert_not_called()
|
|
||||||
|
|
||||||
def test_unlock_screen_handles_adjustment_failure(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test unlock_screen continues when adjustment fails."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
locker.log_file = tmp_path / "workout_log.json"
|
|
||||||
locker.workout_data = {"type": "phone_verified"}
|
|
||||||
object.__setattr__(
|
|
||||||
locker, "_adjust_shutdown_time_later", MagicMock(return_value=False)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Should not raise, should continue with unlock
|
|
||||||
locker.unlock_screen()
|
|
||||||
|
|
||||||
locker._adjust_shutdown_time_later.assert_called_once()
|
|
||||||
locker.root.after.assert_called()
|
|
||||||
@ -1,268 +0,0 @@
|
|||||||
"""Tests for phone verification coverage gaps (part 2)."""
|
|
||||||
|
|
||||||
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 TestGetWirelessSerial:
|
|
||||||
"""Tests for _get_wireless_serial method."""
|
|
||||||
|
|
||||||
def test_returns_wireless_serial(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test returns ip:port serial for a wireless device."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
output = "List of devices attached\n192.168.1.42:5555\tdevice\n"
|
|
||||||
with patch.object(locker, "_run_adb", return_value=(True, output)):
|
|
||||||
result = locker._get_wireless_serial()
|
|
||||||
assert result == "192.168.1.42:5555"
|
|
||||||
|
|
||||||
def test_returns_none_when_adb_fails(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test returns None when adb devices fails."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
with patch.object(locker, "_run_adb", return_value=(False, "")):
|
|
||||||
result = locker._get_wireless_serial()
|
|
||||||
assert result is None
|
|
||||||
|
|
||||||
def test_returns_none_when_no_wireless_device(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test returns None when only USB devices are connected."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
output = "List of devices attached\nABC123DEF456\tdevice\n"
|
|
||||||
with patch.object(locker, "_run_adb", return_value=(True, output)):
|
|
||||||
result = locker._get_wireless_serial()
|
|
||||||
assert result is None
|
|
||||||
|
|
||||||
def test_skips_offline_wireless_device(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test skips offline wireless devices."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
output = "List of devices attached\n192.168.1.42:5555\toffline\n"
|
|
||||||
with patch.object(locker, "_run_adb", return_value=(True, output)):
|
|
||||||
result = locker._get_wireless_serial()
|
|
||||||
assert result is None
|
|
||||||
|
|
||||||
|
|
||||||
class TestTryAdbConnect:
|
|
||||||
"""Tests for _try_adb_connect method."""
|
|
||||||
|
|
||||||
def test_successful_connect(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test successful ADB connect."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
with patch.object(
|
|
||||||
locker, "_run_adb", return_value=(True, "connected to 192.168.1.42:5555")
|
|
||||||
):
|
|
||||||
result = locker._try_adb_connect("192.168.1.42:5555")
|
|
||||||
assert result is True
|
|
||||||
|
|
||||||
def test_failed_connect_unable(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test connect failure with 'unable' in output."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
with patch.object(
|
|
||||||
locker, "_run_adb", return_value=(False, "unable to connect")
|
|
||||||
):
|
|
||||||
result = locker._try_adb_connect("192.168.1.42:5555")
|
|
||||||
assert result is False
|
|
||||||
|
|
||||||
def test_failed_connect_with_failed(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test connect failure with 'failed' in output."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
with patch.object(
|
|
||||||
locker,
|
|
||||||
"_run_adb",
|
|
||||||
return_value=(False, "connected but failed to authenticate"),
|
|
||||||
):
|
|
||||||
result = locker._try_adb_connect("192.168.1.42:5555")
|
|
||||||
assert result is False
|
|
||||||
|
|
||||||
def test_no_connected_in_output(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test connect failure when 'connected' not in output."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
with patch.object(
|
|
||||||
locker, "_run_adb", return_value=(False, "some random output")
|
|
||||||
):
|
|
||||||
result = locker._try_adb_connect("192.168.1.42:5555")
|
|
||||||
assert result is False
|
|
||||||
|
|
||||||
|
|
||||||
class TestGetLocalSubnetPrefix:
|
|
||||||
"""Tests for _get_local_subnet_prefix method."""
|
|
||||||
|
|
||||||
def test_returns_prefix(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test returns first three octets of local IP."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
mock_sock = MagicMock()
|
|
||||||
mock_sock.getsockname.return_value = ("192.168.1.100", 12345)
|
|
||||||
mock_sock.__enter__ = MagicMock(return_value=mock_sock)
|
|
||||||
mock_sock.__exit__ = MagicMock(return_value=False)
|
|
||||||
with patch(
|
|
||||||
"python_pkg.screen_locker._phone_verification.socket.socket",
|
|
||||||
return_value=mock_sock,
|
|
||||||
):
|
|
||||||
result = locker._get_local_subnet_prefix()
|
|
||||||
assert result == "192.168.1"
|
|
||||||
|
|
||||||
def test_returns_none_on_oserror(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test returns None when socket raises OSError."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
with patch(
|
|
||||||
"python_pkg.screen_locker._phone_verification.socket.socket",
|
|
||||||
side_effect=OSError("no network"),
|
|
||||||
):
|
|
||||||
result = locker._get_local_subnet_prefix()
|
|
||||||
assert result is None
|
|
||||||
|
|
||||||
|
|
||||||
class TestTryWirelessReconnect:
|
|
||||||
"""Tests for _try_wireless_reconnect method."""
|
|
||||||
|
|
||||||
def test_returns_false_when_no_prefix(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test returns False when subnet prefix can't be determined."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
with patch.object(locker, "_get_local_subnet_prefix", return_value=None):
|
|
||||||
result = locker._try_wireless_reconnect()
|
|
||||||
assert result is False
|
|
||||||
|
|
||||||
def test_returns_true_when_probe_succeeds(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test returns True when a probe finds the phone."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
with (
|
|
||||||
patch.object(locker, "_get_local_subnet_prefix", return_value="192.168.1"),
|
|
||||||
patch.object(locker, "_try_adb_connect", return_value=True),
|
|
||||||
patch.object(locker, "_has_adb_device", return_value=True),
|
|
||||||
patch(
|
|
||||||
"python_pkg.screen_locker._phone_verification.socket.create_connection",
|
|
||||||
) as mock_conn,
|
|
||||||
):
|
|
||||||
mock_sock = MagicMock()
|
|
||||||
mock_sock.__enter__ = MagicMock(return_value=mock_sock)
|
|
||||||
mock_sock.__exit__ = MagicMock(return_value=False)
|
|
||||||
mock_conn.return_value = mock_sock
|
|
||||||
result = locker._try_wireless_reconnect()
|
|
||||||
assert result is True
|
|
||||||
|
|
||||||
def test_returns_false_when_no_probe_succeeds(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test returns False when no probe finds the phone."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
with (
|
|
||||||
patch.object(locker, "_get_local_subnet_prefix", return_value="192.168.1"),
|
|
||||||
patch(
|
|
||||||
"python_pkg.screen_locker._phone_verification.socket.create_connection",
|
|
||||||
side_effect=OSError("refused"),
|
|
||||||
),
|
|
||||||
):
|
|
||||||
result = locker._try_wireless_reconnect()
|
|
||||||
assert result is False
|
|
||||||
|
|
||||||
def test_probe_connect_succeeds_but_no_device(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test probe passes socket but adb_connect succeeds without device."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
with (
|
|
||||||
patch.object(locker, "_get_local_subnet_prefix", return_value="192.168.1"),
|
|
||||||
patch.object(locker, "_try_adb_connect", return_value=True),
|
|
||||||
patch.object(locker, "_has_adb_device", return_value=False),
|
|
||||||
patch(
|
|
||||||
"python_pkg.screen_locker._phone_verification.socket.create_connection",
|
|
||||||
) as mock_conn,
|
|
||||||
):
|
|
||||||
mock_sock = MagicMock()
|
|
||||||
mock_sock.__enter__ = MagicMock(return_value=mock_sock)
|
|
||||||
mock_sock.__exit__ = MagicMock(return_value=False)
|
|
||||||
mock_conn.return_value = mock_sock
|
|
||||||
result = locker._try_wireless_reconnect()
|
|
||||||
assert result is False
|
|
||||||
|
|
||||||
def test_probe_adb_connect_fails(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test probe where socket connects but adb connect fails."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
with (
|
|
||||||
patch.object(locker, "_get_local_subnet_prefix", return_value="192.168.1"),
|
|
||||||
patch.object(locker, "_try_adb_connect", return_value=False),
|
|
||||||
patch(
|
|
||||||
"python_pkg.screen_locker._phone_verification.socket.create_connection",
|
|
||||||
) as mock_conn,
|
|
||||||
):
|
|
||||||
mock_sock = MagicMock()
|
|
||||||
mock_sock.__enter__ = MagicMock(return_value=mock_sock)
|
|
||||||
mock_sock.__exit__ = MagicMock(return_value=False)
|
|
||||||
mock_conn.return_value = mock_sock
|
|
||||||
result = locker._try_wireless_reconnect()
|
|
||||||
assert result is False
|
|
||||||
@ -1,195 +0,0 @@
|
|||||||
"""Tests for scheduled skip date feature in screen_lock.py."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
import json
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
from unittest.mock import MagicMock, patch
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from python_pkg.screen_locker.tests.conftest import create_locker
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from python_pkg.screen_locker.screen_lock import ScreenLocker
|
|
||||||
|
|
||||||
|
|
||||||
class TestIsScheduledSkipToday:
|
|
||||||
"""Tests for ScreenLocker._is_scheduled_skip_today."""
|
|
||||||
|
|
||||||
def _make_locker(self, mock_tk: MagicMock, tmp_path: Path) -> ScreenLocker:
|
|
||||||
return create_locker(mock_tk, tmp_path)
|
|
||||||
|
|
||||||
def test_returns_false_when_file_absent(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Returns False when scheduled_skips.json does not exist."""
|
|
||||||
locker = self._make_locker(mock_tk, tmp_path)
|
|
||||||
skip_file = tmp_path / "scheduled_skips.json"
|
|
||||||
with patch(
|
|
||||||
"python_pkg.screen_locker.screen_lock.SCHEDULED_SKIPS_FILE",
|
|
||||||
skip_file,
|
|
||||||
):
|
|
||||||
assert locker._is_scheduled_skip_today() is False
|
|
||||||
|
|
||||||
def test_returns_true_when_today_listed(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Returns True when today's date is in the skips list."""
|
|
||||||
locker = self._make_locker(mock_tk, tmp_path)
|
|
||||||
today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d")
|
|
||||||
skip_file = tmp_path / "scheduled_skips.json"
|
|
||||||
skip_file.write_text(json.dumps([today]))
|
|
||||||
with patch(
|
|
||||||
"python_pkg.screen_locker.screen_lock.SCHEDULED_SKIPS_FILE",
|
|
||||||
skip_file,
|
|
||||||
):
|
|
||||||
assert locker._is_scheduled_skip_today() is True
|
|
||||||
|
|
||||||
def test_returns_false_when_today_not_listed(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Returns False when today's date is not in the skips list."""
|
|
||||||
locker = self._make_locker(mock_tk, tmp_path)
|
|
||||||
skip_file = tmp_path / "scheduled_skips.json"
|
|
||||||
skip_file.write_text(json.dumps(["1999-01-01", "2000-06-15"]))
|
|
||||||
with patch(
|
|
||||||
"python_pkg.screen_locker.screen_lock.SCHEDULED_SKIPS_FILE",
|
|
||||||
skip_file,
|
|
||||||
):
|
|
||||||
assert locker._is_scheduled_skip_today() is False
|
|
||||||
|
|
||||||
def test_returns_false_on_corrupt_json(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Returns False when the skips file contains invalid JSON."""
|
|
||||||
locker = self._make_locker(mock_tk, tmp_path)
|
|
||||||
skip_file = tmp_path / "scheduled_skips.json"
|
|
||||||
skip_file.write_text("{not valid json}")
|
|
||||||
with patch(
|
|
||||||
"python_pkg.screen_locker.screen_lock.SCHEDULED_SKIPS_FILE",
|
|
||||||
skip_file,
|
|
||||||
):
|
|
||||||
assert locker._is_scheduled_skip_today() is False
|
|
||||||
|
|
||||||
def test_returns_false_on_read_error(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Returns False when the skips file cannot be read (OSError)."""
|
|
||||||
locker = self._make_locker(mock_tk, tmp_path)
|
|
||||||
skip_file = tmp_path / "scheduled_skips.json"
|
|
||||||
skip_file.write_text("[]")
|
|
||||||
with (
|
|
||||||
patch(
|
|
||||||
"python_pkg.screen_locker.screen_lock.SCHEDULED_SKIPS_FILE",
|
|
||||||
skip_file,
|
|
||||||
),
|
|
||||||
patch("builtins.open", side_effect=OSError("permission denied")),
|
|
||||||
):
|
|
||||||
assert locker._is_scheduled_skip_today() is False
|
|
||||||
|
|
||||||
def test_empty_list_returns_false(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Returns False for an empty skips list."""
|
|
||||||
locker = self._make_locker(mock_tk, tmp_path)
|
|
||||||
skip_file = tmp_path / "scheduled_skips.json"
|
|
||||||
skip_file.write_text("[]")
|
|
||||||
with patch(
|
|
||||||
"python_pkg.screen_locker.screen_lock.SCHEDULED_SKIPS_FILE",
|
|
||||||
skip_file,
|
|
||||||
):
|
|
||||||
assert locker._is_scheduled_skip_today() is False
|
|
||||||
|
|
||||||
|
|
||||||
class TestScheduledSkipEarlyExit:
|
|
||||||
"""Tests for _check_non_verify_exits behaviour with scheduled skips."""
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _write_today_skip(tmp_path: Path) -> None:
|
|
||||||
today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d")
|
|
||||||
skip_file = tmp_path / "scheduled_skips.json"
|
|
||||||
skip_file.write_text(json.dumps([today]))
|
|
||||||
|
|
||||||
def test_exits_on_scheduled_skip_day(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Screen locker calls sys.exit(0) when today is a scheduled skip."""
|
|
||||||
self._write_today_skip(tmp_path)
|
|
||||||
mock_sys_exit.side_effect = SystemExit(0)
|
|
||||||
|
|
||||||
with pytest.raises(SystemExit):
|
|
||||||
create_locker(mock_tk, tmp_path)
|
|
||||||
|
|
||||||
mock_sys_exit.assert_called_once_with(0)
|
|
||||||
|
|
||||||
def test_does_not_exit_when_not_scheduled_skip(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Screen locker proceeds normally when today is not a scheduled skip."""
|
|
||||||
# No file written — _is_scheduled_skip_today returns False
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
|
|
||||||
mock_sys_exit.assert_not_called()
|
|
||||||
assert locker is not None
|
|
||||||
|
|
||||||
def test_scheduled_skip_takes_precedence_over_has_logged(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Scheduled skip exits before has_logged or other checks run."""
|
|
||||||
self._write_today_skip(tmp_path)
|
|
||||||
mock_sys_exit.side_effect = SystemExit(0)
|
|
||||||
|
|
||||||
with pytest.raises(SystemExit):
|
|
||||||
create_locker(mock_tk, tmp_path, has_logged=False)
|
|
||||||
|
|
||||||
mock_sys_exit.assert_called_once_with(0)
|
|
||||||
|
|
||||||
def test_verify_only_mode_ignores_scheduled_skip(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""verify_only mode does not consult scheduled skips."""
|
|
||||||
self._write_today_skip(tmp_path)
|
|
||||||
|
|
||||||
# verify_only exits because no sick day log, not because of scheduled skip
|
|
||||||
create_locker(
|
|
||||||
mock_tk,
|
|
||||||
tmp_path,
|
|
||||||
verify_only=True,
|
|
||||||
is_sick_day_log=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
mock_sys_exit.assert_called_once_with(0)
|
|
||||||
@ -1,420 +0,0 @@
|
|||||||
"""Tests for shutdown schedule adjustment coverage gaps (part 2)."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
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 TestApplyEarlierShutdown:
|
|
||||||
"""Tests for _apply_earlier_shutdown method."""
|
|
||||||
|
|
||||||
def test_returns_false_when_no_config(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test returns False when config can't be read."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
with patch.object(locker, "_read_shutdown_config", return_value=None):
|
|
||||||
assert locker._apply_earlier_shutdown("2026-03-21") is False
|
|
||||||
|
|
||||||
def test_returns_false_when_save_state_fails(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test returns False when saving state fails."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
with (
|
|
||||||
patch.object(locker, "_read_shutdown_config", return_value=(21, 20, 8)),
|
|
||||||
patch.object(locker, "_save_sick_day_state", return_value=False),
|
|
||||||
):
|
|
||||||
assert locker._apply_earlier_shutdown("2026-03-21") is False
|
|
||||||
|
|
||||||
def test_success_applies_earlier_hours(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test successful application of earlier shutdown hours."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
with (
|
|
||||||
patch.object(locker, "_read_shutdown_config", return_value=(21, 20, 8)),
|
|
||||||
patch.object(locker, "_save_sick_day_state", return_value=True),
|
|
||||||
patch.object(
|
|
||||||
locker, "_write_shutdown_config", return_value=True
|
|
||||||
) as mock_write,
|
|
||||||
):
|
|
||||||
result = locker._apply_earlier_shutdown("2026-03-21")
|
|
||||||
assert result is True
|
|
||||||
mock_write.assert_called_once_with(20, 19, 8)
|
|
||||||
|
|
||||||
def test_clamps_to_minimum_18(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test hours are clamped to minimum of 18."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
with (
|
|
||||||
patch.object(locker, "_read_shutdown_config", return_value=(18, 18, 8)),
|
|
||||||
patch.object(locker, "_save_sick_day_state", return_value=True),
|
|
||||||
patch.object(
|
|
||||||
locker, "_write_shutdown_config", return_value=True
|
|
||||||
) as mock_write,
|
|
||||||
):
|
|
||||||
locker._apply_earlier_shutdown("2026-03-21")
|
|
||||||
mock_write.assert_called_once_with(18, 18, 8)
|
|
||||||
|
|
||||||
|
|
||||||
class TestAdjustShutdownTimeEarlier:
|
|
||||||
"""Tests for _adjust_shutdown_time_earlier method."""
|
|
||||||
|
|
||||||
def test_returns_false_when_sick_mode_used_today(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test returns False when sick mode already used today."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
with (
|
|
||||||
patch.object(locker, "_restore_original_config_if_needed"),
|
|
||||||
patch.object(locker, "_sick_mode_used_today", return_value=True),
|
|
||||||
):
|
|
||||||
assert locker._adjust_shutdown_time_earlier() is False
|
|
||||||
|
|
||||||
def test_success(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test successful adjustment."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
with (
|
|
||||||
patch.object(locker, "_restore_original_config_if_needed"),
|
|
||||||
patch.object(locker, "_sick_mode_used_today", return_value=False),
|
|
||||||
patch.object(locker, "_apply_earlier_shutdown", return_value=True),
|
|
||||||
):
|
|
||||||
assert locker._adjust_shutdown_time_earlier() is True
|
|
||||||
|
|
||||||
def test_handles_oserror(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test handles OSError during apply."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
with (
|
|
||||||
patch.object(locker, "_restore_original_config_if_needed"),
|
|
||||||
patch.object(locker, "_sick_mode_used_today", return_value=False),
|
|
||||||
patch.object(
|
|
||||||
locker,
|
|
||||||
"_apply_earlier_shutdown",
|
|
||||||
side_effect=OSError("fail"),
|
|
||||||
),
|
|
||||||
):
|
|
||||||
assert locker._adjust_shutdown_time_earlier() is False
|
|
||||||
|
|
||||||
def test_handles_value_error(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test handles ValueError during apply."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
with (
|
|
||||||
patch.object(locker, "_restore_original_config_if_needed"),
|
|
||||||
patch.object(locker, "_sick_mode_used_today", return_value=False),
|
|
||||||
patch.object(
|
|
||||||
locker,
|
|
||||||
"_apply_earlier_shutdown",
|
|
||||||
side_effect=ValueError("bad"),
|
|
||||||
),
|
|
||||||
):
|
|
||||||
assert locker._adjust_shutdown_time_earlier() is False
|
|
||||||
|
|
||||||
|
|
||||||
class TestAdjustShutdownTimeLater:
|
|
||||||
"""Tests for _adjust_shutdown_time_later method."""
|
|
||||||
|
|
||||||
def test_returns_false_when_no_config(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test returns False when config is missing."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
with patch.object(locker, "_read_shutdown_config", return_value=None):
|
|
||||||
assert locker._adjust_shutdown_time_later() is False
|
|
||||||
|
|
||||||
def test_success_applies_later_hours(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test successful later adjustment with restore flag."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
with (
|
|
||||||
patch.object(locker, "_read_shutdown_config", return_value=(20, 19, 8)),
|
|
||||||
patch.object(
|
|
||||||
locker, "_write_shutdown_config", return_value=True
|
|
||||||
) as mock_write,
|
|
||||||
):
|
|
||||||
result = locker._adjust_shutdown_time_later()
|
|
||||||
assert result is True
|
|
||||||
mock_write.assert_called_once_with(22, 21, 8, restore=True)
|
|
||||||
|
|
||||||
def test_clamps_to_max_23(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test hours are clamped to maximum of 23."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
with (
|
|
||||||
patch.object(locker, "_read_shutdown_config", return_value=(22, 23, 8)),
|
|
||||||
patch.object(
|
|
||||||
locker, "_write_shutdown_config", return_value=True
|
|
||||||
) as mock_write,
|
|
||||||
):
|
|
||||||
locker._adjust_shutdown_time_later()
|
|
||||||
mock_write.assert_called_once_with(23, 23, 8, restore=True)
|
|
||||||
|
|
||||||
def test_handles_oserror(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test handles OSError."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
with patch.object(
|
|
||||||
locker,
|
|
||||||
"_read_shutdown_config",
|
|
||||||
side_effect=OSError("fail"),
|
|
||||||
):
|
|
||||||
assert locker._adjust_shutdown_time_later() is False
|
|
||||||
|
|
||||||
|
|
||||||
class TestSickModeUsedToday:
|
|
||||||
"""Tests for _sick_mode_used_today method."""
|
|
||||||
|
|
||||||
def test_returns_false_when_no_file(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test returns False when state file doesn't exist."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
mock_file = MagicMock()
|
|
||||||
mock_file.exists.return_value = False
|
|
||||||
with patch(
|
|
||||||
"python_pkg.screen_locker._shutdown.SICK_DAY_STATE_FILE",
|
|
||||||
mock_file,
|
|
||||||
):
|
|
||||||
assert locker._sick_mode_used_today() is False
|
|
||||||
|
|
||||||
def test_returns_true_when_used_today(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test returns True when state matches today."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
state_file = tmp_path / "state.json"
|
|
||||||
with patch(
|
|
||||||
"python_pkg.screen_locker._shutdown.SICK_DAY_STATE_FILE",
|
|
||||||
state_file,
|
|
||||||
):
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
|
|
||||||
today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d")
|
|
||||||
state_file.write_text(json.dumps({"date": today}))
|
|
||||||
assert locker._sick_mode_used_today() is True
|
|
||||||
|
|
||||||
def test_returns_false_when_different_date(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test returns False when state is from different date."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
state_file = tmp_path / "state.json"
|
|
||||||
with patch(
|
|
||||||
"python_pkg.screen_locker._shutdown.SICK_DAY_STATE_FILE",
|
|
||||||
state_file,
|
|
||||||
):
|
|
||||||
state_file.write_text(json.dumps({"date": "2020-01-01"}))
|
|
||||||
assert locker._sick_mode_used_today() is False
|
|
||||||
|
|
||||||
def test_returns_false_on_json_error(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test returns False on JSONDecodeError."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
state_file = tmp_path / "state.json"
|
|
||||||
with patch(
|
|
||||||
"python_pkg.screen_locker._shutdown.SICK_DAY_STATE_FILE",
|
|
||||||
state_file,
|
|
||||||
):
|
|
||||||
state_file.write_text("not json{{{")
|
|
||||||
assert locker._sick_mode_used_today() is False
|
|
||||||
|
|
||||||
|
|
||||||
class TestSaveSickDayState:
|
|
||||||
"""Tests for _save_sick_day_state method."""
|
|
||||||
|
|
||||||
def test_saves_state_successfully(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test saves state file with correct content."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
state_file = tmp_path / "state.json"
|
|
||||||
with patch(
|
|
||||||
"python_pkg.screen_locker._shutdown.SICK_DAY_STATE_FILE",
|
|
||||||
state_file,
|
|
||||||
):
|
|
||||||
result = locker._save_sick_day_state("2026-03-21", 21, 20)
|
|
||||||
assert result is True
|
|
||||||
data = json.loads(state_file.read_text())
|
|
||||||
assert data["date"] == "2026-03-21"
|
|
||||||
assert data["original_mon_wed_hour"] == 21
|
|
||||||
assert data["original_thu_sun_hour"] == 20
|
|
||||||
|
|
||||||
def test_returns_false_on_oserror(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test returns False when write fails."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
mock_path = MagicMock()
|
|
||||||
mock_path.open.side_effect = OSError("permission denied")
|
|
||||||
with patch(
|
|
||||||
"python_pkg.screen_locker._shutdown.SICK_DAY_STATE_FILE",
|
|
||||||
mock_path,
|
|
||||||
):
|
|
||||||
result = locker._save_sick_day_state("2026-03-21", 21, 20)
|
|
||||||
assert result is False
|
|
||||||
|
|
||||||
|
|
||||||
class TestLoadSickDayState:
|
|
||||||
"""Tests for _load_sick_day_state method."""
|
|
||||||
|
|
||||||
def test_loads_valid_state(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test loads state with all fields present."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
state_file = tmp_path / "state.json"
|
|
||||||
state_file.write_text(
|
|
||||||
json.dumps(
|
|
||||||
{
|
|
||||||
"date": "2026-03-20",
|
|
||||||
"original_mon_wed_hour": 21,
|
|
||||||
"original_thu_sun_hour": 20,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
with patch(
|
|
||||||
"python_pkg.screen_locker._shutdown.SICK_DAY_STATE_FILE",
|
|
||||||
state_file,
|
|
||||||
):
|
|
||||||
result = locker._load_sick_day_state()
|
|
||||||
assert result == ("2026-03-20", 21, 20)
|
|
||||||
|
|
||||||
def test_returns_none_when_fields_missing(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test returns None when required fields are missing."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
state_file = tmp_path / "state.json"
|
|
||||||
state_file.write_text(json.dumps({"date": "2026-03-20"}))
|
|
||||||
with patch(
|
|
||||||
"python_pkg.screen_locker._shutdown.SICK_DAY_STATE_FILE",
|
|
||||||
state_file,
|
|
||||||
):
|
|
||||||
result = locker._load_sick_day_state()
|
|
||||||
assert result is None
|
|
||||||
|
|
||||||
|
|
||||||
class TestWriteRestoredConfig:
|
|
||||||
"""Tests for _write_restored_config method."""
|
|
||||||
|
|
||||||
def test_restores_config_and_removes_state(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test restores config values and deletes state file."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
state_file = tmp_path / "state.json"
|
|
||||||
state_file.write_text("{}")
|
|
||||||
with (
|
|
||||||
patch.object(locker, "_read_shutdown_config", return_value=(20, 19, 8)),
|
|
||||||
patch.object(
|
|
||||||
locker, "_write_shutdown_config", return_value=True
|
|
||||||
) as mock_write,
|
|
||||||
patch(
|
|
||||||
"python_pkg.screen_locker._shutdown.SICK_DAY_STATE_FILE",
|
|
||||||
state_file,
|
|
||||||
),
|
|
||||||
):
|
|
||||||
locker._write_restored_config(21, 20, "2026-03-20")
|
|
||||||
mock_write.assert_called_once_with(21, 20, 8, restore=True)
|
|
||||||
assert not state_file.exists()
|
|
||||||
|
|
||||||
def test_still_removes_state_when_config_read_fails(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test removes state file even when config read returns None."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
state_file = tmp_path / "state.json"
|
|
||||||
state_file.write_text("{}")
|
|
||||||
with (
|
|
||||||
patch.object(locker, "_read_shutdown_config", return_value=None),
|
|
||||||
patch(
|
|
||||||
"python_pkg.screen_locker._shutdown.SICK_DAY_STATE_FILE",
|
|
||||||
state_file,
|
|
||||||
),
|
|
||||||
):
|
|
||||||
locker._write_restored_config(21, 20, "2026-03-20")
|
|
||||||
assert not state_file.exists()
|
|
||||||
@ -1,316 +0,0 @@
|
|||||||
"""Tests for shutdown schedule adjustment coverage gaps (part 3)."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import subprocess
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
from unittest.mock import MagicMock, patch
|
|
||||||
|
|
||||||
from python_pkg.screen_locker._constants import ADJUST_SHUTDOWN_SCRIPT
|
|
||||||
from python_pkg.screen_locker.tests.conftest import create_locker
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
|
|
||||||
class TestRestoreOriginalConfigIfNeeded:
|
|
||||||
"""Tests for _restore_original_config_if_needed method."""
|
|
||||||
|
|
||||||
def test_no_state_file_does_nothing(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test does nothing when no state file exists."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
mock_file = MagicMock()
|
|
||||||
mock_file.exists.return_value = False
|
|
||||||
with patch(
|
|
||||||
"python_pkg.screen_locker._shutdown.SICK_DAY_STATE_FILE",
|
|
||||||
mock_file,
|
|
||||||
):
|
|
||||||
locker._restore_original_config_if_needed()
|
|
||||||
|
|
||||||
def test_restores_when_state_from_previous_day(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test restores config when state date differs from today."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
state_file = tmp_path / "state.json"
|
|
||||||
state_file.write_text(
|
|
||||||
json.dumps(
|
|
||||||
{
|
|
||||||
"date": "2020-01-01",
|
|
||||||
"original_mon_wed_hour": 21,
|
|
||||||
"original_thu_sun_hour": 20,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
with (
|
|
||||||
patch(
|
|
||||||
"python_pkg.screen_locker._shutdown.SICK_DAY_STATE_FILE",
|
|
||||||
state_file,
|
|
||||||
),
|
|
||||||
patch.object(locker, "_write_restored_config") as mock_restore,
|
|
||||||
):
|
|
||||||
locker._restore_original_config_if_needed()
|
|
||||||
mock_restore.assert_called_once_with(21, 20, "2020-01-01")
|
|
||||||
|
|
||||||
def test_does_not_restore_when_state_from_today(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test does not restore when state date matches today."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
|
|
||||||
today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d")
|
|
||||||
state_file = tmp_path / "state.json"
|
|
||||||
state_file.write_text(
|
|
||||||
json.dumps(
|
|
||||||
{
|
|
||||||
"date": today,
|
|
||||||
"original_mon_wed_hour": 21,
|
|
||||||
"original_thu_sun_hour": 20,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
with (
|
|
||||||
patch(
|
|
||||||
"python_pkg.screen_locker._shutdown.SICK_DAY_STATE_FILE",
|
|
||||||
state_file,
|
|
||||||
),
|
|
||||||
patch.object(locker, "_write_restored_config") as mock_restore,
|
|
||||||
):
|
|
||||||
locker._restore_original_config_if_needed()
|
|
||||||
mock_restore.assert_not_called()
|
|
||||||
|
|
||||||
def test_returns_when_loaded_state_is_none(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test returns early when loaded state is None."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
state_file = tmp_path / "state.json"
|
|
||||||
state_file.write_text(json.dumps({"date": "2020-01-01"}))
|
|
||||||
with (
|
|
||||||
patch(
|
|
||||||
"python_pkg.screen_locker._shutdown.SICK_DAY_STATE_FILE",
|
|
||||||
state_file,
|
|
||||||
),
|
|
||||||
patch.object(locker, "_write_restored_config") as mock_restore,
|
|
||||||
):
|
|
||||||
locker._restore_original_config_if_needed()
|
|
||||||
mock_restore.assert_not_called()
|
|
||||||
|
|
||||||
def test_handles_oserror(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test handles OSError when loading state."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
mock_file = MagicMock()
|
|
||||||
mock_file.exists.return_value = True
|
|
||||||
mock_file.open.side_effect = OSError("fail")
|
|
||||||
with patch(
|
|
||||||
"python_pkg.screen_locker._shutdown.SICK_DAY_STATE_FILE",
|
|
||||||
mock_file,
|
|
||||||
):
|
|
||||||
locker._restore_original_config_if_needed()
|
|
||||||
|
|
||||||
def test_handles_json_decode_error(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test handles JSONDecodeError when loading state."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
state_file = tmp_path / "state.json"
|
|
||||||
state_file.write_text("not valid json{{{")
|
|
||||||
with patch(
|
|
||||||
"python_pkg.screen_locker._shutdown.SICK_DAY_STATE_FILE",
|
|
||||||
state_file,
|
|
||||||
):
|
|
||||||
locker._restore_original_config_if_needed()
|
|
||||||
|
|
||||||
|
|
||||||
class TestReadShutdownConfig:
|
|
||||||
"""Tests for _read_shutdown_config method."""
|
|
||||||
|
|
||||||
def test_returns_none_when_file_missing(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test returns None when config file doesn't exist."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
mock_file = MagicMock()
|
|
||||||
mock_file.exists.return_value = False
|
|
||||||
with patch(
|
|
||||||
"python_pkg.screen_locker._shutdown.SHUTDOWN_CONFIG_FILE",
|
|
||||||
mock_file,
|
|
||||||
):
|
|
||||||
assert locker._read_shutdown_config() is None
|
|
||||||
|
|
||||||
def test_reads_valid_config(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test reads all three config values from file."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
config_file = tmp_path / "shutdown.conf"
|
|
||||||
config_file.write_text("MON_WED_HOUR=21\nTHU_SUN_HOUR=20\nMORNING_END_HOUR=8\n")
|
|
||||||
with patch(
|
|
||||||
"python_pkg.screen_locker._shutdown.SHUTDOWN_CONFIG_FILE",
|
|
||||||
config_file,
|
|
||||||
):
|
|
||||||
result = locker._read_shutdown_config()
|
|
||||||
assert result == (21, 20, 8)
|
|
||||||
|
|
||||||
def test_returns_none_when_values_missing(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test returns None when config has missing keys."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
config_file = tmp_path / "shutdown.conf"
|
|
||||||
config_file.write_text("MON_WED_HOUR=21\n")
|
|
||||||
with patch(
|
|
||||||
"python_pkg.screen_locker._shutdown.SHUTDOWN_CONFIG_FILE",
|
|
||||||
config_file,
|
|
||||||
):
|
|
||||||
result = locker._read_shutdown_config()
|
|
||||||
assert result is None
|
|
||||||
|
|
||||||
|
|
||||||
class TestBuildShutdownCmd:
|
|
||||||
"""Tests for _build_shutdown_cmd method."""
|
|
||||||
|
|
||||||
def test_without_restore(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test command without restore flag."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
cmd = locker._build_shutdown_cmd(21, 20, 8, restore=False)
|
|
||||||
assert cmd == [
|
|
||||||
"/usr/bin/sudo",
|
|
||||||
str(ADJUST_SHUTDOWN_SCRIPT),
|
|
||||||
"21",
|
|
||||||
"20",
|
|
||||||
"8",
|
|
||||||
]
|
|
||||||
|
|
||||||
def test_with_restore(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test command with restore flag."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
cmd = locker._build_shutdown_cmd(21, 20, 8, restore=True)
|
|
||||||
assert cmd == [
|
|
||||||
"/usr/bin/sudo",
|
|
||||||
str(ADJUST_SHUTDOWN_SCRIPT),
|
|
||||||
"--restore",
|
|
||||||
"21",
|
|
||||||
"20",
|
|
||||||
"8",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
class TestWriteShutdownConfig:
|
|
||||||
"""Tests for _write_shutdown_config method."""
|
|
||||||
|
|
||||||
def test_returns_false_when_script_missing(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test returns False when adjust script doesn't exist."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
mock_script = MagicMock()
|
|
||||||
mock_script.exists.return_value = False
|
|
||||||
with patch(
|
|
||||||
"python_pkg.screen_locker._shutdown.ADJUST_SHUTDOWN_SCRIPT",
|
|
||||||
mock_script,
|
|
||||||
):
|
|
||||||
result = locker._write_shutdown_config(21, 20, 8)
|
|
||||||
assert result is False
|
|
||||||
|
|
||||||
def test_success_calls_run_shutdown_cmd(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test successful config write delegates to _run_shutdown_cmd."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
mock_script = MagicMock()
|
|
||||||
mock_script.exists.return_value = True
|
|
||||||
with (
|
|
||||||
patch(
|
|
||||||
"python_pkg.screen_locker._shutdown.ADJUST_SHUTDOWN_SCRIPT",
|
|
||||||
mock_script,
|
|
||||||
),
|
|
||||||
patch.object(locker, "_run_shutdown_cmd", return_value=True) as mock_run,
|
|
||||||
):
|
|
||||||
result = locker._write_shutdown_config(21, 20, 8)
|
|
||||||
assert result is True
|
|
||||||
mock_run.assert_called_once()
|
|
||||||
|
|
||||||
|
|
||||||
class TestRunShutdownCmd:
|
|
||||||
"""Tests for _run_shutdown_cmd method."""
|
|
||||||
|
|
||||||
def test_success(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test successful command execution."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
mock_result = MagicMock(stdout="OK\n")
|
|
||||||
with patch(
|
|
||||||
"python_pkg.screen_locker._shutdown.subprocess.run",
|
|
||||||
return_value=mock_result,
|
|
||||||
):
|
|
||||||
result = locker._run_shutdown_cmd(["cmd"], 21, 20)
|
|
||||||
assert result is True
|
|
||||||
|
|
||||||
def test_returns_false_on_subprocess_error(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test returns False on SubprocessError."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
with patch(
|
|
||||||
"python_pkg.screen_locker._shutdown.subprocess.run",
|
|
||||||
side_effect=subprocess.CalledProcessError(1, "cmd"),
|
|
||||||
):
|
|
||||||
result = locker._run_shutdown_cmd(["cmd"], 21, 20)
|
|
||||||
assert result is False
|
|
||||||
@ -1,449 +0,0 @@
|
|||||||
"""Tests for sick-budget UI integration, finalize, debt-clear, and dialogs."""
|
|
||||||
# pylint: disable=protected-access
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
from unittest.mock import MagicMock, patch
|
|
||||||
|
|
||||||
from python_pkg.screen_locker import _sick_tracker
|
|
||||||
from python_pkg.screen_locker._sick_tracker import SickHistory
|
|
||||||
from python_pkg.screen_locker.tests.conftest import create_locker
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# _ui_flows.py — branches added for sick budget + finalize
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
class TestShowRetryAndSickBudget:
|
|
||||||
"""Tests for budget-aware _show_retry_and_sick."""
|
|
||||||
|
|
||||||
def test_shows_sick_button_when_budget_available(
|
|
||||||
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
|
|
||||||
) -> None:
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
with patch.object(_sick_tracker, "load_history", return_value=SickHistory()):
|
|
||||||
locker._show_retry_and_sick("nope")
|
|
||||||
button_texts = {
|
|
||||||
call.args[1] for call in mock_tk.Button.call_args_list if len(call.args) > 1
|
|
||||||
}
|
|
||||||
# Buttons are created via the helper which sets text via kwarg "text".
|
|
||||||
button_texts |= {
|
|
||||||
call.kwargs.get("text") for call in mock_tk.Button.call_args_list
|
|
||||||
}
|
|
||||||
assert "I'm sick" in button_texts
|
|
||||||
|
|
||||||
def test_hides_sick_button_when_budget_exhausted(
|
|
||||||
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
|
|
||||||
) -> None:
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
full = SickHistory(sick_days=["2026-05-09"] * 99)
|
|
||||||
with (
|
|
||||||
patch.object(_sick_tracker, "load_history", return_value=full),
|
|
||||||
patch.object(_sick_tracker, "is_budget_exhausted", return_value=True),
|
|
||||||
):
|
|
||||||
locker._show_retry_and_sick("nope")
|
|
||||||
button_texts: set[str] = set()
|
|
||||||
for call in mock_tk.Button.call_args_list:
|
|
||||||
button_texts.add(call.kwargs.get("text", ""))
|
|
||||||
assert "I'm sick" not in button_texts
|
|
||||||
|
|
||||||
|
|
||||||
class TestProceedToSickCountdownLoadsHistory:
|
|
||||||
"""Covers the no-cache branch of _proceed_to_sick_countdown."""
|
|
||||||
|
|
||||||
def test_loads_history_when_cache_missing(
|
|
||||||
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
|
|
||||||
) -> None:
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
object.__setattr__(locker, "clear_container", MagicMock())
|
|
||||||
object.__setattr__(
|
|
||||||
locker, "_sick_mode_used_today", MagicMock(return_value=False)
|
|
||||||
)
|
|
||||||
object.__setattr__(
|
|
||||||
locker,
|
|
||||||
"_adjust_shutdown_time_earlier",
|
|
||||||
MagicMock(return_value=True),
|
|
||||||
)
|
|
||||||
with patch.object(
|
|
||||||
_sick_tracker, "load_history", return_value=SickHistory()
|
|
||||||
) as mock_load:
|
|
||||||
locker._proceed_to_sick_countdown()
|
|
||||||
mock_load.assert_called_once()
|
|
||||||
assert hasattr(locker, "_sick_history_cache")
|
|
||||||
|
|
||||||
|
|
||||||
class TestFinalizeSickDay:
|
|
||||||
"""Covers _finalize_sick_day branches including commitment penalty."""
|
|
||||||
|
|
||||||
def test_marks_commitment_broken_and_writes_debt(
|
|
||||||
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
|
|
||||||
) -> None:
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
locker.workout_data = {}
|
|
||||||
history = SickHistory(commitments={"2026-05-10": True})
|
|
||||||
locker._sick_history_cache = history
|
|
||||||
object.__setattr__(locker, "unlock_screen", MagicMock())
|
|
||||||
with (
|
|
||||||
patch.object(_sick_tracker, "had_commitment_for_today", return_value=True),
|
|
||||||
patch.object(_sick_tracker, "save_history", return_value=True),
|
|
||||||
):
|
|
||||||
locker._finalize_sick_day()
|
|
||||||
assert locker.workout_data["broke_commitment"] == "true"
|
|
||||||
assert locker.workout_data["type"] == "sick_day"
|
|
||||||
assert "debt" in locker.workout_data
|
|
||||||
locker.unlock_screen.assert_called_once()
|
|
||||||
|
|
||||||
def test_loads_history_when_cache_missing(
|
|
||||||
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
|
|
||||||
) -> None:
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
locker.workout_data = {}
|
|
||||||
object.__setattr__(locker, "unlock_screen", MagicMock())
|
|
||||||
with (
|
|
||||||
patch.object(
|
|
||||||
_sick_tracker, "load_history", return_value=SickHistory()
|
|
||||||
) as mock_load,
|
|
||||||
patch.object(_sick_tracker, "save_history", return_value=True),
|
|
||||||
):
|
|
||||||
locker._finalize_sick_day()
|
|
||||||
mock_load.assert_called_once()
|
|
||||||
locker.unlock_screen.assert_called_once()
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# screen_lock.py — _clear_debt_on_verified_workout branches
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
class TestClearDebtOnVerifiedWorkout:
|
|
||||||
"""Tests for _clear_debt_on_verified_workout."""
|
|
||||||
|
|
||||||
def test_returns_none_when_not_phone_verified(
|
|
||||||
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
|
|
||||||
) -> None:
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
locker.workout_data = {"type": "sick_day"}
|
|
||||||
assert locker._clear_debt_on_verified_workout() is None
|
|
||||||
|
|
||||||
def test_returns_zero_when_no_debt(
|
|
||||||
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
|
|
||||||
) -> None:
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
locker.workout_data = {"type": "phone_verified"}
|
|
||||||
with patch.object(
|
|
||||||
_sick_tracker, "load_history", return_value=SickHistory(debt=0)
|
|
||||||
):
|
|
||||||
assert locker._clear_debt_on_verified_workout() == 0
|
|
||||||
|
|
||||||
def test_decrements_when_debt_positive(
|
|
||||||
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
|
|
||||||
) -> None:
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
locker.workout_data = {"type": "phone_verified"}
|
|
||||||
history = SickHistory(debt=2)
|
|
||||||
with (
|
|
||||||
patch.object(_sick_tracker, "load_history", return_value=history),
|
|
||||||
patch.object(_sick_tracker, "save_history", return_value=True) as mock_save,
|
|
||||||
):
|
|
||||||
assert locker._clear_debt_on_verified_workout() == 1
|
|
||||||
mock_save.assert_called_once()
|
|
||||||
|
|
||||||
|
|
||||||
class TestUnlockScreenCommitmentPrompt:
|
|
||||||
"""Tests for unlock_screen branches around commitment prompt + debt label."""
|
|
||||||
|
|
||||||
def test_phone_verified_schedules_commitment_prompt(
|
|
||||||
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
|
|
||||||
) -> None:
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
locker.workout_data = {"type": "phone_verified"}
|
|
||||||
locker.log_file = tmp_path / "log.json"
|
|
||||||
object.__setattr__(locker, "save_workout_log", MagicMock())
|
|
||||||
object.__setattr__(
|
|
||||||
locker,
|
|
||||||
"_try_adjust_shutdown_for_workout",
|
|
||||||
MagicMock(return_value=False),
|
|
||||||
)
|
|
||||||
object.__setattr__(
|
|
||||||
locker,
|
|
||||||
"_clear_debt_on_verified_workout",
|
|
||||||
MagicMock(return_value=0),
|
|
||||||
)
|
|
||||||
locker.unlock_screen()
|
|
||||||
# The last after() call schedules the commitment prompt closure.
|
|
||||||
last_call = locker.root.after.call_args_list[-1]
|
|
||||||
assert last_call.args[0] == 1500
|
|
||||||
|
|
||||||
def test_non_verified_schedules_close_directly(
|
|
||||||
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
|
|
||||||
) -> None:
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
locker.workout_data = {"type": "sick_day"}
|
|
||||||
locker.log_file = tmp_path / "log.json"
|
|
||||||
object.__setattr__(locker, "save_workout_log", MagicMock())
|
|
||||||
object.__setattr__(
|
|
||||||
locker,
|
|
||||||
"_try_adjust_shutdown_for_workout",
|
|
||||||
MagicMock(return_value=False),
|
|
||||||
)
|
|
||||||
object.__setattr__(
|
|
||||||
locker,
|
|
||||||
"_clear_debt_on_verified_workout",
|
|
||||||
MagicMock(return_value=None),
|
|
||||||
)
|
|
||||||
locker.unlock_screen()
|
|
||||||
# close() goes through root.after directly.
|
|
||||||
locker.root.after.assert_called_with(1500, locker.close)
|
|
||||||
|
|
||||||
def test_renders_debt_label_when_positive(
|
|
||||||
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
|
|
||||||
) -> None:
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
locker.workout_data = {"type": "phone_verified"}
|
|
||||||
locker.log_file = tmp_path / "log.json"
|
|
||||||
object.__setattr__(locker, "save_workout_log", MagicMock())
|
|
||||||
object.__setattr__(
|
|
||||||
locker,
|
|
||||||
"_try_adjust_shutdown_for_workout",
|
|
||||||
MagicMock(return_value=True),
|
|
||||||
)
|
|
||||||
object.__setattr__(
|
|
||||||
locker,
|
|
||||||
"_clear_debt_on_verified_workout",
|
|
||||||
MagicMock(return_value=2),
|
|
||||||
)
|
|
||||||
locker.unlock_screen()
|
|
||||||
# _text was called via mock_tk.Label; just assert a Label call mentions debt.
|
|
||||||
labels = [call.kwargs.get("text", "") for call in mock_tk.Label.call_args_list]
|
|
||||||
assert any("Workout debt: 2" in t for t in labels)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# _sick_dialog.py — UI mixin
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
class TestShowSickJustification:
|
|
||||||
"""Tests for the structured sick justification dialog."""
|
|
||||||
|
|
||||||
def test_renders_form_without_commitment(
|
|
||||||
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
|
|
||||||
) -> None:
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
with patch.object(_sick_tracker, "load_history", return_value=SickHistory()):
|
|
||||||
locker._show_sick_justification()
|
|
||||||
assert locker._sick_history_cache.sick_days == []
|
|
||||||
assert hasattr(locker, "_sick_submit_button")
|
|
||||||
# Submit button starts enabled (no commitment).
|
|
||||||
# config(state="disabled") only called for commitment path.
|
|
||||||
for call in locker._sick_submit_button.config.call_args_list:
|
|
||||||
assert call.kwargs.get("state") != "disabled"
|
|
||||||
|
|
||||||
def test_renders_form_with_commitment_disables_submit(
|
|
||||||
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
|
|
||||||
) -> None:
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
history = SickHistory(commitments={"2026-05-10": True})
|
|
||||||
with (
|
|
||||||
patch.object(_sick_tracker, "load_history", return_value=history),
|
|
||||||
patch.object(_sick_tracker, "had_commitment_for_today", return_value=True),
|
|
||||||
):
|
|
||||||
locker._show_sick_justification()
|
|
||||||
# Submit button was disabled and forced-delay started.
|
|
||||||
states = [
|
|
||||||
call.kwargs.get("state")
|
|
||||||
for call in locker._sick_submit_button.config.call_args_list
|
|
||||||
]
|
|
||||||
assert "disabled" in states
|
|
||||||
|
|
||||||
def test_renders_recent_history_when_present(
|
|
||||||
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
|
|
||||||
) -> None:
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
history = SickHistory(
|
|
||||||
justifications=[
|
|
||||||
{"date": "2026-05-01", "symptom": "fever", "severity": 7},
|
|
||||||
],
|
|
||||||
)
|
|
||||||
with patch.object(_sick_tracker, "load_history", return_value=history):
|
|
||||||
locker._show_sick_justification()
|
|
||||||
labels = [call.kwargs.get("text", "") for call in mock_tk.Label.call_args_list]
|
|
||||||
assert any("Recent sick days" in t for t in labels)
|
|
||||||
|
|
||||||
|
|
||||||
class TestUpdateCommitmentForcedDelay:
|
|
||||||
"""Tests for _update_commitment_forced_delay."""
|
|
||||||
|
|
||||||
def test_ticks_down(
|
|
||||||
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
|
|
||||||
) -> None:
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
locker._sick_submit_button = MagicMock()
|
|
||||||
locker._commitment_forced_remaining = 3
|
|
||||||
locker._update_commitment_forced_delay()
|
|
||||||
assert locker._commitment_forced_remaining == 2
|
|
||||||
locker.root.after.assert_called()
|
|
||||||
|
|
||||||
def test_enables_when_done(
|
|
||||||
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
|
|
||||||
) -> None:
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
locker._sick_submit_button = MagicMock()
|
|
||||||
locker._commitment_forced_remaining = 0
|
|
||||||
locker._update_commitment_forced_delay()
|
|
||||||
locker._sick_submit_button.config.assert_called_with(
|
|
||||||
text="SUBMIT", state="normal"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestSubmitSickJustification:
|
|
||||||
"""Tests for _submit_sick_justification validation + persistence."""
|
|
||||||
|
|
||||||
def _setup_locker(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
*,
|
|
||||||
fields: dict[str, object] | None = None,
|
|
||||||
) -> object:
|
|
||||||
defaults: dict[str, object] = {
|
|
||||||
"symptom": "fever",
|
|
||||||
"onset": "last night",
|
|
||||||
"severity": 7,
|
|
||||||
"text": "x" * 200,
|
|
||||||
}
|
|
||||||
if fields:
|
|
||||||
defaults.update(fields)
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
locker._sick_history_cache = SickHistory()
|
|
||||||
locker._sick_symptom_var = MagicMock()
|
|
||||||
locker._sick_symptom_var.get.return_value = defaults["symptom"]
|
|
||||||
locker._sick_onset_var = MagicMock()
|
|
||||||
locker._sick_onset_var.get.return_value = defaults["onset"]
|
|
||||||
locker._sick_severity_var = MagicMock()
|
|
||||||
locker._sick_severity_var.get.return_value = defaults["severity"]
|
|
||||||
locker._sick_text_widget = MagicMock()
|
|
||||||
locker._sick_text_widget.get.return_value = defaults["text"]
|
|
||||||
locker._sick_error_label = MagicMock()
|
|
||||||
object.__setattr__(locker, "_proceed_to_sick_countdown", MagicMock())
|
|
||||||
return locker
|
|
||||||
|
|
||||||
def test_validation_failure_displays_error(
|
|
||||||
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
|
|
||||||
) -> None:
|
|
||||||
locker = self._setup_locker(mock_tk, tmp_path, fields={"symptom": ""})
|
|
||||||
locker._submit_sick_justification()
|
|
||||||
locker._sick_error_label.config.assert_called_once()
|
|
||||||
locker._proceed_to_sick_countdown.assert_not_called()
|
|
||||||
|
|
||||||
def test_severity_tcl_error_treated_as_invalid(
|
|
||||||
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
|
|
||||||
) -> None:
|
|
||||||
locker = self._setup_locker(mock_tk, tmp_path)
|
|
||||||
locker._sick_severity_var.get.side_effect = ValueError("bad")
|
|
||||||
locker._submit_sick_justification()
|
|
||||||
locker._sick_error_label.config.assert_called_once()
|
|
||||||
|
|
||||||
def test_save_failure_displays_error(
|
|
||||||
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
|
|
||||||
) -> None:
|
|
||||||
locker = self._setup_locker(mock_tk, tmp_path)
|
|
||||||
with patch.object(_sick_tracker, "save_history", return_value=False):
|
|
||||||
locker._submit_sick_justification()
|
|
||||||
locker._sick_error_label.config.assert_called_once()
|
|
||||||
locker._proceed_to_sick_countdown.assert_not_called()
|
|
||||||
|
|
||||||
def test_success_proceeds_to_countdown(
|
|
||||||
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
|
|
||||||
) -> None:
|
|
||||||
locker = self._setup_locker(mock_tk, tmp_path)
|
|
||||||
with patch.object(_sick_tracker, "save_history", return_value=True):
|
|
||||||
locker._submit_sick_justification()
|
|
||||||
locker._proceed_to_sick_countdown.assert_called_once()
|
|
||||||
|
|
||||||
|
|
||||||
class TestCommitmentPrompt:
|
|
||||||
"""Tests for _show_commitment_prompt + _tick_commitment_timeout + answer."""
|
|
||||||
|
|
||||||
def test_show_prompt_renders_buttons(
|
|
||||||
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
|
|
||||||
) -> None:
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
on_done = MagicMock()
|
|
||||||
locker._show_commitment_prompt(on_done=on_done)
|
|
||||||
assert locker._commitment_done_fn is on_done
|
|
||||||
assert locker._commitment_remaining > 0
|
|
||||||
|
|
||||||
def test_tick_decrements(
|
|
||||||
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
|
|
||||||
) -> None:
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
locker._commitment_remaining = 2
|
|
||||||
locker._commitment_timer_label = MagicMock()
|
|
||||||
locker._tick_commitment_timeout()
|
|
||||||
assert locker._commitment_remaining == 1
|
|
||||||
locker.root.after.assert_called()
|
|
||||||
|
|
||||||
def test_tick_zero_auto_answers_no(
|
|
||||||
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
|
|
||||||
) -> None:
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
on_done = MagicMock()
|
|
||||||
locker._commitment_done_fn = on_done
|
|
||||||
locker._commitment_remaining = 0
|
|
||||||
locker._commitment_timer_label = MagicMock()
|
|
||||||
locker._tick_commitment_timeout()
|
|
||||||
on_done.assert_called_once()
|
|
||||||
|
|
||||||
def test_answer_yes_persists_commitment(
|
|
||||||
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
|
|
||||||
) -> None:
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
on_done = MagicMock()
|
|
||||||
locker._commitment_done_fn = on_done
|
|
||||||
history = SickHistory()
|
|
||||||
with (
|
|
||||||
patch.object(_sick_tracker, "load_history", return_value=history),
|
|
||||||
patch.object(_sick_tracker, "save_history", return_value=True) as mock_save,
|
|
||||||
):
|
|
||||||
locker._answer_commitment(commit=True)
|
|
||||||
mock_save.assert_called_once()
|
|
||||||
on_done.assert_called_once()
|
|
||||||
assert locker._commitment_done_fn is None
|
|
||||||
|
|
||||||
def test_answer_no_skips_persistence(
|
|
||||||
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
|
|
||||||
) -> None:
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
on_done = MagicMock()
|
|
||||||
locker._commitment_done_fn = on_done
|
|
||||||
with patch.object(_sick_tracker, "save_history") as mock_save:
|
|
||||||
locker._answer_commitment(commit=False)
|
|
||||||
mock_save.assert_not_called()
|
|
||||||
on_done.assert_called_once()
|
|
||||||
|
|
||||||
def test_answer_with_no_done_fn_is_safe(
|
|
||||||
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
|
|
||||||
) -> None:
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
# No _commitment_done_fn attribute set.
|
|
||||||
locker._answer_commitment(commit=False)
|
|
||||||
|
|
||||||
|
|
||||||
class TestDisablePaste:
|
|
||||||
"""Tests for the _disable_paste helper."""
|
|
||||||
|
|
||||||
def test_swallows_tcl_error(self) -> None:
|
|
||||||
from python_pkg.screen_locker._sick_dialog import _disable_paste
|
|
||||||
|
|
||||||
widget = MagicMock()
|
|
||||||
import tkinter as tk
|
|
||||||
|
|
||||||
widget.bind.side_effect = tk.TclError("nope")
|
|
||||||
# Should not raise.
|
|
||||||
_disable_paste(widget)
|
|
||||||
@ -1,386 +0,0 @@
|
|||||||
"""Tests for the sick-day tracker pure-logic module."""
|
|
||||||
# pylint: disable=protected-access
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from python_pkg.screen_locker import _sick_tracker
|
|
||||||
from python_pkg.screen_locker._constants import (
|
|
||||||
SICK_BUDGET_PER_7_DAYS,
|
|
||||||
SICK_BUDGET_PER_30_DAYS,
|
|
||||||
SICK_BUDGET_PER_90_DAYS,
|
|
||||||
SICK_COMMITMENT_PENALTY_DAYS,
|
|
||||||
SICK_HISTORY_REVIEW_COUNT,
|
|
||||||
SICK_JUSTIFICATION_MIN_CHARS,
|
|
||||||
SICK_LOCKOUT_MULTIPLIER_PER_RECENT,
|
|
||||||
SICK_LOCKOUT_SECONDS,
|
|
||||||
)
|
|
||||||
from python_pkg.screen_locker._sick_tracker import (
|
|
||||||
JustificationDraft,
|
|
||||||
SickHistory,
|
|
||||||
add_justification,
|
|
||||||
add_sick_day,
|
|
||||||
budget_summary,
|
|
||||||
clear_one_debt,
|
|
||||||
compute_lockout_seconds,
|
|
||||||
count_in_window,
|
|
||||||
format_recent_justifications,
|
|
||||||
had_commitment_for_today,
|
|
||||||
is_budget_exhausted,
|
|
||||||
load_history,
|
|
||||||
mark_commitment_broken,
|
|
||||||
recent_justifications,
|
|
||||||
record_commitment_for_tomorrow,
|
|
||||||
save_history,
|
|
||||||
validate_justification,
|
|
||||||
)
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
|
|
||||||
_TODAY = "2026-05-10"
|
|
||||||
|
|
||||||
|
|
||||||
class TestLoadHistory:
|
|
||||||
"""Tests for load_history."""
|
|
||||||
|
|
||||||
def test_returns_empty_when_file_missing(self) -> None:
|
|
||||||
history = load_history()
|
|
||||||
assert history == SickHistory()
|
|
||||||
|
|
||||||
def test_reads_existing_file(self, tmp_path: Path) -> None:
|
|
||||||
target = tmp_path / "sick_history.json"
|
|
||||||
target.write_text(
|
|
||||||
'{"sick_days": ["2026-05-01"], "debt": 2,'
|
|
||||||
' "commitments": {"2026-05-10": true},'
|
|
||||||
' "broken_commitments": ["2026-05-09"],'
|
|
||||||
' "justifications": [{"date": "2026-05-01"}]}'
|
|
||||||
)
|
|
||||||
with patch.object(_sick_tracker, "SICK_HISTORY_FILE", target):
|
|
||||||
history = load_history()
|
|
||||||
assert history.sick_days == ["2026-05-01"]
|
|
||||||
assert history.debt == 2
|
|
||||||
assert history.commitments == {"2026-05-10": True}
|
|
||||||
assert history.broken_commitments == ["2026-05-09"]
|
|
||||||
assert history.justifications == [{"date": "2026-05-01"}]
|
|
||||||
|
|
||||||
def test_returns_empty_on_corrupt_json(self, tmp_path: Path) -> None:
|
|
||||||
target = tmp_path / "sick_history.json"
|
|
||||||
target.write_text("not json")
|
|
||||||
with patch.object(_sick_tracker, "SICK_HISTORY_FILE", target):
|
|
||||||
assert load_history() == SickHistory()
|
|
||||||
|
|
||||||
def test_returns_empty_on_oserror(self, tmp_path: Path) -> None:
|
|
||||||
target = tmp_path / "sick_history.json"
|
|
||||||
target.write_text("{}")
|
|
||||||
with (
|
|
||||||
patch.object(_sick_tracker, "SICK_HISTORY_FILE", target),
|
|
||||||
patch.object(type(target), "open", side_effect=OSError("boom")),
|
|
||||||
):
|
|
||||||
assert load_history() == SickHistory()
|
|
||||||
|
|
||||||
|
|
||||||
class TestSaveHistory:
|
|
||||||
"""Tests for save_history."""
|
|
||||||
|
|
||||||
def test_persists_history(self, tmp_path: Path) -> None:
|
|
||||||
target = tmp_path / "sick_history.json"
|
|
||||||
with patch.object(_sick_tracker, "SICK_HISTORY_FILE", target):
|
|
||||||
history = SickHistory(sick_days=["2026-05-01"], debt=1)
|
|
||||||
assert save_history(history) is True
|
|
||||||
reloaded = load_history()
|
|
||||||
assert reloaded == history
|
|
||||||
|
|
||||||
def test_returns_false_on_oserror(self, tmp_path: Path) -> None:
|
|
||||||
target = tmp_path / "missing_dir" / "sick_history.json"
|
|
||||||
with patch.object(_sick_tracker, "SICK_HISTORY_FILE", target):
|
|
||||||
assert save_history(SickHistory()) is False
|
|
||||||
|
|
||||||
|
|
||||||
class TestCountInWindow:
|
|
||||||
"""Tests for count_in_window."""
|
|
||||||
|
|
||||||
def test_counts_only_within_window(self) -> None:
|
|
||||||
history = SickHistory(
|
|
||||||
sick_days=[
|
|
||||||
"2026-05-09", # 1 day ago: in 7d, 30d, 90d
|
|
||||||
"2026-05-03", # 7 days ago: NOT in 7d (cutoff exclusive)
|
|
||||||
"2026-04-25", # 15 days ago: NOT in 7d, in 30d, 90d
|
|
||||||
"2026-01-01", # ~130 days ago: outside 90d
|
|
||||||
],
|
|
||||||
)
|
|
||||||
assert count_in_window(history, 7, today=_TODAY) == 1
|
|
||||||
assert count_in_window(history, 30, today=_TODAY) == 3
|
|
||||||
assert count_in_window(history, 90, today=_TODAY) == 3
|
|
||||||
|
|
||||||
def test_skips_invalid_date_strings(self) -> None:
|
|
||||||
history = SickHistory(sick_days=["bad-date", "2026-05-09"])
|
|
||||||
assert count_in_window(history, 7, today=_TODAY) == 1
|
|
||||||
|
|
||||||
def test_returns_zero_when_today_invalid(self) -> None:
|
|
||||||
history = SickHistory(sick_days=["2026-05-09"])
|
|
||||||
assert count_in_window(history, 7, today="bogus") == 0
|
|
||||||
|
|
||||||
def test_uses_today_default_when_none(self) -> None:
|
|
||||||
history = SickHistory(sick_days=[])
|
|
||||||
assert count_in_window(history, 7) == 0
|
|
||||||
|
|
||||||
|
|
||||||
class TestIsBudgetExhausted:
|
|
||||||
"""Tests for is_budget_exhausted."""
|
|
||||||
|
|
||||||
def test_false_when_under_budget(self) -> None:
|
|
||||||
assert is_budget_exhausted(SickHistory(), today=_TODAY) is False
|
|
||||||
|
|
||||||
def test_true_when_weekly_exhausted(self) -> None:
|
|
||||||
history = SickHistory(
|
|
||||||
sick_days=["2026-05-09"] * SICK_BUDGET_PER_7_DAYS,
|
|
||||||
)
|
|
||||||
assert is_budget_exhausted(history, today=_TODAY) is True
|
|
||||||
|
|
||||||
def test_true_when_monthly_exhausted(self) -> None:
|
|
||||||
# Spread far enough apart to all be in 30d but not 7d.
|
|
||||||
history = SickHistory(
|
|
||||||
sick_days=[
|
|
||||||
"2026-05-08",
|
|
||||||
"2026-04-28",
|
|
||||||
"2026-04-18",
|
|
||||||
][:SICK_BUDGET_PER_30_DAYS],
|
|
||||||
)
|
|
||||||
assert is_budget_exhausted(history, today=_TODAY) is True
|
|
||||||
|
|
||||||
def test_true_when_quarterly_exhausted(self) -> None:
|
|
||||||
# All in 90d but only 1 in 30d.
|
|
||||||
days = [
|
|
||||||
"2026-05-09",
|
|
||||||
"2026-04-01",
|
|
||||||
"2026-03-15",
|
|
||||||
"2026-03-10",
|
|
||||||
"2026-03-05",
|
|
||||||
"2026-03-01",
|
|
||||||
"2026-02-28",
|
|
||||||
"2026-02-25",
|
|
||||||
"2026-02-20",
|
|
||||||
"2026-02-15",
|
|
||||||
]
|
|
||||||
history = SickHistory(sick_days=days[:SICK_BUDGET_PER_90_DAYS])
|
|
||||||
assert is_budget_exhausted(history, today=_TODAY) is True
|
|
||||||
|
|
||||||
|
|
||||||
class TestComputeLockoutSeconds:
|
|
||||||
"""Tests for compute_lockout_seconds."""
|
|
||||||
|
|
||||||
def test_base_when_no_recent(self) -> None:
|
|
||||||
assert (
|
|
||||||
compute_lockout_seconds(SickHistory(), today=_TODAY) == SICK_LOCKOUT_SECONDS
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_doubles_per_recent(self) -> None:
|
|
||||||
history = SickHistory(sick_days=["2026-05-09", "2026-04-20"])
|
|
||||||
recent = 2 # both within 30d
|
|
||||||
expected = SICK_LOCKOUT_SECONDS * (SICK_LOCKOUT_MULTIPLIER_PER_RECENT**recent)
|
|
||||||
assert compute_lockout_seconds(history, today=_TODAY) == expected
|
|
||||||
|
|
||||||
|
|
||||||
class TestBudgetSummary:
|
|
||||||
"""Tests for budget_summary."""
|
|
||||||
|
|
||||||
def test_renders_all_windows_and_debt(self) -> None:
|
|
||||||
history = SickHistory(sick_days=["2026-05-09"], debt=3)
|
|
||||||
summary = budget_summary(history, today=_TODAY)
|
|
||||||
assert "Sick:" in summary
|
|
||||||
assert "1/" in summary
|
|
||||||
assert "Debt: 3" in summary
|
|
||||||
|
|
||||||
|
|
||||||
class TestAddSickDay:
|
|
||||||
"""Tests for add_sick_day."""
|
|
||||||
|
|
||||||
def test_adds_today_and_increments_debt(self) -> None:
|
|
||||||
history = SickHistory()
|
|
||||||
new_debt = add_sick_day(history, today=_TODAY)
|
|
||||||
assert history.sick_days == [_TODAY]
|
|
||||||
assert new_debt == 1
|
|
||||||
|
|
||||||
def test_idempotent_on_same_day(self) -> None:
|
|
||||||
history = SickHistory(sick_days=[_TODAY], debt=0)
|
|
||||||
new_debt = add_sick_day(history, today=_TODAY)
|
|
||||||
assert history.sick_days == [_TODAY]
|
|
||||||
# Debt still increments by 1 even if the date is already present.
|
|
||||||
assert new_debt == 1
|
|
||||||
|
|
||||||
def test_double_penalty_when_commitment_broken(self) -> None:
|
|
||||||
history = SickHistory(broken_commitments=[_TODAY])
|
|
||||||
new_debt = add_sick_day(history, today=_TODAY)
|
|
||||||
assert new_debt == SICK_COMMITMENT_PENALTY_DAYS
|
|
||||||
|
|
||||||
|
|
||||||
class TestClearOneDebt:
|
|
||||||
"""Tests for clear_one_debt."""
|
|
||||||
|
|
||||||
def test_decrements_when_positive(self) -> None:
|
|
||||||
history = SickHistory(debt=2)
|
|
||||||
assert clear_one_debt(history) == 1
|
|
||||||
assert history.debt == 1
|
|
||||||
|
|
||||||
def test_clamped_at_zero(self) -> None:
|
|
||||||
history = SickHistory(debt=0)
|
|
||||||
assert clear_one_debt(history) == 0
|
|
||||||
|
|
||||||
|
|
||||||
class TestRecordCommitment:
|
|
||||||
"""Tests for record_commitment_for_tomorrow + had_commitment_for_today."""
|
|
||||||
|
|
||||||
def test_records_for_tomorrow(self) -> None:
|
|
||||||
history = SickHistory()
|
|
||||||
result = record_commitment_for_tomorrow(history, today=_TODAY)
|
|
||||||
assert result == "2026-05-11"
|
|
||||||
assert history.commitments["2026-05-11"] is True
|
|
||||||
|
|
||||||
def test_returns_today_when_today_invalid(self) -> None:
|
|
||||||
history = SickHistory()
|
|
||||||
result = record_commitment_for_tomorrow(history, today="bogus")
|
|
||||||
assert result == "bogus"
|
|
||||||
assert history.commitments == {}
|
|
||||||
|
|
||||||
def test_had_commitment_returns_true(self) -> None:
|
|
||||||
history = SickHistory(commitments={_TODAY: True})
|
|
||||||
assert had_commitment_for_today(history, today=_TODAY) is True
|
|
||||||
|
|
||||||
def test_had_commitment_returns_false(self) -> None:
|
|
||||||
assert had_commitment_for_today(SickHistory(), today=_TODAY) is False
|
|
||||||
|
|
||||||
|
|
||||||
class TestMarkCommitmentBroken:
|
|
||||||
"""Tests for mark_commitment_broken."""
|
|
||||||
|
|
||||||
def test_appends_when_committed(self) -> None:
|
|
||||||
history = SickHistory(commitments={_TODAY: True})
|
|
||||||
mark_commitment_broken(history, today=_TODAY)
|
|
||||||
assert history.broken_commitments == [_TODAY]
|
|
||||||
|
|
||||||
def test_idempotent(self) -> None:
|
|
||||||
history = SickHistory(commitments={_TODAY: True}, broken_commitments=[_TODAY])
|
|
||||||
mark_commitment_broken(history, today=_TODAY)
|
|
||||||
assert history.broken_commitments == [_TODAY]
|
|
||||||
|
|
||||||
def test_noop_when_no_commitment(self) -> None:
|
|
||||||
history = SickHistory()
|
|
||||||
mark_commitment_broken(history, today=_TODAY)
|
|
||||||
assert history.broken_commitments == []
|
|
||||||
|
|
||||||
|
|
||||||
class TestValidateJustification:
|
|
||||||
"""Tests for validate_justification."""
|
|
||||||
|
|
||||||
def _good_text(self) -> str:
|
|
||||||
return "x" * SICK_JUSTIFICATION_MIN_CHARS
|
|
||||||
|
|
||||||
def _draft(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
symptom: str | None = None,
|
|
||||||
onset: str | None = None,
|
|
||||||
severity: int | None = None,
|
|
||||||
text: str | None = None,
|
|
||||||
) -> JustificationDraft:
|
|
||||||
return JustificationDraft(
|
|
||||||
symptom="fever" if symptom is None else symptom,
|
|
||||||
onset="last night" if onset is None else onset,
|
|
||||||
severity=7 if severity is None else severity,
|
|
||||||
text=self._good_text() if text is None else text,
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_returns_none_when_valid(self) -> None:
|
|
||||||
assert validate_justification(self._draft()) is None
|
|
||||||
|
|
||||||
def test_rejects_blank_symptom(self) -> None:
|
|
||||||
assert validate_justification(self._draft(symptom=" ")) is not None
|
|
||||||
|
|
||||||
def test_rejects_blank_onset(self) -> None:
|
|
||||||
assert validate_justification(self._draft(onset="")) is not None
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("severity", [0, 11, -1])
|
|
||||||
def test_rejects_severity_out_of_range(self, severity: int) -> None:
|
|
||||||
assert validate_justification(self._draft(severity=severity)) is not None
|
|
||||||
|
|
||||||
def test_rejects_short_text(self) -> None:
|
|
||||||
assert validate_justification(self._draft(text="too short")) is not None
|
|
||||||
|
|
||||||
|
|
||||||
class TestAddJustification:
|
|
||||||
"""Tests for add_justification."""
|
|
||||||
|
|
||||||
def _draft(self, text: str = " full description text ") -> JustificationDraft:
|
|
||||||
return JustificationDraft(
|
|
||||||
symptom="fever",
|
|
||||||
onset="last night",
|
|
||||||
severity=7,
|
|
||||||
text=text,
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_appends_entry_with_hmac_when_key_present(self) -> None:
|
|
||||||
history = SickHistory()
|
|
||||||
with patch.object(_sick_tracker, "compute_entry_hmac", return_value="deadbeef"):
|
|
||||||
entry = add_justification(history, self._draft(), today=_TODAY)
|
|
||||||
assert history.justifications == [entry]
|
|
||||||
assert entry["hmac"] == "deadbeef"
|
|
||||||
assert entry["text"] == "full description text"
|
|
||||||
assert entry["symptom"] == "fever"
|
|
||||||
assert entry["severity"] == 7
|
|
||||||
assert entry["date"] == _TODAY
|
|
||||||
|
|
||||||
def test_omits_hmac_when_key_unavailable(self) -> None:
|
|
||||||
history = SickHistory()
|
|
||||||
with patch.object(_sick_tracker, "compute_entry_hmac", return_value=None):
|
|
||||||
entry = add_justification(
|
|
||||||
history,
|
|
||||||
self._draft(text="full description"),
|
|
||||||
today=_TODAY,
|
|
||||||
)
|
|
||||||
assert "hmac" not in entry
|
|
||||||
|
|
||||||
|
|
||||||
class TestRecentJustifications:
|
|
||||||
"""Tests for recent_justifications + format_recent_justifications."""
|
|
||||||
|
|
||||||
def test_returns_last_n(self) -> None:
|
|
||||||
history = SickHistory(
|
|
||||||
justifications=[{"i": i} for i in range(5)],
|
|
||||||
)
|
|
||||||
assert recent_justifications(history, 2) == [{"i": 3}, {"i": 4}]
|
|
||||||
|
|
||||||
def test_returns_empty_list_when_n_zero(self) -> None:
|
|
||||||
history = SickHistory(justifications=[{"i": 0}])
|
|
||||||
assert recent_justifications(history, 0) == []
|
|
||||||
|
|
||||||
def test_default_n_is_review_count(self) -> None:
|
|
||||||
history = SickHistory(
|
|
||||||
justifications=[{"i": i} for i in range(SICK_HISTORY_REVIEW_COUNT + 5)],
|
|
||||||
)
|
|
||||||
assert len(recent_justifications(history)) == SICK_HISTORY_REVIEW_COUNT
|
|
||||||
|
|
||||||
def test_format_returns_empty_when_no_history(self) -> None:
|
|
||||||
assert format_recent_justifications(SickHistory()) == ""
|
|
||||||
|
|
||||||
def test_format_renders_lines(self) -> None:
|
|
||||||
history = SickHistory(
|
|
||||||
justifications=[
|
|
||||||
{"date": "2026-05-01", "symptom": "fever", "severity": 7},
|
|
||||||
{"date": "2026-04-15", "symptom": "headache", "severity": 4},
|
|
||||||
],
|
|
||||||
)
|
|
||||||
out = format_recent_justifications(history)
|
|
||||||
assert "2026-05-01" in out
|
|
||||||
assert "fever" in out
|
|
||||||
assert "headache" in out
|
|
||||||
|
|
||||||
def test_format_handles_missing_fields(self) -> None:
|
|
||||||
history = SickHistory(justifications=[{}])
|
|
||||||
out = format_recent_justifications(history)
|
|
||||||
assert "?" in out
|
|
||||||
@ -1,108 +0,0 @@
|
|||||||
"""Tests for _time_check NTP clock skew detection."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import struct
|
|
||||||
import time
|
|
||||||
from unittest.mock import MagicMock, patch
|
|
||||||
|
|
||||||
from python_pkg.screen_locker._time_check import (
|
|
||||||
_NTP_EPOCH_OFFSET,
|
|
||||||
_query_ntp_offset,
|
|
||||||
check_clock_skew,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestQueryNtpOffset:
|
|
||||||
"""Tests for _query_ntp_offset."""
|
|
||||||
|
|
||||||
def test_returns_offset_on_success(self) -> None:
|
|
||||||
"""Test returns float offset when NTP server responds."""
|
|
||||||
now = time.time()
|
|
||||||
# Build a fake NTP response with server time close to now
|
|
||||||
server_ntp = int(now + _NTP_EPOCH_OFFSET)
|
|
||||||
fraction = 0
|
|
||||||
response = b"\x00" * 40 + struct.pack("!II", server_ntp, fraction)
|
|
||||||
|
|
||||||
mock_socket = MagicMock()
|
|
||||||
mock_socket.__enter__ = MagicMock(return_value=mock_socket)
|
|
||||||
mock_socket.__exit__ = MagicMock(return_value=False)
|
|
||||||
mock_socket.recvfrom.return_value = (response, ("pool.ntp.org", 123))
|
|
||||||
|
|
||||||
with patch("socket.socket", return_value=mock_socket):
|
|
||||||
offset = _query_ntp_offset()
|
|
||||||
|
|
||||||
assert offset is not None
|
|
||||||
assert abs(offset) < 5 # Should be very close to zero
|
|
||||||
|
|
||||||
def test_returns_none_on_oserror(self) -> None:
|
|
||||||
"""Test returns None when socket fails."""
|
|
||||||
mock_socket = MagicMock()
|
|
||||||
mock_socket.__enter__ = MagicMock(return_value=mock_socket)
|
|
||||||
mock_socket.__exit__ = MagicMock(return_value=False)
|
|
||||||
mock_socket.sendto.side_effect = OSError("network unreachable")
|
|
||||||
|
|
||||||
with patch("socket.socket", return_value=mock_socket):
|
|
||||||
offset = _query_ntp_offset()
|
|
||||||
|
|
||||||
assert offset is None
|
|
||||||
|
|
||||||
def test_returns_none_on_short_response(self) -> None:
|
|
||||||
"""Test returns None when NTP response is too short."""
|
|
||||||
mock_socket = MagicMock()
|
|
||||||
mock_socket.__enter__ = MagicMock(return_value=mock_socket)
|
|
||||||
mock_socket.__exit__ = MagicMock(return_value=False)
|
|
||||||
mock_socket.recvfrom.return_value = (b"\x00" * 10, ("pool.ntp.org", 123))
|
|
||||||
|
|
||||||
with patch("socket.socket", return_value=mock_socket):
|
|
||||||
offset = _query_ntp_offset()
|
|
||||||
|
|
||||||
assert offset is None
|
|
||||||
|
|
||||||
|
|
||||||
class TestCheckClockSkew:
|
|
||||||
"""Tests for check_clock_skew."""
|
|
||||||
|
|
||||||
def test_ok_within_threshold(self) -> None:
|
|
||||||
"""Test returns ok when clock offset is small."""
|
|
||||||
with patch(
|
|
||||||
"python_pkg.screen_locker._time_check._query_ntp_offset",
|
|
||||||
return_value=2.5,
|
|
||||||
):
|
|
||||||
ok, message = check_clock_skew()
|
|
||||||
|
|
||||||
assert ok is True
|
|
||||||
assert "OK" in message
|
|
||||||
|
|
||||||
def test_fails_when_skew_exceeds_threshold(self) -> None:
|
|
||||||
"""Test returns failure when clock offset exceeds max."""
|
|
||||||
with patch(
|
|
||||||
"python_pkg.screen_locker._time_check._query_ntp_offset",
|
|
||||||
return_value=600.0,
|
|
||||||
):
|
|
||||||
ok, message = check_clock_skew()
|
|
||||||
|
|
||||||
assert ok is False
|
|
||||||
assert "600" in message
|
|
||||||
|
|
||||||
def test_ntp_unreachable_passes(self) -> None:
|
|
||||||
"""Test returns ok when NTP server is unreachable (fail-open)."""
|
|
||||||
with patch(
|
|
||||||
"python_pkg.screen_locker._time_check._query_ntp_offset",
|
|
||||||
return_value=None,
|
|
||||||
):
|
|
||||||
ok, message = check_clock_skew()
|
|
||||||
|
|
||||||
assert ok is True
|
|
||||||
assert "skipped" in message.lower()
|
|
||||||
|
|
||||||
def test_negative_offset_detected(self) -> None:
|
|
||||||
"""Test detects clock ahead with negative offset."""
|
|
||||||
with patch(
|
|
||||||
"python_pkg.screen_locker._time_check._query_ntp_offset",
|
|
||||||
return_value=-400.0,
|
|
||||||
):
|
|
||||||
ok, message = check_clock_skew()
|
|
||||||
|
|
||||||
assert ok is False
|
|
||||||
assert "ahead" in message.lower()
|
|
||||||
@ -1,194 +0,0 @@
|
|||||||
"""Tests for UI transitions, timer logic, and sick day screens."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
from unittest.mock import MagicMock
|
|
||||||
|
|
||||||
from python_pkg.screen_locker.tests.conftest import create_locker
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
|
|
||||||
class TestUITransitions:
|
|
||||||
"""Tests for UI state transitions."""
|
|
||||||
|
|
||||||
def test_clear_container(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test clear_container destroys all child widgets."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
|
|
||||||
# Set up mock children
|
|
||||||
mock_child1 = MagicMock()
|
|
||||||
mock_child2 = MagicMock()
|
|
||||||
locker.container.winfo_children.return_value = [
|
|
||||||
mock_child1,
|
|
||||||
mock_child2,
|
|
||||||
]
|
|
||||||
|
|
||||||
locker.clear_container()
|
|
||||||
|
|
||||||
mock_child1.destroy.assert_called_once()
|
|
||||||
mock_child2.destroy.assert_called_once()
|
|
||||||
|
|
||||||
def test_unlock_screen_saves_and_schedules_close(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test unlock_screen saves log and schedules close."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
locker.log_file = tmp_path / "workout_log.json"
|
|
||||||
locker.workout_data = {"type": "phone_verified"}
|
|
||||||
|
|
||||||
locker.unlock_screen()
|
|
||||||
|
|
||||||
# Check that after() was called to schedule close
|
|
||||||
locker.root.after.assert_called()
|
|
||||||
|
|
||||||
def test_lockout_starts_countdown(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test lockout initializes countdown timer."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
|
|
||||||
locker.lockout()
|
|
||||||
|
|
||||||
# lockout() sets remaining_time to lockout_time (10 in demo mode)
|
|
||||||
# then calls update_lockout_countdown() which decrements it by 1
|
|
||||||
assert locker.remaining_time == 9 # 10 - 1 after first update
|
|
||||||
|
|
||||||
def test_close_destroys_root_and_exits(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test close destroys root window and exits."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
|
|
||||||
locker.close()
|
|
||||||
|
|
||||||
locker.root.destroy.assert_called_once()
|
|
||||||
mock_sys_exit.assert_called_with(0)
|
|
||||||
|
|
||||||
|
|
||||||
class TestTimerLogic:
|
|
||||||
"""Tests for timer countdown logic."""
|
|
||||||
|
|
||||||
def test_update_lockout_countdown_decrements(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test countdown decrements remaining time."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
locker.remaining_time = 5
|
|
||||||
locker.countdown_label = MagicMock()
|
|
||||||
|
|
||||||
locker.update_lockout_countdown()
|
|
||||||
|
|
||||||
assert locker.remaining_time == 4
|
|
||||||
locker.root.after.assert_called_with(1000, locker.update_lockout_countdown)
|
|
||||||
|
|
||||||
def test_update_lockout_countdown_at_zero(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test countdown at zero restarts phone check."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
locker.remaining_time = 0
|
|
||||||
locker.countdown_label = MagicMock()
|
|
||||||
object.__setattr__(locker, "_start_phone_check", MagicMock())
|
|
||||||
|
|
||||||
locker.update_lockout_countdown()
|
|
||||||
|
|
||||||
locker._start_phone_check.assert_called_once()
|
|
||||||
|
|
||||||
|
|
||||||
class TestAskIfSick:
|
|
||||||
"""Tests for ask_if_sick method."""
|
|
||||||
|
|
||||||
def test_ask_if_sick_invokes_justification_dialog(
|
|
||||||
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
|
|
||||||
) -> None:
|
|
||||||
"""ask_if_sick now delegates to the structured justification dialog."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
object.__setattr__(locker, "_show_sick_justification", MagicMock())
|
|
||||||
locker.ask_if_sick()
|
|
||||||
locker._show_sick_justification.assert_called_once_with()
|
|
||||||
|
|
||||||
|
|
||||||
class TestGetSickDayStatus:
|
|
||||||
"""Tests for _get_sick_day_status method."""
|
|
||||||
|
|
||||||
def test_already_adjusted_today(
|
|
||||||
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
|
|
||||||
) -> None:
|
|
||||||
"""Test status when sick mode already used today."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
object.__setattr__(
|
|
||||||
locker, "_sick_mode_used_today", MagicMock(return_value=True)
|
|
||||||
)
|
|
||||||
text, color = locker._get_sick_day_status()
|
|
||||||
assert "already adjusted" in text
|
|
||||||
assert color == "#ffaa00"
|
|
||||||
|
|
||||||
def test_adjustment_success(
|
|
||||||
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
|
|
||||||
) -> None:
|
|
||||||
"""Test status when shutdown time adjusted successfully."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
object.__setattr__(
|
|
||||||
locker, "_sick_mode_used_today", MagicMock(return_value=False)
|
|
||||||
)
|
|
||||||
object.__setattr__(
|
|
||||||
locker, "_adjust_shutdown_time_earlier", MagicMock(return_value=True)
|
|
||||||
)
|
|
||||||
text, color = locker._get_sick_day_status()
|
|
||||||
assert "earlier" in text
|
|
||||||
assert color == "#00aa00"
|
|
||||||
|
|
||||||
def test_adjustment_failure(
|
|
||||||
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
|
|
||||||
) -> None:
|
|
||||||
"""Test status when adjustment fails."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
object.__setattr__(
|
|
||||||
locker, "_sick_mode_used_today", MagicMock(return_value=False)
|
|
||||||
)
|
|
||||||
object.__setattr__(
|
|
||||||
locker, "_adjust_shutdown_time_earlier", MagicMock(return_value=False)
|
|
||||||
)
|
|
||||||
text, color = locker._get_sick_day_status()
|
|
||||||
assert "Could not adjust" in text
|
|
||||||
assert color == "#ff4444"
|
|
||||||
|
|
||||||
|
|
||||||
class TestShowRetryAndSick:
|
|
||||||
"""Tests for _show_retry_and_sick method."""
|
|
||||||
|
|
||||||
def test_displays_buttons(
|
|
||||||
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
|
|
||||||
) -> None:
|
|
||||||
"""Test _show_retry_and_sick shows retry and sick buttons."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
object.__setattr__(locker, "clear_container", MagicMock())
|
|
||||||
|
|
||||||
locker._show_retry_and_sick("Test message")
|
|
||||||
|
|
||||||
locker.clear_container.assert_called_once()
|
|
||||||
mock_tk.Label.assert_called()
|
|
||||||
mock_tk.Button.assert_called()
|
|
||||||
@ -1,50 +0,0 @@
|
|||||||
"""Tests for sick-day countdown flow."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
from unittest.mock import MagicMock
|
|
||||||
|
|
||||||
from python_pkg.screen_locker._sick_tracker import SickHistory
|
|
||||||
from python_pkg.screen_locker.screen_lock import (
|
|
||||||
SICK_LOCKOUT_SECONDS,
|
|
||||||
)
|
|
||||||
from python_pkg.screen_locker.tests.conftest import create_locker
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
|
|
||||||
class TestProceedToSickCountdown:
|
|
||||||
"""Tests for _proceed_to_sick_countdown."""
|
|
||||||
|
|
||||||
def test_sets_up_countdown(
|
|
||||||
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
|
|
||||||
) -> None:
|
|
||||||
"""Countdown initialises with computed escalated value."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
object.__setattr__(locker, "clear_container", MagicMock())
|
|
||||||
object.__setattr__(
|
|
||||||
locker, "_sick_mode_used_today", MagicMock(return_value=False)
|
|
||||||
)
|
|
||||||
object.__setattr__(
|
|
||||||
locker, "_adjust_shutdown_time_earlier", MagicMock(return_value=True)
|
|
||||||
)
|
|
||||||
locker._sick_history_cache = SickHistory()
|
|
||||||
locker._proceed_to_sick_countdown()
|
|
||||||
locker.clear_container.assert_called_once()
|
|
||||||
# First tick has decremented once -> base - 1
|
|
||||||
assert locker.sick_remaining_time == SICK_LOCKOUT_SECONDS - 1
|
|
||||||
|
|
||||||
|
|
||||||
class TestShowSickDayUi:
|
|
||||||
"""Tests for _show_sick_day_ui method."""
|
|
||||||
|
|
||||||
def test_displays_ui(
|
|
||||||
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
|
|
||||||
) -> None:
|
|
||||||
"""_show_sick_day_ui displays labels with explicit countdown."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
locker._show_sick_day_ui("Test status", "#00aa00", 120)
|
|
||||||
mock_tk.Label.assert_called()
|
|
||||||
assert hasattr(locker, "sick_countdown_label")
|
|
||||||
@ -1,35 +0,0 @@
|
|||||||
"""Tests for UI flows coverage gaps (part 2)."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
from unittest.mock import MagicMock
|
|
||||||
|
|
||||||
from python_pkg.screen_locker.tests.conftest import create_locker
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
|
|
||||||
class TestUpdateSickCountdownAtZero:
|
|
||||||
"""Tests for _update_sick_countdown at zero remaining."""
|
|
||||||
|
|
||||||
def test_records_sick_day_and_unlocks_at_zero(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test countdown at zero records sick day and calls unlock."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
locker.sick_remaining_time = 0
|
|
||||||
locker.sick_countdown_label = MagicMock()
|
|
||||||
locker.workout_data = {}
|
|
||||||
locker.log_file = tmp_path / "workout_log.json"
|
|
||||||
object.__setattr__(locker, "unlock_screen", MagicMock())
|
|
||||||
|
|
||||||
locker._update_sick_countdown()
|
|
||||||
|
|
||||||
assert locker.workout_data["type"] == "sick_day"
|
|
||||||
assert locker.workout_data["note"] == "Sick day - shutdown moved earlier"
|
|
||||||
locker.unlock_screen.assert_called_once()
|
|
||||||
@ -1,370 +0,0 @@
|
|||||||
"""Tests for post-sick-day workout verification (--verify-workout)."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
import json
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
from unittest.mock import MagicMock
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from python_pkg.screen_locker.tests.conftest import create_locker
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
|
|
||||||
class TestIsSickDayLog:
|
|
||||||
"""Tests for _is_sick_day_log method."""
|
|
||||||
|
|
||||||
def test_no_log_file(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Return False when log file does not exist."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
locker.log_file = tmp_path / "workout_log.json"
|
|
||||||
assert locker._is_sick_day_log() is False
|
|
||||||
|
|
||||||
def test_invalid_json(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Return False when log file contains invalid JSON."""
|
|
||||||
log_file = tmp_path / "workout_log.json"
|
|
||||||
log_file.write_text("{bad json}")
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
locker.log_file = log_file
|
|
||||||
assert locker._is_sick_day_log() is False
|
|
||||||
|
|
||||||
def test_no_entry_today(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Return False when no entry exists for today."""
|
|
||||||
log_file = tmp_path / "workout_log.json"
|
|
||||||
log_file.write_text(json.dumps({"2020-01-01": {}}))
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
locker.log_file = log_file
|
|
||||||
assert locker._is_sick_day_log() is False
|
|
||||||
|
|
||||||
def test_today_not_sick_day(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Return False when today's entry is a regular workout."""
|
|
||||||
log_file = tmp_path / "workout_log.json"
|
|
||||||
today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d")
|
|
||||||
log_file.write_text(
|
|
||||||
json.dumps(
|
|
||||||
{
|
|
||||||
today: {"workout_data": {"type": "phone_verified"}},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
locker.log_file = log_file
|
|
||||||
assert locker._is_sick_day_log() is False
|
|
||||||
|
|
||||||
def test_today_is_sick_day(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Return True when today's entry is a sick day."""
|
|
||||||
log_file = tmp_path / "workout_log.json"
|
|
||||||
today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d")
|
|
||||||
log_file.write_text(
|
|
||||||
json.dumps(
|
|
||||||
{
|
|
||||||
today: {"workout_data": {"type": "sick_day"}},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
locker.log_file = log_file
|
|
||||||
assert locker._is_sick_day_log() is True
|
|
||||||
|
|
||||||
def test_entry_missing_workout_data(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Return False when entry has no workout_data key."""
|
|
||||||
log_file = tmp_path / "workout_log.json"
|
|
||||||
today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d")
|
|
||||||
log_file.write_text(json.dumps({today: {}}))
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
locker.log_file = log_file
|
|
||||||
assert locker._is_sick_day_log() is False
|
|
||||||
|
|
||||||
|
|
||||||
class TestVerifyOnlyInit:
|
|
||||||
"""Tests for ScreenLocker initialization with verify_only=True."""
|
|
||||||
|
|
||||||
def test_verify_only_exits_when_no_sick_day(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Exit when verify_only but no sick day logged today."""
|
|
||||||
mock_sys_exit.side_effect = SystemExit(0)
|
|
||||||
with pytest.raises(SystemExit):
|
|
||||||
create_locker(
|
|
||||||
mock_tk,
|
|
||||||
tmp_path,
|
|
||||||
verify_only=True,
|
|
||||||
is_sick_day_log=False,
|
|
||||||
)
|
|
||||||
mock_sys_exit.assert_called_once_with(0)
|
|
||||||
|
|
||||||
def test_verify_only_starts_when_sick_day(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Start verification window when sick day is logged."""
|
|
||||||
locker = create_locker(
|
|
||||||
mock_tk,
|
|
||||||
tmp_path,
|
|
||||||
verify_only=True,
|
|
||||||
is_sick_day_log=True,
|
|
||||||
)
|
|
||||||
assert locker.verify_only is True
|
|
||||||
mock_sys_exit.assert_not_called()
|
|
||||||
|
|
||||||
def test_verify_only_sets_title(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Verify window title includes [VERIFY]."""
|
|
||||||
locker = create_locker(
|
|
||||||
mock_tk,
|
|
||||||
tmp_path,
|
|
||||||
verify_only=True,
|
|
||||||
is_sick_day_log=True,
|
|
||||||
)
|
|
||||||
locker.root.title.assert_called_with("Workout Locker [VERIFY]")
|
|
||||||
|
|
||||||
|
|
||||||
class TestSetupVerifyWindow:
|
|
||||||
"""Tests for _setup_verify_window."""
|
|
||||||
|
|
||||||
def test_sets_geometry_and_protocol(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Verify window uses 600x400 geometry and WM_DELETE_WINDOW."""
|
|
||||||
locker = create_locker(
|
|
||||||
mock_tk,
|
|
||||||
tmp_path,
|
|
||||||
verify_only=True,
|
|
||||||
is_sick_day_log=True,
|
|
||||||
)
|
|
||||||
locker.root.geometry.assert_called_with("600x400")
|
|
||||||
locker.root.configure.assert_called_with(
|
|
||||||
bg="#1a1a1a",
|
|
||||||
cursor="arrow",
|
|
||||||
)
|
|
||||||
locker.root.protocol.assert_called_with(
|
|
||||||
"WM_DELETE_WINDOW",
|
|
||||||
locker.close,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestStartVerifyWorkoutCheck:
|
|
||||||
"""Tests for _start_verify_workout_check."""
|
|
||||||
|
|
||||||
def test_starts_phone_check_and_polls(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Start phone verification and begin polling."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
object.__setattr__(
|
|
||||||
locker,
|
|
||||||
"_verify_phone_workout",
|
|
||||||
MagicMock(return_value=("verified", "ok")),
|
|
||||||
)
|
|
||||||
object.__setattr__(
|
|
||||||
locker,
|
|
||||||
"_poll_verify_workout_check",
|
|
||||||
MagicMock(),
|
|
||||||
)
|
|
||||||
|
|
||||||
locker._start_verify_workout_check()
|
|
||||||
|
|
||||||
assert locker._phone_future is not None
|
|
||||||
locker._poll_verify_workout_check.assert_called_once()
|
|
||||||
|
|
||||||
|
|
||||||
class TestPollVerifyWorkoutCheck:
|
|
||||||
"""Tests for _poll_verify_workout_check."""
|
|
||||||
|
|
||||||
def test_schedules_retry_when_not_done(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Re-schedule polling when future is not done."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
mock_future = MagicMock()
|
|
||||||
mock_future.done.return_value = False
|
|
||||||
locker._phone_future = mock_future
|
|
||||||
|
|
||||||
locker._poll_verify_workout_check()
|
|
||||||
|
|
||||||
locker.root.after.assert_called_with(
|
|
||||||
500,
|
|
||||||
locker._poll_verify_workout_check,
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_handles_result_when_done(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Route to result handler when future is done."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
mock_future = MagicMock()
|
|
||||||
mock_future.done.return_value = True
|
|
||||||
mock_future.result.return_value = ("verified", "Found workout")
|
|
||||||
locker._phone_future = mock_future
|
|
||||||
object.__setattr__(
|
|
||||||
locker,
|
|
||||||
"_handle_verify_workout_result",
|
|
||||||
MagicMock(),
|
|
||||||
)
|
|
||||||
|
|
||||||
locker._poll_verify_workout_check()
|
|
||||||
|
|
||||||
locker._handle_verify_workout_result.assert_called_once_with(
|
|
||||||
"verified",
|
|
||||||
"Found workout",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestHandleVerifyWorkoutResult:
|
|
||||||
"""Tests for _handle_verify_workout_result."""
|
|
||||||
|
|
||||||
def test_verified_adjusts_shutdown_and_saves(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""On verified: adjust shutdown, save log, show success."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
locker.log_file = tmp_path / "workout_log.json"
|
|
||||||
object.__setattr__(
|
|
||||||
locker,
|
|
||||||
"_adjust_shutdown_time_later",
|
|
||||||
MagicMock(return_value=True),
|
|
||||||
)
|
|
||||||
|
|
||||||
locker._handle_verify_workout_result("verified", "1 session found")
|
|
||||||
|
|
||||||
assert locker.workout_data["type"] == "phone_verified"
|
|
||||||
assert locker.workout_data["after_sick_day"] == "true"
|
|
||||||
locker._adjust_shutdown_time_later.assert_called_once()
|
|
||||||
locker.root.after.assert_called()
|
|
||||||
|
|
||||||
def test_verified_without_adjustment(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""On verified but adjustment fails: still saves and shows success."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
locker.log_file = tmp_path / "workout_log.json"
|
|
||||||
object.__setattr__(
|
|
||||||
locker,
|
|
||||||
"_adjust_shutdown_time_later",
|
|
||||||
MagicMock(return_value=False),
|
|
||||||
)
|
|
||||||
|
|
||||||
locker._handle_verify_workout_result("verified", "1 session found")
|
|
||||||
|
|
||||||
assert locker.workout_data["type"] == "phone_verified"
|
|
||||||
locker.root.after.assert_called()
|
|
||||||
|
|
||||||
def test_not_verified_shows_retry(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""On not_verified: show retry screen."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
object.__setattr__(
|
|
||||||
locker,
|
|
||||||
"_show_verify_retry",
|
|
||||||
MagicMock(),
|
|
||||||
)
|
|
||||||
|
|
||||||
locker._handle_verify_workout_result(
|
|
||||||
"not_verified",
|
|
||||||
"No workout today",
|
|
||||||
)
|
|
||||||
|
|
||||||
locker._show_verify_retry.assert_called_once_with(
|
|
||||||
"No workout today",
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_error_shows_retry(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""On error: show retry screen."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
object.__setattr__(
|
|
||||||
locker,
|
|
||||||
"_show_verify_retry",
|
|
||||||
MagicMock(),
|
|
||||||
)
|
|
||||||
|
|
||||||
locker._handle_verify_workout_result("error", "ADB failed")
|
|
||||||
|
|
||||||
locker._show_verify_retry.assert_called_once_with("ADB failed")
|
|
||||||
|
|
||||||
|
|
||||||
class TestShowVerifyRetry:
|
|
||||||
"""Tests for _show_verify_retry."""
|
|
||||||
|
|
||||||
def test_shows_retry_and_close_buttons(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Show TRY AGAIN and Close buttons."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
|
|
||||||
locker._show_verify_retry("No workout found")
|
|
||||||
|
|
||||||
# Verify container was cleared and buttons were packed
|
|
||||||
locker.container.winfo_children.return_value = []
|
|
||||||
@ -1,136 +0,0 @@
|
|||||||
"""Tests for VT switching disable/restore during screen lock."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
from unittest.mock import MagicMock, call, patch
|
|
||||||
|
|
||||||
from python_pkg.screen_locker.tests.conftest import create_locker
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
_SETXKBMAP = "/usr/bin/setxkbmap"
|
|
||||||
|
|
||||||
|
|
||||||
class TestVTSwitching:
|
|
||||||
"""Tests for VT switching disable/restore behaviour."""
|
|
||||||
|
|
||||||
def test_vt_switching_disabled_in_production_mode(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_subprocess_run: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""setxkbmap srvrkeys:none is called when locker starts in production."""
|
|
||||||
create_locker(mock_tk, tmp_path, demo_mode=False)
|
|
||||||
|
|
||||||
mock_subprocess_run.assert_called_once_with(
|
|
||||||
[_SETXKBMAP, "-option", "srvrkeys:none"],
|
|
||||||
check=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_vt_switching_not_disabled_in_demo_mode(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_subprocess_run: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""setxkbmap is NOT called in demo mode."""
|
|
||||||
create_locker(mock_tk, tmp_path, demo_mode=True)
|
|
||||||
|
|
||||||
mock_subprocess_run.assert_not_called()
|
|
||||||
|
|
||||||
def test_vt_switching_restored_on_close_in_production(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_subprocess_run: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""setxkbmap -option '' is called when close() runs in production."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path, demo_mode=False)
|
|
||||||
mock_subprocess_run.reset_mock()
|
|
||||||
|
|
||||||
locker.close()
|
|
||||||
|
|
||||||
mock_subprocess_run.assert_called_once_with(
|
|
||||||
[_SETXKBMAP, "-option", ""],
|
|
||||||
check=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_vt_switching_not_restored_in_demo_mode(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_subprocess_run: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""close() does NOT call setxkbmap in demo mode."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path, demo_mode=True)
|
|
||||||
mock_subprocess_run.reset_mock()
|
|
||||||
|
|
||||||
locker.close()
|
|
||||||
|
|
||||||
mock_subprocess_run.assert_not_called()
|
|
||||||
|
|
||||||
def test_disable_then_restore_are_complementary(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_subprocess_run: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Full lifecycle: disable on init, restore on close in production."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path, demo_mode=False)
|
|
||||||
|
|
||||||
assert mock_subprocess_run.call_count == 1
|
|
||||||
assert mock_subprocess_run.call_args_list[0] == call(
|
|
||||||
[_SETXKBMAP, "-option", "srvrkeys:none"],
|
|
||||||
check=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
locker.close()
|
|
||||||
|
|
||||||
assert mock_subprocess_run.call_count == 2
|
|
||||||
assert mock_subprocess_run.call_args_list[1] == call(
|
|
||||||
[_SETXKBMAP, "-option", ""],
|
|
||||||
check=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_disable_graceful_when_setxkbmap_missing(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_subprocess_run: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""No crash and no subprocess call when setxkbmap is not installed."""
|
|
||||||
with patch(
|
|
||||||
"python_pkg.screen_locker._window_setup.shutil.which",
|
|
||||||
return_value=None,
|
|
||||||
):
|
|
||||||
create_locker(mock_tk, tmp_path, demo_mode=False)
|
|
||||||
|
|
||||||
mock_subprocess_run.assert_not_called()
|
|
||||||
|
|
||||||
def test_restore_graceful_when_setxkbmap_missing(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_subprocess_run: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""No crash and no subprocess call on close when setxkbmap is not installed."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path, demo_mode=False)
|
|
||||||
mock_subprocess_run.reset_mock()
|
|
||||||
|
|
||||||
with patch(
|
|
||||||
"python_pkg.screen_locker._window_setup.shutil.which",
|
|
||||||
return_value=None,
|
|
||||||
):
|
|
||||||
locker.close()
|
|
||||||
|
|
||||||
mock_subprocess_run.assert_not_called()
|
|
||||||
@ -1,188 +0,0 @@
|
|||||||
"""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
|
|
||||||
@ -1,83 +0,0 @@
|
|||||||
"""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()
|
|
||||||
@ -1,243 +0,0 @@
|
|||||||
"""Tests for _weekly_check: is_relaxed_day, count_weekly_workouts,
|
|
||||||
has_weekly_minimum."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
import json
|
|
||||||
from typing import TYPE_CHECKING, Any
|
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
from python_pkg.screen_locker._weekly_check import (
|
|
||||||
_RELAXED_WEEKDAYS,
|
|
||||||
WEEKLY_WORKOUT_MINIMUM,
|
|
||||||
count_weekly_workouts,
|
|
||||||
has_weekly_minimum,
|
|
||||||
is_relaxed_day,
|
|
||||||
)
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Helpers
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def _dt(weekday: int, hour: int = 10) -> datetime:
|
|
||||||
"""Return a UTC-aware datetime for the given ISO weekday (0=Mon, 6=Sun)."""
|
|
||||||
# 2025-05-19 is a Monday (weekday 0)
|
|
||||||
base = datetime(2025, 5, 19, hour, 0, 0, tzinfo=timezone.utc)
|
|
||||||
from datetime import timedelta
|
|
||||||
|
|
||||||
return base + timedelta(days=weekday)
|
|
||||||
|
|
||||||
|
|
||||||
def _make_log(entries: dict[str, str], log_file: Path) -> Path:
|
|
||||||
"""Write a workout_log.json with given date→workout_type mapping."""
|
|
||||||
data: dict[str, Any] = {
|
|
||||||
date: {
|
|
||||||
"timestamp": f"{date}T10:00:00+00:00",
|
|
||||||
"workout_data": {"type": wtype},
|
|
||||||
}
|
|
||||||
for date, wtype in entries.items()
|
|
||||||
}
|
|
||||||
log_file.write_text(json.dumps(data))
|
|
||||||
return log_file
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# is_relaxed_day
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
class TestIsRelaxedDay:
|
|
||||||
def test_monday_is_enforced(self) -> None:
|
|
||||||
assert is_relaxed_day(today=_dt(0)) is False
|
|
||||||
|
|
||||||
def test_tuesday_is_relaxed(self) -> None:
|
|
||||||
assert is_relaxed_day(today=_dt(1)) is True
|
|
||||||
|
|
||||||
def test_wednesday_is_relaxed(self) -> None:
|
|
||||||
assert is_relaxed_day(today=_dt(2)) is True
|
|
||||||
|
|
||||||
def test_thursday_is_relaxed(self) -> None:
|
|
||||||
assert is_relaxed_day(today=_dt(3)) is True
|
|
||||||
|
|
||||||
def test_friday_is_enforced(self) -> None:
|
|
||||||
assert is_relaxed_day(today=_dt(4)) is False
|
|
||||||
|
|
||||||
def test_saturday_is_enforced(self) -> None:
|
|
||||||
assert is_relaxed_day(today=_dt(5)) is False
|
|
||||||
|
|
||||||
def test_sunday_is_enforced(self) -> None:
|
|
||||||
assert is_relaxed_day(today=_dt(6)) is False
|
|
||||||
|
|
||||||
def test_relaxed_weekdays_constant_correct(self) -> None:
|
|
||||||
assert frozenset({1, 2, 3}) == _RELAXED_WEEKDAYS
|
|
||||||
|
|
||||||
def test_uses_local_time_by_default(self) -> None:
|
|
||||||
result = is_relaxed_day()
|
|
||||||
assert isinstance(result, bool)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# count_weekly_workouts
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
class TestCountWeeklyWorkouts:
|
|
||||||
def test_no_log_file_returns_zero(self, tmp_path: Path) -> None:
|
|
||||||
log = tmp_path / "workout_log.json"
|
|
||||||
assert count_weekly_workouts(log, today=_dt(4)) == 0
|
|
||||||
|
|
||||||
def test_corrupt_json_returns_zero(self, tmp_path: Path) -> None:
|
|
||||||
log = tmp_path / "workout_log.json"
|
|
||||||
log.write_text("{not valid json}")
|
|
||||||
assert count_weekly_workouts(log, today=_dt(4)) == 0
|
|
||||||
|
|
||||||
def test_oserror_returns_zero(self, tmp_path: Path) -> None:
|
|
||||||
log = tmp_path / "workout_log.json"
|
|
||||||
log.write_text("{}")
|
|
||||||
with patch("builtins.open", side_effect=OSError("no permission")):
|
|
||||||
assert count_weekly_workouts(log, today=_dt(4)) == 0
|
|
||||||
|
|
||||||
def test_counts_phone_verified_in_current_week(self, tmp_path: Path) -> None:
|
|
||||||
log = tmp_path / "workout_log.json"
|
|
||||||
# Mon=2025-05-19, Tue=2025-05-20 both in same week; check on Fri=2025-05-23
|
|
||||||
_make_log({"2025-05-19": "phone_verified", "2025-05-20": "phone_verified"}, log)
|
|
||||||
assert count_weekly_workouts(log, today=_dt(4)) == 2
|
|
||||||
|
|
||||||
def test_sick_day_not_counted(self, tmp_path: Path) -> None:
|
|
||||||
log = tmp_path / "workout_log.json"
|
|
||||||
_make_log({"2025-05-19": "sick_day"}, log)
|
|
||||||
assert count_weekly_workouts(log, today=_dt(4)) == 0
|
|
||||||
|
|
||||||
def test_early_bird_not_counted(self, tmp_path: Path) -> None:
|
|
||||||
log = tmp_path / "workout_log.json"
|
|
||||||
_make_log({"2025-05-19": "early_bird"}, log)
|
|
||||||
assert count_weekly_workouts(log, today=_dt(4)) == 0
|
|
||||||
|
|
||||||
def test_previous_week_not_counted(self, tmp_path: Path) -> None:
|
|
||||||
log = tmp_path / "workout_log.json"
|
|
||||||
# 2025-05-12 is the Monday of the previous week
|
|
||||||
_make_log({"2025-05-12": "phone_verified"}, log)
|
|
||||||
assert count_weekly_workouts(log, today=_dt(4)) == 0
|
|
||||||
|
|
||||||
def test_future_date_not_counted(self, tmp_path: Path) -> None:
|
|
||||||
log = tmp_path / "workout_log.json"
|
|
||||||
# 2025-05-24 is Saturday, checking on Friday 2025-05-23
|
|
||||||
_make_log({"2025-05-24": "phone_verified"}, log)
|
|
||||||
assert count_weekly_workouts(log, today=_dt(4)) == 0
|
|
||||||
|
|
||||||
def test_invalid_date_key_skipped(self, tmp_path: Path) -> None:
|
|
||||||
log = tmp_path / "workout_log.json"
|
|
||||||
data: dict[str, Any] = {
|
|
||||||
"not-a-date": {
|
|
||||||
"timestamp": "x",
|
|
||||||
"workout_data": {"type": "phone_verified"},
|
|
||||||
},
|
|
||||||
"2025-05-19": {
|
|
||||||
"timestamp": "x",
|
|
||||||
"workout_data": {"type": "phone_verified"},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
log.write_text(json.dumps(data))
|
|
||||||
assert count_weekly_workouts(log, today=_dt(4)) == 1
|
|
||||||
|
|
||||||
def test_non_dict_entry_skipped(self, tmp_path: Path) -> None:
|
|
||||||
log = tmp_path / "workout_log.json"
|
|
||||||
data: dict[str, Any] = {"2025-05-19": "not-a-dict"}
|
|
||||||
log.write_text(json.dumps(data))
|
|
||||||
assert count_weekly_workouts(log, today=_dt(4)) == 0
|
|
||||||
|
|
||||||
def test_counts_up_to_four(self, tmp_path: Path) -> None:
|
|
||||||
log = tmp_path / "workout_log.json"
|
|
||||||
_make_log(
|
|
||||||
{
|
|
||||||
"2025-05-19": "phone_verified",
|
|
||||||
"2025-05-20": "phone_verified",
|
|
||||||
"2025-05-21": "phone_verified",
|
|
||||||
"2025-05-22": "phone_verified",
|
|
||||||
},
|
|
||||||
log,
|
|
||||||
)
|
|
||||||
assert count_weekly_workouts(log, today=_dt(4)) == 4
|
|
||||||
|
|
||||||
def test_today_counts_if_this_week(self, tmp_path: Path) -> None:
|
|
||||||
log = tmp_path / "workout_log.json"
|
|
||||||
# today is Friday 2025-05-23
|
|
||||||
_make_log({"2025-05-23": "phone_verified"}, log)
|
|
||||||
assert count_weekly_workouts(log, today=_dt(4)) == 1
|
|
||||||
|
|
||||||
def test_monday_start_of_week_counted(self, tmp_path: Path) -> None:
|
|
||||||
log = tmp_path / "workout_log.json"
|
|
||||||
_make_log({"2025-05-19": "phone_verified"}, log)
|
|
||||||
# Checking on Monday itself (today=Mon)
|
|
||||||
assert count_weekly_workouts(log, today=_dt(0)) == 1
|
|
||||||
|
|
||||||
def test_mixed_types_only_verified_counted(self, tmp_path: Path) -> None:
|
|
||||||
log = tmp_path / "workout_log.json"
|
|
||||||
_make_log(
|
|
||||||
{
|
|
||||||
"2025-05-19": "phone_verified",
|
|
||||||
"2025-05-20": "sick_day",
|
|
||||||
"2025-05-21": "early_bird",
|
|
||||||
"2025-05-22": "phone_verified",
|
|
||||||
},
|
|
||||||
log,
|
|
||||||
)
|
|
||||||
assert count_weekly_workouts(log, today=_dt(4)) == 2
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# has_weekly_minimum
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
class TestHasWeeklyMinimum:
|
|
||||||
def test_zero_workouts_is_false(self, tmp_path: Path) -> None:
|
|
||||||
log = tmp_path / "workout_log.json"
|
|
||||||
assert has_weekly_minimum(log, today=_dt(4)) is False
|
|
||||||
|
|
||||||
def test_three_workouts_is_false(self, tmp_path: Path) -> None:
|
|
||||||
log = tmp_path / "workout_log.json"
|
|
||||||
_make_log(
|
|
||||||
{
|
|
||||||
"2025-05-19": "phone_verified",
|
|
||||||
"2025-05-20": "phone_verified",
|
|
||||||
"2025-05-21": "phone_verified",
|
|
||||||
},
|
|
||||||
log,
|
|
||||||
)
|
|
||||||
assert has_weekly_minimum(log, today=_dt(4)) is False
|
|
||||||
|
|
||||||
def test_four_workouts_is_true(self, tmp_path: Path) -> None:
|
|
||||||
log = tmp_path / "workout_log.json"
|
|
||||||
_make_log(
|
|
||||||
{
|
|
||||||
"2025-05-19": "phone_verified",
|
|
||||||
"2025-05-20": "phone_verified",
|
|
||||||
"2025-05-21": "phone_verified",
|
|
||||||
"2025-05-22": "phone_verified",
|
|
||||||
},
|
|
||||||
log,
|
|
||||||
)
|
|
||||||
assert has_weekly_minimum(log, today=_dt(4)) is True
|
|
||||||
|
|
||||||
def test_five_workouts_is_true(self, tmp_path: Path) -> None:
|
|
||||||
log = tmp_path / "workout_log.json"
|
|
||||||
_make_log(
|
|
||||||
{
|
|
||||||
"2025-05-19": "phone_verified",
|
|
||||||
"2025-05-20": "phone_verified",
|
|
||||||
"2025-05-21": "phone_verified",
|
|
||||||
"2025-05-22": "phone_verified",
|
|
||||||
"2025-05-23": "phone_verified",
|
|
||||||
},
|
|
||||||
log,
|
|
||||||
)
|
|
||||||
assert has_weekly_minimum(log, today=_dt(4)) is True
|
|
||||||
|
|
||||||
def test_weekly_workout_minimum_constant(self) -> None:
|
|
||||||
assert WEEKLY_WORKOUT_MINIMUM == 4
|
|
||||||
@ -1,598 +0,0 @@
|
|||||||
"""Tests for weekly workout enforcement and relaxed-day (Tue-Thu) logic."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
from unittest.mock import MagicMock, patch
|
|
||||||
|
|
||||||
from python_pkg.screen_locker.screen_lock import ScreenLocker
|
|
||||||
from python_pkg.screen_locker.tests.conftest import (
|
|
||||||
create_locker,
|
|
||||||
create_locker_relaxed_day,
|
|
||||||
)
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# _check_non_verify_exits: relaxed-day branch
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
class TestRelaxedDayBranch:
|
|
||||||
def test_relaxed_day_sets_flag_instead_of_exiting(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
locker = create_locker_relaxed_day(mock_tk, tmp_path)
|
|
||||||
assert locker._relaxed_day_mode is True
|
|
||||||
mock_sys_exit.assert_not_called()
|
|
||||||
|
|
||||||
def test_relaxed_day_calls_start_relaxed_flow(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
with (
|
|
||||||
patch.object(Path, "resolve", return_value=tmp_path),
|
|
||||||
patch.object(ScreenLocker, "has_logged_today", return_value=False),
|
|
||||||
patch.object(ScreenLocker, "_is_sick_day_log", return_value=False),
|
|
||||||
patch.object(ScreenLocker, "_is_early_bird_log", return_value=False),
|
|
||||||
patch.object(ScreenLocker, "_is_early_bird_time", return_value=False),
|
|
||||||
patch.object(
|
|
||||||
ScreenLocker, "_try_auto_upgrade_early_bird", return_value=False
|
|
||||||
),
|
|
||||||
patch(
|
|
||||||
"python_pkg.screen_locker.screen_lock.is_relaxed_day",
|
|
||||||
return_value=True,
|
|
||||||
),
|
|
||||||
patch(
|
|
||||||
"python_pkg.screen_locker.screen_lock.has_weekly_minimum",
|
|
||||||
return_value=False,
|
|
||||||
),
|
|
||||||
patch.object(ScreenLocker, "_start_phone_check") as mock_phone,
|
|
||||||
patch.object(ScreenLocker, "_start_relaxed_day_flow") as mock_relaxed,
|
|
||||||
patch.object(ScreenLocker, "_start_verify_workout_check"),
|
|
||||||
):
|
|
||||||
ScreenLocker(demo_mode=True)
|
|
||||||
|
|
||||||
mock_relaxed.assert_called_once()
|
|
||||||
mock_phone.assert_not_called()
|
|
||||||
|
|
||||||
def test_relaxed_day_uses_small_window_not_fullscreen(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
with (
|
|
||||||
patch.object(Path, "resolve", return_value=tmp_path),
|
|
||||||
patch.object(ScreenLocker, "has_logged_today", return_value=False),
|
|
||||||
patch.object(ScreenLocker, "_is_sick_day_log", return_value=False),
|
|
||||||
patch.object(ScreenLocker, "_is_early_bird_log", return_value=False),
|
|
||||||
patch.object(ScreenLocker, "_is_early_bird_time", return_value=False),
|
|
||||||
patch.object(
|
|
||||||
ScreenLocker, "_try_auto_upgrade_early_bird", return_value=False
|
|
||||||
),
|
|
||||||
patch(
|
|
||||||
"python_pkg.screen_locker.screen_lock.is_relaxed_day",
|
|
||||||
return_value=True,
|
|
||||||
),
|
|
||||||
patch(
|
|
||||||
"python_pkg.screen_locker.screen_lock.has_weekly_minimum",
|
|
||||||
return_value=False,
|
|
||||||
),
|
|
||||||
patch.object(ScreenLocker, "_setup_window") as mock_full,
|
|
||||||
patch.object(ScreenLocker, "_setup_relaxed_day_window") as mock_small,
|
|
||||||
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_small.assert_called_once()
|
|
||||||
mock_full.assert_not_called()
|
|
||||||
|
|
||||||
def test_relaxed_day_no_grab_input(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
with (
|
|
||||||
patch.object(Path, "resolve", return_value=tmp_path),
|
|
||||||
patch.object(ScreenLocker, "has_logged_today", return_value=False),
|
|
||||||
patch.object(ScreenLocker, "_is_sick_day_log", return_value=False),
|
|
||||||
patch.object(ScreenLocker, "_is_early_bird_log", return_value=False),
|
|
||||||
patch.object(ScreenLocker, "_is_early_bird_time", return_value=False),
|
|
||||||
patch.object(
|
|
||||||
ScreenLocker, "_try_auto_upgrade_early_bird", return_value=False
|
|
||||||
),
|
|
||||||
patch(
|
|
||||||
"python_pkg.screen_locker.screen_lock.is_relaxed_day",
|
|
||||||
return_value=True,
|
|
||||||
),
|
|
||||||
patch(
|
|
||||||
"python_pkg.screen_locker.screen_lock.has_weekly_minimum",
|
|
||||||
return_value=False,
|
|
||||||
),
|
|
||||||
patch.object(ScreenLocker, "_grab_input") as mock_grab,
|
|
||||||
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()
|
|
||||||
|
|
||||||
def test_has_logged_today_exits_before_relaxed_check(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
create_locker_relaxed_day(mock_tk, tmp_path, has_logged=True)
|
|
||||||
mock_sys_exit.assert_called_once_with(0)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# _check_non_verify_exits: Fri-Mon weekly minimum branch
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
class TestWeeklyMinimumBranch:
|
|
||||||
def test_weekly_minimum_met_exits(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
with patch(
|
|
||||||
"python_pkg.screen_locker.screen_lock.has_weekly_minimum",
|
|
||||||
return_value=True,
|
|
||||||
):
|
|
||||||
create_locker(mock_tk, tmp_path, has_logged=False)
|
|
||||||
|
|
||||||
mock_sys_exit.assert_called_once_with(0)
|
|
||||||
|
|
||||||
def test_weekly_minimum_not_met_shows_full_lock(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
# create_locker already stubs _start_phone_check; just verify no exit
|
|
||||||
# and _relaxed_day_mode stays False (full lock path taken).
|
|
||||||
with patch(
|
|
||||||
"python_pkg.screen_locker.screen_lock.has_weekly_minimum",
|
|
||||||
return_value=False,
|
|
||||||
):
|
|
||||||
locker = create_locker(mock_tk, tmp_path, has_logged=False)
|
|
||||||
|
|
||||||
mock_sys_exit.assert_not_called()
|
|
||||||
assert locker._relaxed_day_mode is False
|
|
||||||
|
|
||||||
def test_weekly_minimum_not_checked_on_relaxed_day(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
with patch(
|
|
||||||
"python_pkg.screen_locker.screen_lock.has_weekly_minimum",
|
|
||||||
) as mock_weekly:
|
|
||||||
create_locker_relaxed_day(mock_tk, tmp_path)
|
|
||||||
|
|
||||||
mock_weekly.assert_not_called()
|
|
||||||
|
|
||||||
def test_has_logged_exits_before_weekly_check(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
with patch(
|
|
||||||
"python_pkg.screen_locker.screen_lock.has_weekly_minimum",
|
|
||||||
) as mock_weekly:
|
|
||||||
create_locker(mock_tk, tmp_path, has_logged=True)
|
|
||||||
|
|
||||||
mock_weekly.assert_not_called()
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Relaxed-day UI flow methods
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
class TestStartRelaxedDayFlow:
|
|
||||||
def _make_locker(self, mock_tk: MagicMock, tmp_path: Path) -> ScreenLocker:
|
|
||||||
return create_locker(mock_tk, tmp_path)
|
|
||||||
|
|
||||||
def test_shows_weekly_count_in_text(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
locker = self._make_locker(mock_tk, tmp_path)
|
|
||||||
with (
|
|
||||||
patch(
|
|
||||||
"python_pkg.screen_locker._ui_flows.count_weekly_workouts",
|
|
||||||
return_value=2,
|
|
||||||
),
|
|
||||||
patch.object(locker, "_text") as mock_text,
|
|
||||||
patch.object(locker, "_label"),
|
|
||||||
patch.object(locker, "_button_row"),
|
|
||||||
patch.object(locker, "_button"),
|
|
||||||
patch.object(locker, "clear_container"),
|
|
||||||
):
|
|
||||||
locker._start_relaxed_day_flow()
|
|
||||||
|
|
||||||
all_text = " ".join(str(c) for c in mock_text.call_args_list)
|
|
||||||
assert "2" in all_text
|
|
||||||
assert "4" in all_text
|
|
||||||
|
|
||||||
def test_skip_button_wires_close(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
locker = self._make_locker(mock_tk, tmp_path)
|
|
||||||
with (
|
|
||||||
patch(
|
|
||||||
"python_pkg.screen_locker._ui_flows.count_weekly_workouts",
|
|
||||||
return_value=0,
|
|
||||||
),
|
|
||||||
patch.object(locker, "_button") as mock_button,
|
|
||||||
patch.object(locker, "_label"),
|
|
||||||
patch.object(locker, "_text"),
|
|
||||||
patch.object(locker, "_button_row", return_value=MagicMock()),
|
|
||||||
patch.object(locker, "clear_container"),
|
|
||||||
):
|
|
||||||
locker._start_relaxed_day_flow()
|
|
||||||
|
|
||||||
skip_cmds = [
|
|
||||||
c.kwargs["command"]
|
|
||||||
for c in mock_button.call_args_list
|
|
||||||
if "Skip" in str(c.args)
|
|
||||||
]
|
|
||||||
assert any(cmd == locker.close for cmd in skip_cmds)
|
|
||||||
|
|
||||||
def test_log_button_wires_relaxed_phone_check(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
locker = self._make_locker(mock_tk, tmp_path)
|
|
||||||
with (
|
|
||||||
patch(
|
|
||||||
"python_pkg.screen_locker._ui_flows.count_weekly_workouts",
|
|
||||||
return_value=1,
|
|
||||||
),
|
|
||||||
patch.object(locker, "_button") as mock_button,
|
|
||||||
patch.object(locker, "_label"),
|
|
||||||
patch.object(locker, "_text"),
|
|
||||||
patch.object(locker, "_button_row", return_value=MagicMock()),
|
|
||||||
patch.object(locker, "clear_container"),
|
|
||||||
):
|
|
||||||
locker._start_relaxed_day_flow()
|
|
||||||
|
|
||||||
log_cmds = [
|
|
||||||
c.kwargs["command"]
|
|
||||||
for c in mock_button.call_args_list
|
|
||||||
if "Log" in str(c.args)
|
|
||||||
]
|
|
||||||
assert any(cmd == locker._start_relaxed_phone_check for cmd in log_cmds)
|
|
||||||
|
|
||||||
|
|
||||||
class TestStartRelaxedPhoneCheck:
|
|
||||||
def _make_locker(self, mock_tk: MagicMock, tmp_path: Path) -> ScreenLocker:
|
|
||||||
return create_locker(mock_tk, tmp_path)
|
|
||||||
|
|
||||||
def test_submits_phone_verify_and_polls(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
locker = self._make_locker(mock_tk, tmp_path)
|
|
||||||
with patch.object(
|
|
||||||
locker, "_verify_phone_workout", return_value=("verified", "ok")
|
|
||||||
):
|
|
||||||
locker._start_relaxed_phone_check()
|
|
||||||
|
|
||||||
assert locker._phone_future is not None
|
|
||||||
locker.root.after.assert_called()
|
|
||||||
|
|
||||||
def test_poll_routes_when_done(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
locker = self._make_locker(mock_tk, tmp_path)
|
|
||||||
mock_future = MagicMock()
|
|
||||||
mock_future.done.return_value = True
|
|
||||||
mock_future.result.return_value = ("verified", "ok")
|
|
||||||
locker._phone_future = mock_future
|
|
||||||
with patch.object(locker, "_handle_relaxed_phone_result") as mock_handle:
|
|
||||||
locker._poll_relaxed_phone_check()
|
|
||||||
mock_handle.assert_called_once_with("verified", "ok")
|
|
||||||
|
|
||||||
def test_poll_waits_when_not_done(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
locker = self._make_locker(mock_tk, tmp_path)
|
|
||||||
mock_future = MagicMock()
|
|
||||||
mock_future.done.return_value = False
|
|
||||||
locker._phone_future = mock_future
|
|
||||||
with patch.object(locker, "_handle_relaxed_phone_result") as mock_handle:
|
|
||||||
locker._poll_relaxed_phone_check()
|
|
||||||
mock_handle.assert_not_called()
|
|
||||||
locker.root.after.assert_called_with(500, locker._poll_relaxed_phone_check)
|
|
||||||
|
|
||||||
def test_poll_with_none_future_waits(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
locker = self._make_locker(mock_tk, tmp_path)
|
|
||||||
locker._phone_future = None
|
|
||||||
with patch.object(locker, "_handle_relaxed_phone_result") as mock_handle:
|
|
||||||
locker._poll_relaxed_phone_check()
|
|
||||||
mock_handle.assert_not_called()
|
|
||||||
|
|
||||||
|
|
||||||
class TestHandleRelaxedPhoneResult:
|
|
||||||
def _make_locker(self, mock_tk: MagicMock, tmp_path: Path) -> ScreenLocker:
|
|
||||||
return create_locker(mock_tk, tmp_path)
|
|
||||||
|
|
||||||
def test_verified_calls_unlock_screen(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
locker = self._make_locker(mock_tk, tmp_path)
|
|
||||||
with patch.object(locker, "unlock_screen"):
|
|
||||||
locker._handle_relaxed_phone_result("verified", "StrongLifts sync OK")
|
|
||||||
|
|
||||||
assert locker.workout_data["type"] == "phone_verified"
|
|
||||||
assert locker.workout_data["source"] == "StrongLifts sync OK"
|
|
||||||
locker.root.after.assert_called()
|
|
||||||
|
|
||||||
def test_not_verified_shows_relaxed_retry(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
locker = self._make_locker(mock_tk, tmp_path)
|
|
||||||
with patch.object(locker, "_show_relaxed_retry") as mock_retry:
|
|
||||||
locker._handle_relaxed_phone_result("not_verified", "no workout today")
|
|
||||||
|
|
||||||
mock_retry.assert_called_once_with("no workout today", "not_verified")
|
|
||||||
|
|
||||||
def test_too_short_shows_relaxed_retry(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
locker = self._make_locker(mock_tk, tmp_path)
|
|
||||||
with patch.object(locker, "_show_relaxed_retry") as mock_retry:
|
|
||||||
locker._handle_relaxed_phone_result("too_short", "only 20 min")
|
|
||||||
|
|
||||||
mock_retry.assert_called_once_with("only 20 min", "too_short")
|
|
||||||
|
|
||||||
def test_no_phone_shows_relaxed_retry(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
locker = self._make_locker(mock_tk, tmp_path)
|
|
||||||
with patch.object(locker, "_show_relaxed_retry") as mock_retry:
|
|
||||||
locker._handle_relaxed_phone_result("no_phone", "ADB not found")
|
|
||||||
|
|
||||||
mock_retry.assert_called_once_with("ADB not found", "no_phone")
|
|
||||||
|
|
||||||
|
|
||||||
class TestShowRelaxedRetry:
|
|
||||||
def _make_locker(self, mock_tk: MagicMock, tmp_path: Path) -> ScreenLocker:
|
|
||||||
return create_locker(mock_tk, tmp_path)
|
|
||||||
|
|
||||||
def test_shows_try_again_and_close_buttons(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
locker = self._make_locker(mock_tk, tmp_path)
|
|
||||||
with (
|
|
||||||
patch.object(locker, "_button") as mock_button,
|
|
||||||
patch.object(locker, "_label"),
|
|
||||||
patch.object(locker, "_text"),
|
|
||||||
patch.object(locker, "_button_row", return_value=MagicMock()),
|
|
||||||
patch.object(locker, "clear_container"),
|
|
||||||
):
|
|
||||||
locker._show_relaxed_retry("msg", "not_verified")
|
|
||||||
|
|
||||||
button_texts = " ".join(str(c.args) for c in mock_button.call_args_list)
|
|
||||||
assert "TRY AGAIN" in button_texts
|
|
||||||
assert "Close" in button_texts
|
|
||||||
|
|
||||||
def test_no_sick_button(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
locker = self._make_locker(mock_tk, tmp_path)
|
|
||||||
with (
|
|
||||||
patch.object(locker, "_button") as mock_button,
|
|
||||||
patch.object(locker, "_label"),
|
|
||||||
patch.object(locker, "_text"),
|
|
||||||
patch.object(locker, "_button_row", return_value=MagicMock()),
|
|
||||||
patch.object(locker, "clear_container"),
|
|
||||||
):
|
|
||||||
locker._show_relaxed_retry("msg", "not_verified")
|
|
||||||
|
|
||||||
button_texts = " ".join(str(c.args) for c in mock_button.call_args_list)
|
|
||||||
assert "sick" not in button_texts.lower()
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# _check_today_state_exits: return True/False branches
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
class TestCheckTodayStateExits:
|
|
||||||
"""Cover all return True/False paths in _check_today_state_exits.
|
|
||||||
|
|
||||||
sys.exit is mocked without side_effect so execution continues past it
|
|
||||||
and the 'return True' statements are reachable.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def _make_locker(self, mock_tk: MagicMock, tmp_path: Path) -> ScreenLocker:
|
|
||||||
return create_locker(mock_tk, tmp_path)
|
|
||||||
|
|
||||||
def test_early_bird_upgrade_success_returns_true(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
locker = self._make_locker(mock_tk, tmp_path)
|
|
||||||
with (
|
|
||||||
patch.object(locker, "_is_early_bird_log", return_value=True),
|
|
||||||
patch.object(locker, "_is_early_bird_time", return_value=False),
|
|
||||||
patch.object(locker, "_try_auto_upgrade_early_bird", return_value=True),
|
|
||||||
):
|
|
||||||
result = locker._check_today_state_exits()
|
|
||||||
assert result is True
|
|
||||||
|
|
||||||
def test_early_bird_upgrade_fail_returns_false(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
locker = self._make_locker(mock_tk, tmp_path)
|
|
||||||
with (
|
|
||||||
patch.object(locker, "_is_early_bird_log", return_value=True),
|
|
||||||
patch.object(locker, "_is_early_bird_time", return_value=False),
|
|
||||||
patch.object(locker, "_try_auto_upgrade_early_bird", return_value=False),
|
|
||||||
):
|
|
||||||
result = locker._check_today_state_exits()
|
|
||||||
assert result is False
|
|
||||||
|
|
||||||
def test_early_bird_window_active_returns_true(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
locker = self._make_locker(mock_tk, tmp_path)
|
|
||||||
with (
|
|
||||||
patch.object(locker, "_is_early_bird_log", return_value=True),
|
|
||||||
patch.object(locker, "_is_early_bird_time", return_value=True),
|
|
||||||
):
|
|
||||||
result = locker._check_today_state_exits()
|
|
||||||
assert result is True
|
|
||||||
|
|
||||||
def test_sick_day_auto_upgrade_returns_true(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
locker = self._make_locker(mock_tk, tmp_path)
|
|
||||||
with (
|
|
||||||
patch.object(locker, "_is_early_bird_log", return_value=False),
|
|
||||||
patch.object(locker, "_is_sick_day_log", return_value=True),
|
|
||||||
patch.object(locker, "_try_auto_upgrade_sick_day", return_value=True),
|
|
||||||
):
|
|
||||||
result = locker._check_today_state_exits()
|
|
||||||
assert result is True
|
|
||||||
|
|
||||||
def test_workout_skip_today_returns_true(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
locker = self._make_locker(mock_tk, tmp_path)
|
|
||||||
with (
|
|
||||||
patch.object(locker, "_is_early_bird_log", return_value=False),
|
|
||||||
patch.object(locker, "_is_sick_day_log", return_value=False),
|
|
||||||
patch.object(locker, "has_logged_today", return_value=False),
|
|
||||||
patch(
|
|
||||||
"python_pkg.screen_locker.screen_lock.has_workout_skip_today",
|
|
||||||
return_value=True,
|
|
||||||
),
|
|
||||||
):
|
|
||||||
result = locker._check_today_state_exits()
|
|
||||||
assert result is True
|
|
||||||
|
|
||||||
def test_early_bird_time_returns_true(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
locker = self._make_locker(mock_tk, tmp_path)
|
|
||||||
with (
|
|
||||||
patch.object(locker, "_is_early_bird_log", return_value=False),
|
|
||||||
patch.object(locker, "_is_sick_day_log", return_value=False),
|
|
||||||
patch.object(locker, "has_logged_today", return_value=False),
|
|
||||||
patch(
|
|
||||||
"python_pkg.screen_locker.screen_lock.has_workout_skip_today",
|
|
||||||
return_value=False,
|
|
||||||
),
|
|
||||||
patch.object(locker, "_is_early_bird_time", return_value=True),
|
|
||||||
patch.object(locker, "_save_early_bird_log"),
|
|
||||||
):
|
|
||||||
result = locker._check_today_state_exits()
|
|
||||||
assert result is True
|
|
||||||
|
|
||||||
def test_no_exit_conditions_returns_false(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
locker = self._make_locker(mock_tk, tmp_path)
|
|
||||||
with (
|
|
||||||
patch.object(locker, "_is_early_bird_log", return_value=False),
|
|
||||||
patch.object(locker, "_is_sick_day_log", return_value=False),
|
|
||||||
patch.object(locker, "has_logged_today", return_value=False),
|
|
||||||
patch(
|
|
||||||
"python_pkg.screen_locker.screen_lock.has_workout_skip_today",
|
|
||||||
return_value=False,
|
|
||||||
),
|
|
||||||
patch.object(locker, "_is_early_bird_time", return_value=False),
|
|
||||||
):
|
|
||||||
result = locker._check_today_state_exits()
|
|
||||||
assert result is False
|
|
||||||
|
|
||||||
|
|
||||||
class TestCheckNonVerifyExitsScheduledSkip:
|
|
||||||
"""Cover the return after scheduled-skip sys.exit in _check_non_verify_exits."""
|
|
||||||
|
|
||||||
def test_scheduled_skip_return_reached(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
with patch.object(locker, "_is_scheduled_skip_today", return_value=True):
|
|
||||||
locker._check_non_verify_exits()
|
|
||||||
mock_sys_exit.assert_called_once_with(0)
|
|
||||||
@ -1,18 +0,0 @@
|
|||||||
[Unit]
|
|
||||||
Description=Workout Screen Locker
|
|
||||||
After=graphical-session.target
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=simple
|
|
||||||
WorkingDirectory=/home/kuhy/testsAndMisc
|
|
||||||
Environment=DISPLAY=:0
|
|
||||||
Environment=PYTHONPATH=/home/kuhy/testsAndMisc
|
|
||||||
ExecStartPre=/bin/sleep 1
|
|
||||||
ExecStart=/usr/bin/python3 -m python_pkg.screen_locker.screen_lock --production
|
|
||||||
Restart=on-failure
|
|
||||||
RestartSec=2s
|
|
||||||
RestartPreventExitStatus=0
|
|
||||||
User=%u
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=graphical-session.target
|
|
||||||
Loading…
Reference in New Issue
Block a user