diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml new file mode 100644 index 0000000..98911e8 --- /dev/null +++ b/.github/workflows/pre-commit.yml @@ -0,0 +1,19 @@ +name: pre-commit + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + pre-commit: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Install dependencies + run: pip install -r requirements.txt + - uses: pre-commit/action@v3.0.1 diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml new file mode 100644 index 0000000..66e5931 --- /dev/null +++ b/.github/workflows/python-tests.yml @@ -0,0 +1,28 @@ +name: Tests + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.10", "3.11", "3.12"] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: pip install -r requirements.txt + + - name: Run tests with coverage + run: python -m pytest -v diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..22de16e --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +__pycache__/ +*.py[cod] +.Python +build/ +dist/ +*.egg-info/ +.env +.venv/ +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +.coverage +coverage.lcov +htmlcov/ +*.log +.DS_Store diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..502b9ef --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,131 @@ +# Pre-commit Configuration for screen-locker +# Install: pre-commit install && pre-commit install --hook-type pre-push +# Run: pre-commit run --all-files +# Update: pre-commit autoupdate + +default_language_version: + python: python3 +default_stages: [pre-commit] +fail_fast: false + +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.6.0 + hooks: + - id: trailing-whitespace + args: [--markdown-linebreak-ext=md] + - id: end-of-file-fixer + - id: check-yaml + - id: check-json + - id: check-toml + - id: check-added-large-files + args: [--maxkb=2000] + - id: check-merge-conflict + - id: detect-private-key + - id: debug-statements + - id: name-tests-test + args: [--pytest-test-first] + - id: check-ast + - id: mixed-line-ending + args: [--fix=lf] + - id: requirements-txt-fixer + + - repo: local + hooks: + - id: no-noqa + name: Block noqa comments + entry: '(?i)#\s*(noqa|type:\s*ignore)' + language: pygrep + types: [python] + - id: no-ruff-noqa + name: Block ruff noqa file-level comments + entry: '(?i)#\s*ruff:\s*noqa' + language: pygrep + types: [python] + + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.15.2 + hooks: + - id: ruff + args: [--fix, --unsafe-fixes, --exit-non-zero-on-fix, --show-fixes] + types_or: [python, pyi] + - id: ruff-format + types_or: [python, pyi] + + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.13.0 + hooks: + - id: mypy + args: + - --ignore-missing-imports + - --no-error-summary + - --disable-error-code=no-untyped-def + - --disable-error-code=no-untyped-call + - --disable-error-code=var-annotated + - --disable-error-code=no-any-unimported + - --disable-error-code=type-arg + - --disable-error-code=no-any-return + - --disable-error-code=misc + - --disable-error-code=unused-ignore + - --disable-error-code=unreachable + - --disable-error-code=assignment + - --disable-error-code=no-redef + - --disable-error-code=attr-defined + - --disable-error-code=arg-type + - --disable-error-code=union-attr + - --disable-error-code=call-overload + - --disable-error-code=return-value + - --disable-error-code=redundant-cast + - --disable-error-code=empty-body + - --disable-error-code=list-item + + - repo: https://github.com/pylint-dev/pylint + rev: v3.3.2 + hooks: + - id: pylint + args: + - --rcfile=pyproject.toml + - --fail-under=8.0 + - --jobs=4 + additional_dependencies: + - pytest + + - repo: https://github.com/PyCQA/bandit + rev: 1.7.10 + hooks: + - id: bandit + args: + - -c + - pyproject.toml + - --severity-level=high + - --confidence-level=medium + - --skip=B113 + additional_dependencies: ["bandit[toml]"] + exclude: ^(tests/|.*test.*\.py$) + + - repo: local + hooks: + - id: pytest-coverage + name: pytest with coverage enforcement + entry: python -m pytest + language: system + types: [python] + pass_filenames: false + require_serial: true + stages: [pre-push] + + - repo: https://github.com/codespell-project/codespell + rev: v2.3.0 + hooks: + - id: codespell + args: + - --skip=*.json,*.lock,.git,__pycache__,.venv + - --ignore-words-list=als,ans,ect,nd,som,sur,te,nam,numer,lew,sie,wil,postion,clen,ther,folow,derrive,ony,tje,noe,theses,crate,doubleclick,wile,tabel,pary,blok,bloc,proces,serwer,parametr,adres,hart,dout,metod,tekst,synonim,grup,mosty,lokal,skalar,milion,nowe,tre,hel,alph + + - repo: local + hooks: + - id: shellcheck + name: shellcheck + entry: bash -c 'printf "%s\0" "$@" | xargs -0 -n 40 shellcheck --severity=warning' -- + language: system + types: [shell] diff --git a/screen_locker/early-bird-workout-check.timer b/early-bird-workout-check.timer similarity index 100% rename from screen_locker/early-bird-workout-check.timer rename to early-bird-workout-check.timer diff --git a/screen_locker/install_autostart.sh b/install_autostart.sh similarity index 100% rename from screen_locker/install_autostart.sh rename to install_autostart.sh diff --git a/screen_locker/install_systemd.sh b/install_systemd.sh similarity index 100% rename from screen_locker/install_systemd.sh rename to install_systemd.sh diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..862e1d0 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,149 @@ +[project] +name = "screen-locker" +version = "1.0.0" +description = "Tkinter/systemd screen locker with workout tracking, sick-day management, and wake-alarm integration" +requires-python = ">=3.10" +dependencies = [] # pure stdlib — tkinter is bundled with Python + +[tool.ruff] +target-version = "py310" +include = ["*.py", "**/*.py"] +exclude = [".git", ".venv", "__pycache__", "build", "dist", ".eggs"] + +[tool.ruff.lint] +select = ["ALL"] +ignore = [ + "D203", + "D213", + "COM812", + "ISC001", + "S603", +] +fixable = ["ALL"] +unfixable = [] + +[tool.ruff.lint.per-file-ignores] +"**/tests/**/*.py" = ["ARG", "D", "PLC0415", "PLR2004", "S101", "SLF001"] +"**/test_*.py" = ["ARG", "D", "PLC0415", "PLR2004", "S101", "SLF001"] + +[tool.ruff.lint.pydocstyle] +convention = "google" + +[tool.ruff.lint.isort] +force-single-line = false +force-sort-within-sections = true +known-first-party = ["screen_locker"] + +[tool.ruff.lint.flake8-quotes] +docstring-quotes = "double" +inline-quotes = "double" + +[tool.ruff.lint.flake8-tidy-imports] +ban-relative-imports = "all" + +[tool.ruff.format] +quote-style = "double" +indent-style = "space" +skip-magic-trailing-comma = false +line-ending = "auto" +docstring-code-format = true + +[tool.mypy] +python_version = "3.10" +strict = true +warn_return_any = true +warn_unused_configs = true +disallow_untyped_defs = true +disallow_incomplete_defs = true +check_untyped_defs = true +disallow_untyped_decorators = true +no_implicit_optional = true +warn_redundant_casts = true +warn_unused_ignores = true +warn_no_return = true +warn_unreachable = true +disallow_any_unimported = true +disallow_any_explicit = false +disallow_any_generics = true +disallow_subclassing_any = true +strict_equality = true +extra_checks = true +ignore_missing_imports = true +show_error_codes = true +color_output = true +exclude = [".venv/"] + +[tool.pylint.main] +analyse-fallback-blocks = true +persistent = true +jobs = 0 +py-version = "3.10" +ignore = [".venv", "__pycache__"] +ignore-patterns = [".*\\.pyi$"] + +[tool.pylint.messages_control] +enable = "all" +disable = [] + +[tool.pylint.design] +min-public-methods = 0 +max-module-lines = 1000 +max-attributes = 10 + +[tool.pylint.typecheck] +generated-members = [ + ".*\\.assert_called_once_with", + ".*\\.assert_called_once", + ".*\\.assert_called", + ".*\\.assert_not_called", + ".*\\.assert_any_call", + ".*\\.call_args", + ".*\\.call_args_list", + ".*\\.call_count", +] + +[tool.bandit] +exclude_dirs = ["tests", ".venv"] + +[tool.pytest.ini_options] +testpaths = ["screen_locker/tests"] +python_files = ["test_*.py", "*_test.py"] +python_classes = ["Test*"] +python_functions = ["test_*"] +addopts = [ + "-v", + "--strict-markers", + "--strict-config", + "-ra", + "--cov=screen_locker", + "--cov-branch", + "--cov-report=term-missing", + "--cov-report=lcov", +] +filterwarnings = [ + "error", + "ignore::DeprecationWarning", + "default::pytest.PytestUnraisableExceptionWarning", +] + +[tool.coverage.run] +source = ["screen_locker"] +branch = true +omit = [ + "*/__pycache__/*", + "*/tests/*", + "*/.venv/*", +] + +[tool.coverage.report] +fail_under = 100 +show_missing = true +skip_covered = false +exclude_lines = [ + "pragma: no cover", + "raise NotImplementedError", + "raise AssertionError", + "if TYPE_CHECKING:", + 'if __name__ == "__main__":', +] +partial_branches = ["pragma: no branch"] diff --git a/screen_locker/remove_autostart.sh b/remove_autostart.sh similarity index 100% rename from screen_locker/remove_autostart.sh rename to remove_autostart.sh diff --git a/screen_locker/remove_systemd.sh b/remove_systemd.sh similarity index 100% rename from screen_locker/remove_systemd.sh rename to remove_systemd.sh diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..0395f2e --- /dev/null +++ b/requirements.txt @@ -0,0 +1,14 @@ +# Screen Locker — development dependencies +# Runtime: pure Python stdlib (tkinter, subprocess, socket, sqlite3, etc.) +bandit>=1.7.0 +codespell>=2.2.0 +coverage>=7.4.0 +mypy>=1.8.0 +pre-commit>=3.6.0 +pylint>=3.0.0 +pytest>=8.0.0 +pytest-cov>=4.1.0 +pytest-randomly>=3.15.0 +pytest-sugar>=1.0.0 +pytest-xdist>=3.5.0 +ruff>=0.8.0 diff --git a/screen_locker/run.sh b/run.sh similarity index 66% rename from screen_locker/run.sh rename to run.sh index d0202f6..8e8aee2 100755 --- a/screen_locker/run.sh +++ b/run.sh @@ -1,11 +1,10 @@ #!/usr/bin/env bash set -e SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" -VENV="$REPO_ROOT/.venv" +VENV="$SCRIPT_DIR/.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 "$@" +cd "$SCRIPT_DIR" +"$VENV/bin/python" -m screen_locker.screen_lock "$@" diff --git a/screen_locker/_constants.py b/screen_locker/_constants.py index 892b887..7bdbd5c 100644 --- a/screen_locker/_constants.py +++ b/screen_locker/_constants.py @@ -48,3 +48,16 @@ SICK_DAY_STATE_FILE = Path(__file__).resolve().parent / "sick_day_state.json" 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" + +# --------------------------------------------------------------------------- +# Wake-alarm integration (originally from wake_alarm._constants / _state). +# These must match the values used by the companion wake_alarm service. +# --------------------------------------------------------------------------- +# Days on which the wake alarm fires (0=Mon … 6=Sun). +ALARM_DAYS: frozenset[int] = frozenset({0, 4, 5, 6}) +# How many hours after midnight the alarm triggers (configurable in wake_alarm). +WAKE_AFTER_HOURS: int = 8 +# Path to the rtcwake binary. +RTCWAKE_BIN: str = "/usr/sbin/rtcwake" +# State file written by wake_alarm; read here to check for workout skip. +WAKE_STATE_FILE = Path(__file__).resolve().parent.parent / "wake_alarm" / "wake_state.json" diff --git a/screen_locker/_early_bird.py b/screen_locker/_early_bird.py index 6f6b748..5204261 100644 --- a/screen_locker/_early_bird.py +++ b/screen_locker/_early_bird.py @@ -6,7 +6,7 @@ from datetime import datetime, timezone import json import logging -from python_pkg.screen_locker._constants import ( +from screen_locker._constants import ( EARLY_BIRD_END_HOUR, EARLY_BIRD_END_MINUTE, EARLY_BIRD_START_HOUR, diff --git a/screen_locker/_log_integrity.py b/screen_locker/_log_integrity.py index cc4afcc..762c702 100644 --- a/screen_locker/_log_integrity.py +++ b/screen_locker/_log_integrity.py @@ -1,19 +1,80 @@ -"""HMAC-based integrity checking — re-exports from shared package.""" +"""HMAC-based integrity checking for signed state entries.""" 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, -) +import hashlib +import hmac +import json +import logging +from pathlib import Path +import secrets -__all__ = [ - "HMAC_KEY_FILE", - "_generate_hmac_key", - "_load_hmac_key", - "compute_entry_hmac", - "verify_entry_hmac", -] +_logger = logging.getLogger(__name__) + +# HMAC key for signing state entries (root-owned, 0600) +HMAC_KEY_FILE = Path("/etc/workout-locker/hmac.key") + + +def _load_hmac_key() -> bytes | None: + """Load HMAC key from the root-owned key file. + + Returns the key bytes, or None if the file cannot be read. + """ + try: + return HMAC_KEY_FILE.read_bytes().strip() + except OSError: + _logger.warning("Cannot read HMAC key from %s", HMAC_KEY_FILE) + return None + + +def _generate_hmac_key() -> bytes | None: + """Generate a new HMAC key and write it to the key file. + + The key file must be writable (requires root or setup script). + Returns the new key bytes, or None on failure. + """ + key = secrets.token_bytes(32) + try: + HMAC_KEY_FILE.parent.mkdir(parents=True, exist_ok=True) + HMAC_KEY_FILE.write_bytes(key) + except OSError: + _logger.warning("Cannot write HMAC key to %s", HMAC_KEY_FILE) + return None + return key + + +def compute_entry_hmac(entry_data: dict[str, object]) -> str | None: + """Compute HMAC-SHA256 for a state entry. + + Args: + entry_data: The entry dict (without the 'hmac' field). + + Returns: + Hex-encoded HMAC string, or None if the key is unavailable. + """ + key = _load_hmac_key() + if key is None: + return None + payload = json.dumps(entry_data, sort_keys=True, separators=(",", ":")) + return hmac.new(key, payload.encode(), hashlib.sha256).hexdigest() + + +def verify_entry_hmac(entry: dict[str, object]) -> bool: + """Verify HMAC signature of a state entry. + + Args: + entry: The full entry dict including the 'hmac' field. + + Returns: + True if the HMAC is valid, False if invalid or key unavailable. + """ + stored_hmac = entry.get("hmac") + if not isinstance(stored_hmac, str): + return False + key = _load_hmac_key() + if key is None: + return False + entry_without_hmac = {k: v for k, v in entry.items() if k != "hmac"} + payload = json.dumps(entry_without_hmac, sort_keys=True, separators=(",", ":")) + expected = hmac.new(key, payload.encode(), hashlib.sha256).hexdigest() + return hmac.compare_digest(stored_hmac, expected) diff --git a/screen_locker/_phone_verification.py b/screen_locker/_phone_verification.py index 869be3f..a1627a7 100644 --- a/screen_locker/_phone_verification.py +++ b/screen_locker/_phone_verification.py @@ -17,12 +17,12 @@ import subprocess import tempfile import time -from python_pkg.screen_locker._constants import ( +from screen_locker._constants import ( ADB_TIMEOUT, MIN_WORKOUT_DURATION_MINUTES, STRONGLIFTS_DB_REMOTE, ) -from python_pkg.screen_locker._time_check import check_clock_skew +from screen_locker._time_check import check_clock_skew _logger = logging.getLogger(__name__) diff --git a/screen_locker/_shutdown.py b/screen_locker/_shutdown.py index 89692ef..f7fcfbb 100644 --- a/screen_locker/_shutdown.py +++ b/screen_locker/_shutdown.py @@ -8,12 +8,12 @@ import json import logging import subprocess -from python_pkg.screen_locker._constants import ( +from screen_locker._constants import ( ADJUST_SHUTDOWN_SCRIPT, SHUTDOWN_CONFIG_FILE, SICK_DAY_STATE_FILE, ) -from python_pkg.wake_alarm._constants import ( +from screen_locker._constants import ( ALARM_DAYS, RTCWAKE_BIN, WAKE_AFTER_HOURS, diff --git a/screen_locker/_sick_dialog.py b/screen_locker/_sick_dialog.py index 912a22c..3c2921b 100644 --- a/screen_locker/_sick_dialog.py +++ b/screen_locker/_sick_dialog.py @@ -7,8 +7,8 @@ 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 ( +from screen_locker import _sick_tracker +from screen_locker._constants import ( COMMITMENT_PROMPT_TIMEOUT_SECONDS, SICK_COMMITMENT_FORCED_READ_SECONDS, SICK_JUSTIFICATION_MIN_CHARS, @@ -17,7 +17,7 @@ from python_pkg.screen_locker._constants import ( if TYPE_CHECKING: from collections.abc import Callable - from python_pkg.screen_locker._sick_tracker import SickHistory + from screen_locker._sick_tracker import SickHistory _logger = logging.getLogger(__name__) diff --git a/screen_locker/_sick_tracker.py b/screen_locker/_sick_tracker.py index 54dfe43..9ab7070 100644 --- a/screen_locker/_sick_tracker.py +++ b/screen_locker/_sick_tracker.py @@ -12,7 +12,7 @@ import json import logging from typing import Any -from python_pkg.screen_locker._constants import ( +from screen_locker._constants import ( SICK_BUDGET_PER_7_DAYS, SICK_BUDGET_PER_30_DAYS, SICK_BUDGET_PER_90_DAYS, @@ -23,7 +23,7 @@ from python_pkg.screen_locker._constants import ( SICK_LOCKOUT_MULTIPLIER_PER_RECENT, SICK_LOCKOUT_SECONDS, ) -from python_pkg.shared.log_integrity import compute_entry_hmac +from screen_locker._log_integrity import compute_entry_hmac _logger = logging.getLogger(__name__) diff --git a/screen_locker/_time_check.py b/screen_locker/_time_check.py index f1d0073..dc64373 100644 --- a/screen_locker/_time_check.py +++ b/screen_locker/_time_check.py @@ -7,7 +7,7 @@ import socket import struct import time -from python_pkg.screen_locker._constants import MAX_CLOCK_SKEW_SECONDS +from screen_locker._constants import MAX_CLOCK_SKEW_SECONDS _logger = logging.getLogger(__name__) diff --git a/screen_locker/_ui_flows.py b/screen_locker/_ui_flows.py index edd8aca..1ce2d7b 100644 --- a/screen_locker/_ui_flows.py +++ b/screen_locker/_ui_flows.py @@ -5,13 +5,13 @@ 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 ( +from screen_locker import _sick_tracker +from 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 ( +from screen_locker._weekly_check import ( WEEKLY_WORKOUT_MINIMUM, count_weekly_workouts, ) diff --git a/screen_locker/_wake_state.py b/screen_locker/_wake_state.py new file mode 100644 index 0000000..d4e7049 --- /dev/null +++ b/screen_locker/_wake_state.py @@ -0,0 +1,63 @@ +"""Read wake-alarm state to check if a workout skip was earned today. + +This module reads the JSON state file written by the companion wake_alarm +service. It does not import wake_alarm directly — the two packages +communicate only through the shared state file on disk. + +The state file path defaults to WAKE_STATE_FILE in _constants.py, which +points to the sibling wake_alarm package directory. Override it in tests +by patching ``screen_locker._wake_state.WAKE_STATE_FILE``. +""" + +from __future__ import annotations + +from datetime import datetime, timezone +import json +import logging + +from screen_locker._constants import WAKE_STATE_FILE +from screen_locker._log_integrity import verify_entry_hmac + +_logger = logging.getLogger(__name__) + + +def _today_str() -> str: + """Return today's date as YYYY-MM-DD in UTC.""" + return datetime.now(tz=timezone.utc).strftime("%Y-%m-%d") + + +def load_wake_state() -> dict[str, object] | None: + """Load and verify today's wake state. + + Returns the state dict if it exists, is valid (HMAC OK), and is + for today. Returns None otherwise. + """ + if not WAKE_STATE_FILE.exists(): + return None + + try: + with WAKE_STATE_FILE.open() as f: + state = json.load(f) + except (OSError, json.JSONDecodeError): + _logger.warning("Cannot read wake state file") + return None + + if not isinstance(state, dict): + return None + + if state.get("date") != _today_str(): + return None + + if not verify_entry_hmac(state): + _logger.warning("Wake state HMAC verification failed") + return None + + return state + + +def has_workout_skip_today() -> bool: + """Check if the user earned a workout skip for today.""" + state = load_wake_state() + if state is None: + return False + return bool(state.get("skip_workout")) diff --git a/screen_locker/screen_lock.py b/screen_locker/screen_lock.py index fd0eb86..11f9f18 100755 --- a/screen_locker/screen_lock.py +++ b/screen_locker/screen_lock.py @@ -14,8 +14,8 @@ 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 ( +from screen_locker import _sick_tracker +from screen_locker._constants import ( EARLY_BIRD_END_HOUR, EARLY_BIRD_END_MINUTE, EARLY_BIRD_START_HOUR, @@ -28,23 +28,23 @@ from python_pkg.screen_locker._constants import ( SICK_LOCKOUT_SECONDS, STRONGLIFTS_DB_REMOTE, ) -from python_pkg.screen_locker._early_bird import EarlyBirdMixin -from python_pkg.screen_locker._log_integrity import ( +from screen_locker._early_bird import EarlyBirdMixin +from 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 ( +from screen_locker._phone_verification import PhoneVerificationMixin +from screen_locker._shutdown import ShutdownMixin +from screen_locker._sick_dialog import SickDialogMixin +from screen_locker._ui_flows import UIFlowsMixin +from 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 +from screen_locker._window_setup import WindowSetupMixin +from screen_locker._wake_state import has_workout_skip_today if TYPE_CHECKING: from collections.abc import Callable diff --git a/screen_locker/tests/conftest.py b/screen_locker/tests/conftest.py index 1164cd6..cba25c7 100644 --- a/screen_locker/tests/conftest.py +++ b/screen_locker/tests/conftest.py @@ -17,7 +17,7 @@ from unittest.mock import MagicMock, patch import pytest -from python_pkg.screen_locker.screen_lock import ScreenLocker +from screen_locker.screen_lock import ScreenLocker if TYPE_CHECKING: from collections.abc import Generator, Iterator @@ -51,9 +51,9 @@ def _block_real_tk_and_exit() -> Iterator[None]: 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"), + patch("screen_locker.screen_lock.tk", mock), + patch("screen_locker._sick_dialog.tk", mock), + patch("screen_locker.screen_lock.sys.exit"), ): yield @@ -70,10 +70,10 @@ def mock_subprocess_run() -> Generator[MagicMock]: """ with ( patch( - "python_pkg.screen_locker._window_setup.shutil.which", + "screen_locker._window_setup.shutil.which", return_value="/usr/bin/setxkbmap", ), - patch("python_pkg.screen_locker._window_setup.subprocess.run") as mock, + patch("screen_locker._window_setup.subprocess.run") as mock, ): yield mock @@ -84,11 +84,11 @@ def _isolate_sick_history(tmp_path: Path) -> Iterator[None]: target = tmp_path / "sick_history.json" with ( patch( - "python_pkg.screen_locker._sick_tracker.SICK_HISTORY_FILE", + "screen_locker._sick_tracker.SICK_HISTORY_FILE", target, ), patch( - "python_pkg.screen_locker._constants.SICK_HISTORY_FILE", + "screen_locker._constants.SICK_HISTORY_FILE", target, ), ): @@ -100,7 +100,7 @@ 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", + "screen_locker.screen_lock.SCHEDULED_SKIPS_FILE", target, ): yield @@ -117,11 +117,11 @@ def _mock_weekly_logic() -> Iterator[None]: """ with ( patch( - "python_pkg.screen_locker.screen_lock.is_relaxed_day", + "screen_locker.screen_lock.is_relaxed_day", return_value=False, ), patch( - "python_pkg.screen_locker.screen_lock.has_weekly_minimum", + "screen_locker.screen_lock.has_weekly_minimum", return_value=False, ), ): @@ -131,7 +131,7 @@ def _mock_weekly_logic() -> Iterator[None]: @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: + with patch("screen_locker.screen_lock.tk") as mock: # Set up Tk root mock mock_root = MagicMock() mock_root.winfo_screenwidth.return_value = 1920 @@ -152,7 +152,7 @@ def mock_tk() -> Generator[MagicMock]: @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: + with patch("screen_locker.screen_lock.sys.exit") as mock: yield mock @@ -223,9 +223,9 @@ def create_locker_relaxed_day( 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("screen_locker.screen_lock.is_relaxed_day", return_value=True), patch( - "python_pkg.screen_locker.screen_lock.has_weekly_minimum", + "screen_locker.screen_lock.has_weekly_minimum", return_value=False, ), patch.object(ScreenLocker, "_start_phone_check"), diff --git a/screen_locker/tests/test_adb_and_phone.py b/screen_locker/tests/test_adb_and_phone.py index 0e8f7e0..e0caecd 100644 --- a/screen_locker/tests/test_adb_and_phone.py +++ b/screen_locker/tests/test_adb_and_phone.py @@ -9,8 +9,8 @@ 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 +from screen_locker.screen_lock import STRONGLIFTS_DB_REMOTE +from screen_locker.tests.conftest import create_locker if TYPE_CHECKING: from pathlib import Path @@ -29,7 +29,7 @@ class TestRunAdb: 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", + "screen_locker._phone_verification.subprocess.run", return_value=mock_result, ) as mock_run: success, output = locker._run_adb(["devices"]) @@ -48,7 +48,7 @@ class TestRunAdb: locker = create_locker(mock_tk, tmp_path) mock_result = MagicMock(returncode=1, stdout="") with patch( - "python_pkg.screen_locker._phone_verification.subprocess.run", + "screen_locker._phone_verification.subprocess.run", return_value=mock_result, ): success, _output = locker._run_adb(["devices"]) @@ -64,7 +64,7 @@ class TestRunAdb: """Test ADB binary not found.""" locker = create_locker(mock_tk, tmp_path) with patch( - "python_pkg.screen_locker._phone_verification.subprocess.run", + "screen_locker._phone_verification.subprocess.run", side_effect=FileNotFoundError("adb not found"), ): success, output = locker._run_adb(["devices"]) @@ -81,7 +81,7 @@ class TestRunAdb: """Test ADB OSError.""" locker = create_locker(mock_tk, tmp_path) with patch( - "python_pkg.screen_locker._phone_verification.subprocess.run", + "screen_locker._phone_verification.subprocess.run", side_effect=OSError("permission denied"), ): success, output = locker._run_adb(["devices"]) @@ -98,7 +98,7 @@ class TestRunAdb: """Test ADB command timeout.""" locker = create_locker(mock_tk, tmp_path) with patch( - "python_pkg.screen_locker._phone_verification.subprocess.run", + "screen_locker._phone_verification.subprocess.run", side_effect=subprocess.TimeoutExpired("adb", 15), ): success, output = locker._run_adb(["devices"]) diff --git a/screen_locker/tests/test_adb_and_phone_part2.py b/screen_locker/tests/test_adb_and_phone_part2.py index 5e38be1..ef21c01 100644 --- a/screen_locker/tests/test_adb_and_phone_part2.py +++ b/screen_locker/tests/test_adb_and_phone_part2.py @@ -11,7 +11,7 @@ from typing import TYPE_CHECKING import pytest -from python_pkg.screen_locker.tests.conftest import create_locker +from screen_locker.tests.conftest import create_locker if TYPE_CHECKING: from pathlib import Path diff --git a/screen_locker/tests/test_early_bird.py b/screen_locker/tests/test_early_bird.py index c7444f0..7a6be17 100644 --- a/screen_locker/tests/test_early_bird.py +++ b/screen_locker/tests/test_early_bird.py @@ -10,8 +10,8 @@ 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 ( +from screen_locker.screen_lock import ScreenLocker +from screen_locker.tests.conftest import ( create_locker, create_locker_early_bird, ) @@ -222,7 +222,7 @@ class TestSaveEarlyBirdLog: locker = create_locker(mock_tk, tmp_path) locker.log_file = log_file with patch( - "python_pkg.screen_locker.screen_lock.compute_entry_hmac", + "screen_locker.screen_lock.compute_entry_hmac", return_value=None, ): locker._save_early_bird_log() @@ -258,7 +258,7 @@ class TestTryAutoUpgradeEarlyBird: MagicMock(return_value=True), ) with patch( - "python_pkg.screen_locker.screen_lock.compute_entry_hmac", + "screen_locker.screen_lock.compute_entry_hmac", return_value=None, ): result = locker._try_auto_upgrade_early_bird() @@ -334,7 +334,7 @@ class TestHasLoggedTodayEarlyBird: locker = create_locker(mock_tk, tmp_path) locker.log_file = log_file with patch( - "python_pkg.screen_locker.screen_lock.verify_entry_hmac", + "screen_locker.screen_lock.verify_entry_hmac", return_value=True, ): assert locker.has_logged_today() is False @@ -366,7 +366,7 @@ class TestInitEarlyBirdFlow: patch.object(ScreenLocker, "_start_phone_check"), patch.object(ScreenLocker, "_start_verify_workout_check"), patch( - "python_pkg.screen_locker.screen_lock.has_workout_skip_today", + "screen_locker.screen_lock.has_workout_skip_today", return_value=False, ), pytest.raises(SystemExit), diff --git a/screen_locker/tests/test_init_and_log.py b/screen_locker/tests/test_init_and_log.py index fbeb824..666246c 100644 --- a/screen_locker/tests/test_init_and_log.py +++ b/screen_locker/tests/test_init_and_log.py @@ -10,8 +10,8 @@ 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 +from screen_locker.screen_lock import _assert_not_under_pytest +from screen_locker.tests.conftest import create_locker if TYPE_CHECKING: from pathlib import Path @@ -23,7 +23,7 @@ class TestAssertNotUnderPytest: 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), + patch("screen_locker.screen_lock.tk", tk), pytest.raises(RuntimeError, match="SAFETY"), ): _assert_not_under_pytest() @@ -128,7 +128,7 @@ class TestHasLoggedToday: locker = create_locker(mock_tk, tmp_path) locker.log_file = log_file with patch( - "python_pkg.screen_locker.screen_lock.verify_entry_hmac", + "screen_locker.screen_lock.verify_entry_hmac", return_value=True, ): assert locker.has_logged_today() is True @@ -149,7 +149,7 @@ class TestHasLoggedToday: locker = create_locker(mock_tk, tmp_path) locker.log_file = log_file with patch( - "python_pkg.screen_locker.screen_lock.verify_entry_hmac", + "screen_locker.screen_lock.verify_entry_hmac", return_value=False, ): assert locker.has_logged_today() is False @@ -171,11 +171,11 @@ class TestHasLoggedToday: locker.log_file = log_file with ( patch( - "python_pkg.screen_locker.screen_lock.verify_entry_hmac", + "screen_locker.screen_lock.verify_entry_hmac", return_value=False, ), patch( - "python_pkg.screen_locker.screen_lock._load_hmac_key", + "screen_locker.screen_lock._load_hmac_key", return_value=None, ), ): @@ -198,11 +198,11 @@ class TestHasLoggedToday: locker.log_file = log_file with ( patch( - "python_pkg.screen_locker.screen_lock.verify_entry_hmac", + "screen_locker.screen_lock.verify_entry_hmac", return_value=False, ), patch( - "python_pkg.screen_locker.screen_lock._load_hmac_key", + "screen_locker.screen_lock._load_hmac_key", return_value=b"secret-key", ), ): @@ -238,7 +238,7 @@ class TestSaveWorkoutLog: locker.log_file = log_file locker.workout_data = {"type": "running"} with patch( - "python_pkg.screen_locker.screen_lock.compute_entry_hmac", + "screen_locker.screen_lock.compute_entry_hmac", return_value="abc123", ): locker.save_workout_log() @@ -263,7 +263,7 @@ class TestSaveWorkoutLog: locker.log_file = log_file locker.workout_data = {"type": "running"} with patch( - "python_pkg.screen_locker.screen_lock.compute_entry_hmac", + "screen_locker.screen_lock.compute_entry_hmac", return_value=None, ): locker.save_workout_log() @@ -287,7 +287,7 @@ class TestSaveWorkoutLog: locker.log_file = log_file locker.workout_data = {"type": "strength"} with patch( - "python_pkg.screen_locker.screen_lock.compute_entry_hmac", + "screen_locker.screen_lock.compute_entry_hmac", return_value="sig", ): locker.save_workout_log() @@ -312,7 +312,7 @@ class TestSaveWorkoutLog: locker.log_file = log_file locker.workout_data = {"type": "running"} with patch( - "python_pkg.screen_locker.screen_lock.compute_entry_hmac", + "screen_locker.screen_lock.compute_entry_hmac", return_value="sig", ): locker.save_workout_log() @@ -335,7 +335,7 @@ class TestSaveWorkoutLog: locker.log_file = log_file locker.workout_data = {"type": "running"} with patch( - "python_pkg.screen_locker.screen_lock.compute_entry_hmac", + "screen_locker.screen_lock.compute_entry_hmac", return_value="sig", ): # Should not raise, just log warning diff --git a/screen_locker/tests/test_init_and_log_part2.py b/screen_locker/tests/test_init_and_log_part2.py index f6d08c3..e2cd72b 100644 --- a/screen_locker/tests/test_init_and_log_part2.py +++ b/screen_locker/tests/test_init_and_log_part2.py @@ -10,8 +10,8 @@ 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 +from screen_locker.screen_lock import ScreenLocker +from screen_locker.tests.conftest import create_locker if TYPE_CHECKING: from pathlib import Path @@ -59,7 +59,7 @@ class TestAutoUpgradeSickDay: return_value=True, ) as mock_adjust, patch( - "python_pkg.screen_locker.screen_lock.compute_entry_hmac", + "screen_locker.screen_lock.compute_entry_hmac", return_value="sig", ), ): diff --git a/screen_locker/tests/test_log_integrity.py b/screen_locker/tests/test_log_integrity.py index 2200c80..335a521 100644 --- a/screen_locker/tests/test_log_integrity.py +++ b/screen_locker/tests/test_log_integrity.py @@ -8,14 +8,14 @@ import json from typing import TYPE_CHECKING from unittest.mock import patch -from python_pkg.screen_locker._log_integrity import ( +from 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" +_HMAC_KEY_FILE_PATH = "screen_locker._log_integrity.HMAC_KEY_FILE" if TYPE_CHECKING: from pathlib import Path diff --git a/screen_locker/tests/test_phone_check_unlock.py b/screen_locker/tests/test_phone_check_unlock.py index f99a0f4..fc56db9 100644 --- a/screen_locker/tests/test_phone_check_unlock.py +++ b/screen_locker/tests/test_phone_check_unlock.py @@ -6,7 +6,7 @@ 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 +from screen_locker.tests.conftest import create_locker if TYPE_CHECKING: from pathlib import Path @@ -55,7 +55,7 @@ class TestVerifyPhoneWorkout: ) with patch( - "python_pkg.screen_locker._phone_verification.check_clock_skew", + "screen_locker._phone_verification.check_clock_skew", return_value=(True, "Clock OK"), ): status, message = locker._verify_phone_workout() @@ -90,7 +90,7 @@ class TestVerifyPhoneWorkout: ) with patch( - "python_pkg.screen_locker._phone_verification.check_clock_skew", + "screen_locker._phone_verification.check_clock_skew", return_value=(True, "Clock OK"), ): status, message = locker._verify_phone_workout() @@ -138,7 +138,7 @@ class TestVerifyPhoneWorkout: ) with patch( - "python_pkg.screen_locker._phone_verification.check_clock_skew", + "screen_locker._phone_verification.check_clock_skew", return_value=(True, "Clock OK"), ): status, message = locker._verify_phone_workout() @@ -162,7 +162,7 @@ class TestVerifyPhoneWorkout: ) with patch( - "python_pkg.screen_locker._phone_verification.check_clock_skew", + "screen_locker._phone_verification.check_clock_skew", return_value=(True, "Clock OK"), ): status, _ = locker._verify_phone_workout() @@ -189,7 +189,7 @@ class TestVerifyPhoneWorkout: ) with patch( - "python_pkg.screen_locker._phone_verification.check_clock_skew", + "screen_locker._phone_verification.check_clock_skew", return_value=(True, "Clock OK"), ): status, message = locker._verify_phone_workout() @@ -207,7 +207,7 @@ class TestVerifyPhoneWorkout: locker = create_locker(mock_tk, tmp_path) with patch( - "python_pkg.screen_locker._phone_verification.check_clock_skew", + "screen_locker._phone_verification.check_clock_skew", return_value=(False, "System clock is 600s ahead"), ): status, message = locker._verify_phone_workout() @@ -245,7 +245,7 @@ class TestVerifyPhoneWorkout: ) with patch( - "python_pkg.screen_locker._phone_verification.check_clock_skew", + "screen_locker._phone_verification.check_clock_skew", return_value=(True, "Clock OK"), ): status, message = locker._verify_phone_workout() @@ -288,7 +288,7 @@ class TestVerifyPhoneWorkout: ) with patch( - "python_pkg.screen_locker._phone_verification.check_clock_skew", + "screen_locker._phone_verification.check_clock_skew", return_value=(True, "Clock OK"), ): status, message = locker._verify_phone_workout() diff --git a/screen_locker/tests/test_phone_check_unlock_part2.py b/screen_locker/tests/test_phone_check_unlock_part2.py index 1035ce3..f60eae8 100644 --- a/screen_locker/tests/test_phone_check_unlock_part2.py +++ b/screen_locker/tests/test_phone_check_unlock_part2.py @@ -6,12 +6,12 @@ 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 ( +from screen_locker._constants import NO_PHONE_EXTRA_LOCKOUT_SECONDS +from screen_locker.screen_lock import ( PHONE_PENALTY_DELAY_DEMO, PHONE_PENALTY_DELAY_PRODUCTION, ) -from python_pkg.screen_locker.tests.conftest import create_locker +from screen_locker.tests.conftest import create_locker if TYPE_CHECKING: from pathlib import Path diff --git a/screen_locker/tests/test_phone_verification_part2.py b/screen_locker/tests/test_phone_verification_part2.py index 5ee4a5b..2fcc64c 100644 --- a/screen_locker/tests/test_phone_verification_part2.py +++ b/screen_locker/tests/test_phone_verification_part2.py @@ -5,7 +5,7 @@ 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 +from screen_locker.tests.conftest import create_locker if TYPE_CHECKING: from pathlib import Path @@ -144,7 +144,7 @@ class TestGetLocalSubnetPrefix: 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", + "screen_locker._phone_verification.socket.socket", return_value=mock_sock, ): result = locker._get_local_subnet_prefix() @@ -159,7 +159,7 @@ class TestGetLocalSubnetPrefix: """Test returns None when socket raises OSError.""" locker = create_locker(mock_tk, tmp_path) with patch( - "python_pkg.screen_locker._phone_verification.socket.socket", + "screen_locker._phone_verification.socket.socket", side_effect=OSError("no network"), ): result = locker._get_local_subnet_prefix() @@ -194,7 +194,7 @@ class TestTryWirelessReconnect: 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", + "screen_locker._phone_verification.socket.create_connection", ) as mock_conn, ): mock_sock = MagicMock() @@ -215,7 +215,7 @@ class TestTryWirelessReconnect: with ( patch.object(locker, "_get_local_subnet_prefix", return_value="192.168.1"), patch( - "python_pkg.screen_locker._phone_verification.socket.create_connection", + "screen_locker._phone_verification.socket.create_connection", side_effect=OSError("refused"), ), ): @@ -235,7 +235,7 @@ class TestTryWirelessReconnect: 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", + "screen_locker._phone_verification.socket.create_connection", ) as mock_conn, ): mock_sock = MagicMock() @@ -257,7 +257,7 @@ class TestTryWirelessReconnect: 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", + "screen_locker._phone_verification.socket.create_connection", ) as mock_conn, ): mock_sock = MagicMock() diff --git a/screen_locker/tests/test_scheduled_skip.py b/screen_locker/tests/test_scheduled_skip.py index cc3b6a7..c8a2a52 100644 --- a/screen_locker/tests/test_scheduled_skip.py +++ b/screen_locker/tests/test_scheduled_skip.py @@ -9,12 +9,12 @@ from unittest.mock import MagicMock, patch import pytest -from python_pkg.screen_locker.tests.conftest import create_locker +from screen_locker.tests.conftest import create_locker if TYPE_CHECKING: from pathlib import Path - from python_pkg.screen_locker.screen_lock import ScreenLocker + from screen_locker.screen_lock import ScreenLocker class TestIsScheduledSkipToday: @@ -33,7 +33,7 @@ class TestIsScheduledSkipToday: 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", + "screen_locker.screen_lock.SCHEDULED_SKIPS_FILE", skip_file, ): assert locker._is_scheduled_skip_today() is False @@ -50,7 +50,7 @@ class TestIsScheduledSkipToday: 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", + "screen_locker.screen_lock.SCHEDULED_SKIPS_FILE", skip_file, ): assert locker._is_scheduled_skip_today() is True @@ -66,7 +66,7 @@ class TestIsScheduledSkipToday: 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", + "screen_locker.screen_lock.SCHEDULED_SKIPS_FILE", skip_file, ): assert locker._is_scheduled_skip_today() is False @@ -82,7 +82,7 @@ class TestIsScheduledSkipToday: 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", + "screen_locker.screen_lock.SCHEDULED_SKIPS_FILE", skip_file, ): assert locker._is_scheduled_skip_today() is False @@ -99,7 +99,7 @@ class TestIsScheduledSkipToday: skip_file.write_text("[]") with ( patch( - "python_pkg.screen_locker.screen_lock.SCHEDULED_SKIPS_FILE", + "screen_locker.screen_lock.SCHEDULED_SKIPS_FILE", skip_file, ), patch("builtins.open", side_effect=OSError("permission denied")), @@ -117,7 +117,7 @@ class TestIsScheduledSkipToday: skip_file = tmp_path / "scheduled_skips.json" skip_file.write_text("[]") with patch( - "python_pkg.screen_locker.screen_lock.SCHEDULED_SKIPS_FILE", + "screen_locker.screen_lock.SCHEDULED_SKIPS_FILE", skip_file, ): assert locker._is_scheduled_skip_today() is False diff --git a/screen_locker/tests/test_shutdown_part2.py b/screen_locker/tests/test_shutdown_part2.py index 28822a8..a19c00e 100644 --- a/screen_locker/tests/test_shutdown_part2.py +++ b/screen_locker/tests/test_shutdown_part2.py @@ -6,7 +6,7 @@ import json from typing import TYPE_CHECKING from unittest.mock import MagicMock, patch -from python_pkg.screen_locker.tests.conftest import create_locker +from screen_locker.tests.conftest import create_locker if TYPE_CHECKING: from pathlib import Path @@ -228,7 +228,7 @@ class TestSickModeUsedToday: mock_file = MagicMock() mock_file.exists.return_value = False with patch( - "python_pkg.screen_locker._shutdown.SICK_DAY_STATE_FILE", + "screen_locker._shutdown.SICK_DAY_STATE_FILE", mock_file, ): assert locker._sick_mode_used_today() is False @@ -243,7 +243,7 @@ class TestSickModeUsedToday: locker = create_locker(mock_tk, tmp_path) state_file = tmp_path / "state.json" with patch( - "python_pkg.screen_locker._shutdown.SICK_DAY_STATE_FILE", + "screen_locker._shutdown.SICK_DAY_STATE_FILE", state_file, ): from datetime import datetime, timezone @@ -262,7 +262,7 @@ class TestSickModeUsedToday: locker = create_locker(mock_tk, tmp_path) state_file = tmp_path / "state.json" with patch( - "python_pkg.screen_locker._shutdown.SICK_DAY_STATE_FILE", + "screen_locker._shutdown.SICK_DAY_STATE_FILE", state_file, ): state_file.write_text(json.dumps({"date": "2020-01-01"})) @@ -278,7 +278,7 @@ class TestSickModeUsedToday: locker = create_locker(mock_tk, tmp_path) state_file = tmp_path / "state.json" with patch( - "python_pkg.screen_locker._shutdown.SICK_DAY_STATE_FILE", + "screen_locker._shutdown.SICK_DAY_STATE_FILE", state_file, ): state_file.write_text("not json{{{") @@ -298,7 +298,7 @@ class TestSaveSickDayState: locker = create_locker(mock_tk, tmp_path) state_file = tmp_path / "state.json" with patch( - "python_pkg.screen_locker._shutdown.SICK_DAY_STATE_FILE", + "screen_locker._shutdown.SICK_DAY_STATE_FILE", state_file, ): result = locker._save_sick_day_state("2026-03-21", 21, 20) @@ -319,7 +319,7 @@ class TestSaveSickDayState: mock_path = MagicMock() mock_path.open.side_effect = OSError("permission denied") with patch( - "python_pkg.screen_locker._shutdown.SICK_DAY_STATE_FILE", + "screen_locker._shutdown.SICK_DAY_STATE_FILE", mock_path, ): result = locker._save_sick_day_state("2026-03-21", 21, 20) @@ -348,7 +348,7 @@ class TestLoadSickDayState: ) ) with patch( - "python_pkg.screen_locker._shutdown.SICK_DAY_STATE_FILE", + "screen_locker._shutdown.SICK_DAY_STATE_FILE", state_file, ): result = locker._load_sick_day_state() @@ -365,7 +365,7 @@ class TestLoadSickDayState: 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", + "screen_locker._shutdown.SICK_DAY_STATE_FILE", state_file, ): result = locker._load_sick_day_state() @@ -391,7 +391,7 @@ class TestWriteRestoredConfig: locker, "_write_shutdown_config", return_value=True ) as mock_write, patch( - "python_pkg.screen_locker._shutdown.SICK_DAY_STATE_FILE", + "screen_locker._shutdown.SICK_DAY_STATE_FILE", state_file, ), ): @@ -412,7 +412,7 @@ class TestWriteRestoredConfig: with ( patch.object(locker, "_read_shutdown_config", return_value=None), patch( - "python_pkg.screen_locker._shutdown.SICK_DAY_STATE_FILE", + "screen_locker._shutdown.SICK_DAY_STATE_FILE", state_file, ), ): diff --git a/screen_locker/tests/test_shutdown_part3.py b/screen_locker/tests/test_shutdown_part3.py index 7ec85c7..9992a46 100644 --- a/screen_locker/tests/test_shutdown_part3.py +++ b/screen_locker/tests/test_shutdown_part3.py @@ -7,8 +7,8 @@ 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 +from screen_locker._constants import ADJUST_SHUTDOWN_SCRIPT +from screen_locker.tests.conftest import create_locker if TYPE_CHECKING: from pathlib import Path @@ -28,7 +28,7 @@ class TestRestoreOriginalConfigIfNeeded: mock_file = MagicMock() mock_file.exists.return_value = False with patch( - "python_pkg.screen_locker._shutdown.SICK_DAY_STATE_FILE", + "screen_locker._shutdown.SICK_DAY_STATE_FILE", mock_file, ): locker._restore_original_config_if_needed() @@ -53,7 +53,7 @@ class TestRestoreOriginalConfigIfNeeded: ) with ( patch( - "python_pkg.screen_locker._shutdown.SICK_DAY_STATE_FILE", + "screen_locker._shutdown.SICK_DAY_STATE_FILE", state_file, ), patch.object(locker, "_write_restored_config") as mock_restore, @@ -84,7 +84,7 @@ class TestRestoreOriginalConfigIfNeeded: ) with ( patch( - "python_pkg.screen_locker._shutdown.SICK_DAY_STATE_FILE", + "screen_locker._shutdown.SICK_DAY_STATE_FILE", state_file, ), patch.object(locker, "_write_restored_config") as mock_restore, @@ -104,7 +104,7 @@ class TestRestoreOriginalConfigIfNeeded: state_file.write_text(json.dumps({"date": "2020-01-01"})) with ( patch( - "python_pkg.screen_locker._shutdown.SICK_DAY_STATE_FILE", + "screen_locker._shutdown.SICK_DAY_STATE_FILE", state_file, ), patch.object(locker, "_write_restored_config") as mock_restore, @@ -124,7 +124,7 @@ class TestRestoreOriginalConfigIfNeeded: mock_file.exists.return_value = True mock_file.open.side_effect = OSError("fail") with patch( - "python_pkg.screen_locker._shutdown.SICK_DAY_STATE_FILE", + "screen_locker._shutdown.SICK_DAY_STATE_FILE", mock_file, ): locker._restore_original_config_if_needed() @@ -140,7 +140,7 @@ class TestRestoreOriginalConfigIfNeeded: state_file = tmp_path / "state.json" state_file.write_text("not valid json{{{") with patch( - "python_pkg.screen_locker._shutdown.SICK_DAY_STATE_FILE", + "screen_locker._shutdown.SICK_DAY_STATE_FILE", state_file, ): locker._restore_original_config_if_needed() @@ -160,7 +160,7 @@ class TestReadShutdownConfig: mock_file = MagicMock() mock_file.exists.return_value = False with patch( - "python_pkg.screen_locker._shutdown.SHUTDOWN_CONFIG_FILE", + "screen_locker._shutdown.SHUTDOWN_CONFIG_FILE", mock_file, ): assert locker._read_shutdown_config() is None @@ -176,7 +176,7 @@ class TestReadShutdownConfig: 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", + "screen_locker._shutdown.SHUTDOWN_CONFIG_FILE", config_file, ): result = locker._read_shutdown_config() @@ -193,7 +193,7 @@ class TestReadShutdownConfig: 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", + "screen_locker._shutdown.SHUTDOWN_CONFIG_FILE", config_file, ): result = locker._read_shutdown_config() @@ -253,7 +253,7 @@ class TestWriteShutdownConfig: mock_script = MagicMock() mock_script.exists.return_value = False with patch( - "python_pkg.screen_locker._shutdown.ADJUST_SHUTDOWN_SCRIPT", + "screen_locker._shutdown.ADJUST_SHUTDOWN_SCRIPT", mock_script, ): result = locker._write_shutdown_config(21, 20, 8) @@ -271,7 +271,7 @@ class TestWriteShutdownConfig: mock_script.exists.return_value = True with ( patch( - "python_pkg.screen_locker._shutdown.ADJUST_SHUTDOWN_SCRIPT", + "screen_locker._shutdown.ADJUST_SHUTDOWN_SCRIPT", mock_script, ), patch.object(locker, "_run_shutdown_cmd", return_value=True) as mock_run, @@ -294,7 +294,7 @@ class TestRunShutdownCmd: locker = create_locker(mock_tk, tmp_path) mock_result = MagicMock(stdout="OK\n") with patch( - "python_pkg.screen_locker._shutdown.subprocess.run", + "screen_locker._shutdown.subprocess.run", return_value=mock_result, ): result = locker._run_shutdown_cmd(["cmd"], 21, 20) @@ -309,7 +309,7 @@ class TestRunShutdownCmd: """Test returns False on SubprocessError.""" locker = create_locker(mock_tk, tmp_path) with patch( - "python_pkg.screen_locker._shutdown.subprocess.run", + "screen_locker._shutdown.subprocess.run", side_effect=subprocess.CalledProcessError(1, "cmd"), ): result = locker._run_shutdown_cmd(["cmd"], 21, 20) diff --git a/screen_locker/tests/test_sick_features.py b/screen_locker/tests/test_sick_features.py index cef712f..0589a14 100644 --- a/screen_locker/tests/test_sick_features.py +++ b/screen_locker/tests/test_sick_features.py @@ -6,9 +6,9 @@ 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 +from screen_locker import _sick_tracker +from screen_locker._sick_tracker import SickHistory +from screen_locker.tests.conftest import create_locker if TYPE_CHECKING: from pathlib import Path @@ -439,7 +439,7 @@ 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 + from screen_locker._sick_dialog import _disable_paste widget = MagicMock() import tkinter as tk diff --git a/screen_locker/tests/test_sick_tracker.py b/screen_locker/tests/test_sick_tracker.py index e7d7e4a..48b4416 100644 --- a/screen_locker/tests/test_sick_tracker.py +++ b/screen_locker/tests/test_sick_tracker.py @@ -8,8 +8,8 @@ from unittest.mock import patch import pytest -from python_pkg.screen_locker import _sick_tracker -from python_pkg.screen_locker._constants import ( +from screen_locker import _sick_tracker +from screen_locker._constants import ( SICK_BUDGET_PER_7_DAYS, SICK_BUDGET_PER_30_DAYS, SICK_BUDGET_PER_90_DAYS, @@ -19,7 +19,7 @@ from python_pkg.screen_locker._constants import ( SICK_LOCKOUT_MULTIPLIER_PER_RECENT, SICK_LOCKOUT_SECONDS, ) -from python_pkg.screen_locker._sick_tracker import ( +from screen_locker._sick_tracker import ( JustificationDraft, SickHistory, add_justification, diff --git a/screen_locker/tests/test_time_check.py b/screen_locker/tests/test_time_check.py index b8474bc..4514a61 100644 --- a/screen_locker/tests/test_time_check.py +++ b/screen_locker/tests/test_time_check.py @@ -6,7 +6,7 @@ import struct import time from unittest.mock import MagicMock, patch -from python_pkg.screen_locker._time_check import ( +from screen_locker._time_check import ( _NTP_EPOCH_OFFSET, _query_ntp_offset, check_clock_skew, @@ -66,7 +66,7 @@ class TestCheckClockSkew: 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", + "screen_locker._time_check._query_ntp_offset", return_value=2.5, ): ok, message = check_clock_skew() @@ -77,7 +77,7 @@ class TestCheckClockSkew: 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", + "screen_locker._time_check._query_ntp_offset", return_value=600.0, ): ok, message = check_clock_skew() @@ -88,7 +88,7 @@ class TestCheckClockSkew: 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", + "screen_locker._time_check._query_ntp_offset", return_value=None, ): ok, message = check_clock_skew() @@ -99,7 +99,7 @@ class TestCheckClockSkew: 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", + "screen_locker._time_check._query_ntp_offset", return_value=-400.0, ): ok, message = check_clock_skew() diff --git a/screen_locker/tests/test_ui_and_timers.py b/screen_locker/tests/test_ui_and_timers.py index e9f615f..3e385f1 100644 --- a/screen_locker/tests/test_ui_and_timers.py +++ b/screen_locker/tests/test_ui_and_timers.py @@ -5,7 +5,7 @@ from __future__ import annotations from typing import TYPE_CHECKING from unittest.mock import MagicMock -from python_pkg.screen_locker.tests.conftest import create_locker +from screen_locker.tests.conftest import create_locker if TYPE_CHECKING: from pathlib import Path diff --git a/screen_locker/tests/test_ui_and_timers_part2.py b/screen_locker/tests/test_ui_and_timers_part2.py index 6a51cb2..53ec95e 100644 --- a/screen_locker/tests/test_ui_and_timers_part2.py +++ b/screen_locker/tests/test_ui_and_timers_part2.py @@ -5,11 +5,11 @@ 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 ( +from screen_locker._sick_tracker import SickHistory +from screen_locker.screen_lock import ( SICK_LOCKOUT_SECONDS, ) -from python_pkg.screen_locker.tests.conftest import create_locker +from screen_locker.tests.conftest import create_locker if TYPE_CHECKING: from pathlib import Path diff --git a/screen_locker/tests/test_ui_flows_part2.py b/screen_locker/tests/test_ui_flows_part2.py index 0e8facf..9fd188b 100644 --- a/screen_locker/tests/test_ui_flows_part2.py +++ b/screen_locker/tests/test_ui_flows_part2.py @@ -5,7 +5,7 @@ from __future__ import annotations from typing import TYPE_CHECKING from unittest.mock import MagicMock -from python_pkg.screen_locker.tests.conftest import create_locker +from screen_locker.tests.conftest import create_locker if TYPE_CHECKING: from pathlib import Path diff --git a/screen_locker/tests/test_verify_workout.py b/screen_locker/tests/test_verify_workout.py index e4134c5..d027a67 100644 --- a/screen_locker/tests/test_verify_workout.py +++ b/screen_locker/tests/test_verify_workout.py @@ -9,7 +9,7 @@ from unittest.mock import MagicMock import pytest -from python_pkg.screen_locker.tests.conftest import create_locker +from screen_locker.tests.conftest import create_locker if TYPE_CHECKING: from pathlib import Path diff --git a/screen_locker/tests/test_vt_switching.py b/screen_locker/tests/test_vt_switching.py index 8001f80..d1079e6 100644 --- a/screen_locker/tests/test_vt_switching.py +++ b/screen_locker/tests/test_vt_switching.py @@ -5,7 +5,7 @@ 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 +from screen_locker.tests.conftest import create_locker if TYPE_CHECKING: from pathlib import Path @@ -109,7 +109,7 @@ class TestVTSwitching: ) -> None: """No crash and no subprocess call when setxkbmap is not installed.""" with patch( - "python_pkg.screen_locker._window_setup.shutil.which", + "screen_locker._window_setup.shutil.which", return_value=None, ): create_locker(mock_tk, tmp_path, demo_mode=False) @@ -128,7 +128,7 @@ class TestVTSwitching: mock_subprocess_run.reset_mock() with patch( - "python_pkg.screen_locker._window_setup.shutil.which", + "screen_locker._window_setup.shutil.which", return_value=None, ): locker.close() diff --git a/screen_locker/tests/test_wake_shutdown.py b/screen_locker/tests/test_wake_shutdown.py index 4881f86..8a10294 100644 --- a/screen_locker/tests/test_wake_shutdown.py +++ b/screen_locker/tests/test_wake_shutdown.py @@ -5,7 +5,7 @@ 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 +from screen_locker.tests.conftest import create_locker if TYPE_CHECKING: from pathlib import Path @@ -26,7 +26,7 @@ class TestIsTomorrowAlarmDay: # Sunday 2026-04-12 → tomorrow Monday with patch( - "python_pkg.screen_locker._shutdown.datetime", + "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 @@ -34,7 +34,7 @@ class TestIsTomorrowAlarmDay: # Ensure timedelta works with patch( - "python_pkg.screen_locker._shutdown.timedelta", + "screen_locker._shutdown.timedelta", timedelta, ): assert locker._is_tomorrow_alarm_day() is True @@ -52,10 +52,10 @@ class TestIsTomorrowAlarmDay: # Monday 2026-04-13 → tomorrow Tuesday (weekday=1) with ( patch( - "python_pkg.screen_locker._shutdown.datetime", + "screen_locker._shutdown.datetime", ) as mock_dt, patch( - "python_pkg.screen_locker._shutdown.timedelta", + "screen_locker._shutdown.timedelta", timedelta, ), ): @@ -76,10 +76,10 @@ class TestIsTomorrowAlarmDay: # Thursday 2026-04-16 → tomorrow Friday (weekday=4) with ( patch( - "python_pkg.screen_locker._shutdown.datetime", + "screen_locker._shutdown.datetime", ) as mock_dt, patch( - "python_pkg.screen_locker._shutdown.timedelta", + "screen_locker._shutdown.timedelta", timedelta, ), ): @@ -100,7 +100,7 @@ class TestScheduleRtcwake: """Successful rtcwake call returns True.""" locker = create_locker(mock_tk, tmp_path) with patch( - "python_pkg.screen_locker._shutdown.subprocess.run", + "screen_locker._shutdown.subprocess.run", ) as mock_run: mock_run.return_value = MagicMock(returncode=0) assert locker._schedule_rtcwake() is True @@ -119,7 +119,7 @@ class TestScheduleRtcwake: import subprocess with patch( - "python_pkg.screen_locker._shutdown.subprocess.run", + "screen_locker._shutdown.subprocess.run", side_effect=subprocess.SubprocessError("rtcwake failed"), ): assert locker._schedule_rtcwake() is False diff --git a/screen_locker/tests/test_wake_skip.py b/screen_locker/tests/test_wake_skip.py index a7becca..e61e571 100644 --- a/screen_locker/tests/test_wake_skip.py +++ b/screen_locker/tests/test_wake_skip.py @@ -5,7 +5,7 @@ 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 +from screen_locker.tests.conftest import create_locker if TYPE_CHECKING: from pathlib import Path @@ -22,7 +22,7 @@ class TestWakeSkipIntegration: ) -> None: """Screen locker exits if wake alarm granted workout skip today.""" with patch( - "python_pkg.screen_locker.screen_lock.has_workout_skip_today", + "screen_locker.screen_lock.has_workout_skip_today", return_value=True, ): create_locker(mock_tk, tmp_path, has_logged=False) @@ -37,7 +37,7 @@ class TestWakeSkipIntegration: ) -> None: """Screen locker proceeds normally if no wake skip active.""" with patch( - "python_pkg.screen_locker.screen_lock.has_workout_skip_today", + "screen_locker.screen_lock.has_workout_skip_today", return_value=False, ): locker = create_locker(mock_tk, tmp_path, has_logged=False) @@ -53,7 +53,7 @@ class TestWakeSkipIntegration: ) -> None: """has_logged_today exits before wake skip is even checked.""" with patch( - "python_pkg.screen_locker.screen_lock.has_workout_skip_today", + "screen_locker.screen_lock.has_workout_skip_today", return_value=True, ): create_locker(mock_tk, tmp_path, has_logged=True) @@ -69,7 +69,7 @@ class TestWakeSkipIntegration: ) -> None: """verify_only mode checks sick day log, not wake skip.""" with patch( - "python_pkg.screen_locker.screen_lock.has_workout_skip_today", + "screen_locker.screen_lock.has_workout_skip_today", return_value=True, ): create_locker( diff --git a/screen_locker/tests/test_wake_state.py b/screen_locker/tests/test_wake_state.py new file mode 100644 index 0000000..e339390 --- /dev/null +++ b/screen_locker/tests/test_wake_state.py @@ -0,0 +1,203 @@ +"""Tests for screen_locker._wake_state — 100% branch coverage.""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import TYPE_CHECKING +from unittest.mock import MagicMock, patch + +import pytest + +from screen_locker._wake_state import has_workout_skip_today, load_wake_state + +if TYPE_CHECKING: + pass + +_TODAY = "2026-05-28" +_STATE_PATH = "screen_locker._wake_state.WAKE_STATE_FILE" + + +# --------------------------------------------------------------------------- +# load_wake_state — file-absent / unreadable paths +# --------------------------------------------------------------------------- + + +class TestLoadWakeStateFileAbsent: + def test_returns_none_when_file_missing(self, tmp_path: Path) -> None: + """Returns None when state file does not exist.""" + missing = tmp_path / "no_such_file.json" + with patch(_STATE_PATH, missing): + assert load_wake_state() is None + + def test_returns_none_on_oserror(self, tmp_path: Path) -> None: + """Returns None when the state file cannot be opened.""" + state_file = tmp_path / "wake_state.json" + state_file.write_text("{}") + with ( + patch(_STATE_PATH, state_file), + patch("screen_locker._wake_state.WAKE_STATE_FILE", state_file), + patch("builtins.open", side_effect=OSError("permission denied")), + ): + assert load_wake_state() is None + + def test_returns_none_on_invalid_json(self, tmp_path: Path) -> None: + """Returns None when the state file contains invalid JSON.""" + state_file = tmp_path / "wake_state.json" + state_file.write_text("not-valid-json") + with patch(_STATE_PATH, state_file): + assert load_wake_state() is None + + def test_returns_none_when_state_not_dict(self, tmp_path: Path) -> None: + """Returns None when the JSON root is not a dict.""" + state_file = tmp_path / "wake_state.json" + state_file.write_text("[1, 2, 3]") + with patch(_STATE_PATH, state_file): + assert load_wake_state() is None + + +# --------------------------------------------------------------------------- +# load_wake_state — date / HMAC validation paths +# --------------------------------------------------------------------------- + + +class TestLoadWakeStateValidation: + def test_returns_none_when_date_stale(self, tmp_path: Path) -> None: + """Returns None when the state file is from a previous day.""" + state_file = tmp_path / "wake_state.json" + state = {"date": "2000-01-01", "skip_workout": True} + state_file.write_text(json.dumps(state)) + with ( + patch(_STATE_PATH, state_file), + patch( + "screen_locker._wake_state._today_str", + return_value=_TODAY, + ), + ): + assert load_wake_state() is None + + def test_returns_none_when_hmac_invalid(self, tmp_path: Path) -> None: + """Returns None when HMAC verification fails.""" + state_file = tmp_path / "wake_state.json" + state = {"date": _TODAY, "skip_workout": True, "hmac": "bad"} + state_file.write_text(json.dumps(state)) + with ( + patch(_STATE_PATH, state_file), + patch( + "screen_locker._wake_state._today_str", + return_value=_TODAY, + ), + patch( + "screen_locker._wake_state.verify_entry_hmac", + return_value=False, + ), + ): + assert load_wake_state() is None + + def test_returns_state_when_valid(self, tmp_path: Path) -> None: + """Returns the state dict when date matches and HMAC is valid.""" + state_file = tmp_path / "wake_state.json" + state = {"date": _TODAY, "skip_workout": True, "hmac": "sig"} + state_file.write_text(json.dumps(state)) + with ( + patch(_STATE_PATH, state_file), + patch( + "screen_locker._wake_state._today_str", + return_value=_TODAY, + ), + patch( + "screen_locker._wake_state.verify_entry_hmac", + return_value=True, + ), + ): + result = load_wake_state() + assert result is not None + assert result["skip_workout"] is True + + def test_returns_state_when_skip_false(self, tmp_path: Path) -> None: + """Returns the state dict even when skip_workout is False.""" + state_file = tmp_path / "wake_state.json" + state = {"date": _TODAY, "skip_workout": False, "hmac": "sig"} + state_file.write_text(json.dumps(state)) + with ( + patch(_STATE_PATH, state_file), + patch( + "screen_locker._wake_state._today_str", + return_value=_TODAY, + ), + patch( + "screen_locker._wake_state.verify_entry_hmac", + return_value=True, + ), + ): + result = load_wake_state() + assert result is not None + assert result["skip_workout"] is False + + +# --------------------------------------------------------------------------- +# has_workout_skip_today +# --------------------------------------------------------------------------- + + +class TestHasWorkoutSkipToday: + def test_returns_false_when_no_state(self) -> None: + """Returns False when load_wake_state returns None.""" + with patch( + "screen_locker._wake_state.load_wake_state", + return_value=None, + ): + assert has_workout_skip_today() is False + + def test_returns_true_when_skip_active(self) -> None: + """Returns True when state has skip_workout=True.""" + with patch( + "screen_locker._wake_state.load_wake_state", + return_value={"date": _TODAY, "skip_workout": True}, + ): + assert has_workout_skip_today() is True + + def test_returns_false_when_skip_inactive(self) -> None: + """Returns False when state has skip_workout=False.""" + with patch( + "screen_locker._wake_state.load_wake_state", + return_value={"date": _TODAY, "skip_workout": False}, + ): + assert has_workout_skip_today() is False + + +# --------------------------------------------------------------------------- +# _today_str +# --------------------------------------------------------------------------- + + +class TestTodayStr: + def test_returns_iso_date_format(self) -> None: + """_today_str returns a YYYY-MM-DD string.""" + from screen_locker._wake_state import _today_str + + result = _today_str() + assert len(result) == 10 + assert result[4] == "-" + assert result[7] == "-" + + +# --------------------------------------------------------------------------- +# OSError in load_wake_state body (JSON decode error path) +# --------------------------------------------------------------------------- + + +class TestLoadWakeStateOsError: + def test_oserror_during_read(self, tmp_path: Path) -> None: + """Returns None and warns when open() raises OSError.""" + state_file = tmp_path / "wake_state.json" + state_file.write_text("{}") + mock_open = MagicMock(side_effect=OSError("disk error")) + with ( + patch(_STATE_PATH, state_file), + patch("screen_locker._wake_state.open", mock_open, create=True), + ): + # We can't easily patch builtins.open for a specific file, + # so we test by patching the json.load path + with patch("json.load", side_effect=OSError("disk error")): + assert load_wake_state() is None diff --git a/screen_locker/tests/test_weekly_check.py b/screen_locker/tests/test_weekly_check.py index 5ff4f50..ca98ffa 100644 --- a/screen_locker/tests/test_weekly_check.py +++ b/screen_locker/tests/test_weekly_check.py @@ -8,7 +8,7 @@ import json from typing import TYPE_CHECKING, Any from unittest.mock import patch -from python_pkg.screen_locker._weekly_check import ( +from screen_locker._weekly_check import ( _RELAXED_WEEKDAYS, WEEKLY_WORKOUT_MINIMUM, count_weekly_workouts, diff --git a/screen_locker/tests/test_weekly_logic.py b/screen_locker/tests/test_weekly_logic.py index 13f4442..08ac985 100644 --- a/screen_locker/tests/test_weekly_logic.py +++ b/screen_locker/tests/test_weekly_logic.py @@ -5,8 +5,8 @@ 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 ( +from screen_locker.screen_lock import ScreenLocker +from screen_locker.tests.conftest import ( create_locker, create_locker_relaxed_day, ) @@ -43,11 +43,11 @@ class TestRelaxedDayBranch: ScreenLocker, "_try_auto_upgrade_early_bird", return_value=False ), patch( - "python_pkg.screen_locker.screen_lock.is_relaxed_day", + "screen_locker.screen_lock.is_relaxed_day", return_value=True, ), patch( - "python_pkg.screen_locker.screen_lock.has_weekly_minimum", + "screen_locker.screen_lock.has_weekly_minimum", return_value=False, ), patch.object(ScreenLocker, "_start_phone_check") as mock_phone, @@ -75,11 +75,11 @@ class TestRelaxedDayBranch: ScreenLocker, "_try_auto_upgrade_early_bird", return_value=False ), patch( - "python_pkg.screen_locker.screen_lock.is_relaxed_day", + "screen_locker.screen_lock.is_relaxed_day", return_value=True, ), patch( - "python_pkg.screen_locker.screen_lock.has_weekly_minimum", + "screen_locker.screen_lock.has_weekly_minimum", return_value=False, ), patch.object(ScreenLocker, "_setup_window") as mock_full, @@ -109,11 +109,11 @@ class TestRelaxedDayBranch: ScreenLocker, "_try_auto_upgrade_early_bird", return_value=False ), patch( - "python_pkg.screen_locker.screen_lock.is_relaxed_day", + "screen_locker.screen_lock.is_relaxed_day", return_value=True, ), patch( - "python_pkg.screen_locker.screen_lock.has_weekly_minimum", + "screen_locker.screen_lock.has_weekly_minimum", return_value=False, ), patch.object(ScreenLocker, "_grab_input") as mock_grab, @@ -148,7 +148,7 @@ class TestWeeklyMinimumBranch: tmp_path: Path, ) -> None: with patch( - "python_pkg.screen_locker.screen_lock.has_weekly_minimum", + "screen_locker.screen_lock.has_weekly_minimum", return_value=True, ): create_locker(mock_tk, tmp_path, has_logged=False) @@ -164,7 +164,7 @@ class TestWeeklyMinimumBranch: # 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", + "screen_locker.screen_lock.has_weekly_minimum", return_value=False, ): locker = create_locker(mock_tk, tmp_path, has_logged=False) @@ -179,7 +179,7 @@ class TestWeeklyMinimumBranch: tmp_path: Path, ) -> None: with patch( - "python_pkg.screen_locker.screen_lock.has_weekly_minimum", + "screen_locker.screen_lock.has_weekly_minimum", ) as mock_weekly: create_locker_relaxed_day(mock_tk, tmp_path) @@ -192,7 +192,7 @@ class TestWeeklyMinimumBranch: tmp_path: Path, ) -> None: with patch( - "python_pkg.screen_locker.screen_lock.has_weekly_minimum", + "screen_locker.screen_lock.has_weekly_minimum", ) as mock_weekly: create_locker(mock_tk, tmp_path, has_logged=True) @@ -217,7 +217,7 @@ class TestStartRelaxedDayFlow: locker = self._make_locker(mock_tk, tmp_path) with ( patch( - "python_pkg.screen_locker._ui_flows.count_weekly_workouts", + "screen_locker._ui_flows.count_weekly_workouts", return_value=2, ), patch.object(locker, "_text") as mock_text, @@ -241,7 +241,7 @@ class TestStartRelaxedDayFlow: locker = self._make_locker(mock_tk, tmp_path) with ( patch( - "python_pkg.screen_locker._ui_flows.count_weekly_workouts", + "screen_locker._ui_flows.count_weekly_workouts", return_value=0, ), patch.object(locker, "_button") as mock_button, @@ -268,7 +268,7 @@ class TestStartRelaxedDayFlow: locker = self._make_locker(mock_tk, tmp_path) with ( patch( - "python_pkg.screen_locker._ui_flows.count_weekly_workouts", + "screen_locker._ui_flows.count_weekly_workouts", return_value=1, ), patch.object(locker, "_button") as mock_button, @@ -534,7 +534,7 @@ class TestCheckTodayStateExits: 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", + "screen_locker.screen_lock.has_workout_skip_today", return_value=True, ), ): @@ -553,7 +553,7 @@ class TestCheckTodayStateExits: 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", + "screen_locker.screen_lock.has_workout_skip_today", return_value=False, ), patch.object(locker, "_is_early_bird_time", return_value=True), @@ -574,7 +574,7 @@ class TestCheckTodayStateExits: 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", + "screen_locker.screen_lock.has_workout_skip_today", return_value=False, ), patch.object(locker, "_is_early_bird_time", return_value=False), diff --git a/screen_locker/workout-locker.service b/workout-locker.service similarity index 60% rename from screen_locker/workout-locker.service rename to workout-locker.service index 47e5437..04ceaa1 100644 --- a/screen_locker/workout-locker.service +++ b/workout-locker.service @@ -4,11 +4,11 @@ After=graphical-session.target [Service] Type=simple -WorkingDirectory=/home/kuhy/testsAndMisc +WorkingDirectory=/opt/screen-locker Environment=DISPLAY=:0 -Environment=PYTHONPATH=/home/kuhy/testsAndMisc +Environment=PYTHONPATH=/opt/screen-locker ExecStartPre=/bin/sleep 1 -ExecStart=/usr/bin/python3 -m python_pkg.screen_locker.screen_lock --production +ExecStart=/usr/bin/python3 -m screen_locker.screen_lock --production Restart=on-failure RestartSec=2s RestartPreventExitStatus=0