mirror of
https://github.com/kuhyx/wake-alarm.git
synced 2026-07-04 12:03:01 +02:00
Extract wake_alarm from testsAndMisc as a standalone repo
Rewrites python_pkg.wake_alarm imports to wake_alarm, vendors the shared configure_logging helper, drops the monorepo PYTHONPATH from install.sh and the systemd unit (package is now pip-installed), and untracks wake_state.json (runtime HMAC state, now gitignored). Scaffolds standalone lint/test config copied from the already-corrected diet_guard scaffold (pylint --fail-under=10 with tests excluded and the use-implicit-booleaness/consider-using-with disables, mypy's actual disabled-error-code set, ruff ALL, bandit, 100% branch coverage), plus the wave.Wave_write generated-members fix this package's _audio.py needs.
This commit is contained in:
parent
dd01b6f846
commit
407d7cbf8f
19
.github/workflows/pre-commit.yml
vendored
Normal file
19
.github/workflows/pre-commit.yml
vendored
Normal file
@ -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
|
||||
28
.github/workflows/python-tests.yml
vendored
Normal file
28
.github/workflows/python-tests.yml
vendored
Normal file
@ -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
|
||||
16
.gitignore
vendored
Normal file
16
.gitignore
vendored
Normal file
@ -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
|
||||
140
.pre-commit-config.yaml
Normal file
140
.pre-commit-config.yaml
Normal file
@ -0,0 +1,140 @@
|
||||
# Pre-commit Configuration for wake-alarm
|
||||
# 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=10
|
||||
- --jobs=4
|
||||
additional_dependencies:
|
||||
- pytest
|
||||
- python-kasa
|
||||
- gatelock @ git+https://github.com/kuhyx/gatelock@v0.1.0
|
||||
# Test suites intentionally use patterns (protected-access, magic-value
|
||||
# comparisons) that don't belong in the source-code 10/10 gate.
|
||||
exclude: ^(\.venv/)|(^|/)(tests/|conftest\.py)
|
||||
|
||||
- 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
|
||||
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: shellcheck
|
||||
name: shellcheck
|
||||
entry: bash -c 'printf "%s\0" "$@" | xargs -0 -n 40 shellcheck --severity=warning' --
|
||||
language: system
|
||||
types: [shell]
|
||||
- id: max-file-length
|
||||
name: Max file length (500 lines)
|
||||
entry: python3 scripts/check_file_length.py
|
||||
language: system
|
||||
types_or: [python, shell]
|
||||
111
CLAUDE.md
Normal file
111
CLAUDE.md
Normal file
@ -0,0 +1,111 @@
|
||||
# CLAUDE.md — wake_alarm
|
||||
|
||||
## What this does
|
||||
|
||||
A weekend wake alarm: on alarm days (`ALARM_DAYS` in `_constants.py` — Mon,
|
||||
Fri, Sat, Sun), the machine hibernates overnight and wakes itself via
|
||||
`rtcwake` at the configured alarm time. `wake-alarm.service` then opens a
|
||||
fullscreen Tk window that must be dismissed (with a typed challenge — see
|
||||
`_challenges.py`), ramps fans to 100% via `wake-alarm-fans.sh`, plays audio
|
||||
through whatever sink comes up after the monitor wakes, and optionally
|
||||
toggles a TP-Link Tapo P110 smart plug via `python-kasa`.
|
||||
|
||||
## Scheduling — hibernate-based, not a systemd timer
|
||||
|
||||
There is **no systemd timer** for this package. The wake mechanism is:
|
||||
|
||||
1. `shutdown-wrapper.sh`, installed to `/usr/local/bin/shutdown` (shadowing
|
||||
`/usr/bin/shutdown` via PATH order), intercepts shutdown/poweroff calls on
|
||||
alarm nights and calls `rtcwake -m disk` instead — hibernating with the
|
||||
RTC alarm set to wake the machine at `WAKE_AFTER_HOURS` (`_constants.py`)
|
||||
from now.
|
||||
2. `sleep-hook.sh`, installed to `/usr/lib/systemd/system-sleep/`, fires on
|
||||
resume (`$1 == post`) and starts `morning-routine.service` for every
|
||||
logged-in session. That orchestrator (lives in testsAndMisc, not here —
|
||||
see below) runs the alarm first, then the workout screen lock, so the two
|
||||
never fight for the fullscreen.
|
||||
3. `wake-alarm.service` itself is `Type=simple`, started either directly or
|
||||
by the orchestrator, and exits once the alarm is dismissed.
|
||||
|
||||
If you change `WAKE_AFTER_HOURS` in `_constants.py`, you must also update the
|
||||
duplicate constant in `shutdown-wrapper.sh` (`WAKE_AFTER_HOURS=8`) — they are
|
||||
not wired together, by design (the shell wrapper has no Python runtime
|
||||
available at the point it intercepts `shutdown`).
|
||||
|
||||
## Cross-repo coupling — not a bug
|
||||
|
||||
`_constants.py`'s `WORKOUT_LOG_FILE` points at
|
||||
`~/screen-locker/screen_locker/workout_log.json` — a file owned by the
|
||||
separate, already-standalone `screen-locker` repo
|
||||
(https://github.com/kuhyx/screen-locker). This is intentional: the alarm
|
||||
reads whether today's workout was already logged by screen-locker to decide
|
||||
whether the morning routine should also lock the workout screen. If this
|
||||
path ever raises `ModuleNotFoundError`-style confusion, the bug is almost
|
||||
certainly in the **orchestrator** (`morning_routine` in testsAndMisc), not
|
||||
here — see the next section.
|
||||
|
||||
## The morning_routine orchestrator lives elsewhere
|
||||
|
||||
`morning_routine._orchestrator` (in `testsAndMisc/python_pkg/morning_routine/`)
|
||||
runs this package and `screen_locker` as two sequential subprocesses. When
|
||||
either package is extracted to its own repo, **the orchestrator's module
|
||||
reference must be updated in the same change** — this exact mistake
|
||||
(orchestrator left pointing at `python_pkg.screen_locker.screen_lock` after
|
||||
screen-locker's extraction on 2026-05-28) caused a month-long silent
|
||||
production failure where the alarm fired and dismissed correctly but the
|
||||
workout lock crashed with `ModuleNotFoundError` on every run. Once both
|
||||
`wake_alarm` and `screen_locker` are pip-installed system-wide, the
|
||||
orchestrator needs no `PYTHONPATH`/`cwd` plumbing — plain
|
||||
`subprocess.run([sys.executable, "-m", module, "--production"])` resolves
|
||||
both.
|
||||
|
||||
## Production dependency installation — read this before adding any dependency
|
||||
|
||||
`wake-alarm.service` runs `/usr/bin/python` directly — **not** a venv. Any
|
||||
new non-stdlib dependency (this package itself, `gatelock`, `python-kasa`,
|
||||
anything added later) must be installed into system Python's *user*
|
||||
site-packages:
|
||||
|
||||
```bash
|
||||
/usr/bin/python3 -m pip install --user --break-system-packages -e .
|
||||
```
|
||||
|
||||
`install.sh` already does this. **If you add a dependency and only install it
|
||||
into a dev venv, the production service will silently fail with
|
||||
`ModuleNotFoundError` on its next run** — this exact gap caused a 3-day
|
||||
diet_guard production outage (2026-06-19 to 2026-06-22) for the sibling
|
||||
`gatelock` migration. Always verify against
|
||||
`/usr/bin/python3 -c "import <new_dep>"`, not just the dev venv, before
|
||||
considering a dependency change done.
|
||||
|
||||
## Operational gotchas
|
||||
|
||||
- **`python-kasa` is optional at runtime** (`_smart_plug.py` catches
|
||||
`ImportError` and disables smart-plug control with a warning log), but it
|
||||
*is* a hard dependency for this repo's own tooling (mypy/pylint/tests need
|
||||
it importable) — see `pyproject.toml`/`requirements.txt`.
|
||||
- **The `wave` module needs special pylint handling.** `_audio.py` opens WAV
|
||||
files in write mode; pylint's stdlib stub infers the read-mode overload and
|
||||
wrongly flags `setnchannels`/`setsampwidth`/`setframerate`/`writeframes` as
|
||||
missing. See the `generated-members` list in `pyproject.toml`'s
|
||||
`[tool.pylint.typecheck]` — don't remove it if pylint starts complaining
|
||||
about `wave.Wave_write`.
|
||||
- **`wake_state.json` is runtime state, not tracked.** It holds the
|
||||
HMAC-signed dismissal record for the current alarm day. It used to be
|
||||
accidentally committed in the monorepo; it is gitignored here and must
|
||||
stay that way.
|
||||
|
||||
## Commands
|
||||
|
||||
- Run tests: `python -m pytest wake_alarm/tests/ --cov=wake_alarm --cov-branch --cov-fail-under=100`
|
||||
- Lint: `pre-commit run --all-files`
|
||||
- Test the lock manually (safe, closeable): `python -m wake_alarm._alarm --demo`
|
||||
- Install for production: `bash install.sh`
|
||||
|
||||
## Do NOT
|
||||
|
||||
- Don't add a dependency without doing the production install-path check
|
||||
above.
|
||||
- Don't forget the orchestrator when changing this package's module path or
|
||||
invocation — see "The morning_routine orchestrator lives elsewhere" above.
|
||||
- Don't commit `wake_state.json`.
|
||||
39
README.md
Normal file
39
README.md
Normal file
@ -0,0 +1,39 @@
|
||||
# wake_alarm
|
||||
|
||||
A hibernate-scheduled weekend wake alarm: the machine hibernates overnight
|
||||
on alarm days and wakes itself via `rtcwake`, then shows a fullscreen,
|
||||
challenge-dismissed alarm with fan ramp and optional TP-Link Tapo P110
|
||||
smart-plug control.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bash install.sh
|
||||
```
|
||||
|
||||
This installs the package + dependencies into system Python's user
|
||||
site-packages (the systemd service runs `/usr/bin/python` directly, not a
|
||||
venv — see `CLAUDE.md`), installs the systemd user service, the
|
||||
systemd-sleep resume hook, the `shutdown` wrapper that triggers hibernate on
|
||||
alarm nights, the fan-ramp script, and (optionally) `python-kasa` for
|
||||
smart-plug control.
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
python -m wake_alarm._alarm --demo # test the alarm window (safe, closeable)
|
||||
```
|
||||
|
||||
The alarm fires automatically via the hibernate/wake cycle once installed;
|
||||
no manual invocation is needed in normal operation.
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
python -m venv .venv && .venv/bin/pip install -r requirements.txt
|
||||
.venv/bin/pre-commit install && .venv/bin/pre-commit install --hook-type pre-push
|
||||
.venv/bin/python -m pytest wake_alarm/tests/ --cov=wake_alarm --cov-branch --cov-fail-under=100
|
||||
```
|
||||
|
||||
See `CLAUDE.md` for scheduling details, the hibernate/`rtcwake` mechanism,
|
||||
the cross-repo `workout_log.json` read, and production deployment gotchas.
|
||||
@ -4,17 +4,22 @@
|
||||
# Usage: bash install.sh
|
||||
#
|
||||
# What it does:
|
||||
# 1. Copies wake-alarm.service to ~/.config/systemd/user/
|
||||
# 2. Enables and starts the service
|
||||
# 3. Installs the systemd-sleep hook (restarts alarm after hibernate resume)
|
||||
# 4. Adds a sudoers entry for passwordless rtcwake
|
||||
# 5. Installs shutdown wrapper so "shutdown now" also hibernates on alarm nights
|
||||
# 6. Installs fan-control script so alarm can max fans on wake
|
||||
# 7. Installs python-kasa (AUR) so the alarm can toggle a Tapo P110 smart plug
|
||||
# 1. Installs wake_alarm + dependencies for /usr/bin/python
|
||||
# 2. Installs system dependencies (alsa-utils, ddcutil)
|
||||
# 3. Copies wake-alarm.service to ~/.config/systemd/user/ and enables it
|
||||
# 4. Installs the systemd-sleep hook (restarts alarm after hibernate resume)
|
||||
# 5. Adds a sudoers entry for passwordless rtcwake
|
||||
# 6. Installs shutdown wrapper so "shutdown now" also hibernates on alarm nights
|
||||
# 7. Installs fan-control script so alarm can max fans on wake
|
||||
# 8. Installs python-kasa (AUR) so the alarm can toggle a Tapo P110 smart plug
|
||||
# 9. Installs ddcutil and grants /dev/i2c-* access for DDC/CI monitor control
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Split declare/assign so the command-substitution exit code is not masked (SC2155).
|
||||
SCRIPT_DIR="$(dirname "$(readlink -f "$0")")"
|
||||
readonly SCRIPT_DIR
|
||||
readonly REPO_DIR="$SCRIPT_DIR"
|
||||
SERVICE_FILE="$SCRIPT_DIR/wake-alarm.service"
|
||||
SLEEP_HOOK_SRC="$SCRIPT_DIR/sleep-hook.sh"
|
||||
SHUTDOWN_WRAPPER_SRC="$SCRIPT_DIR/shutdown-wrapper.sh"
|
||||
@ -28,8 +33,15 @@ RTCWAKE_BIN="/usr/sbin/rtcwake"
|
||||
|
||||
echo "=== Weekend Wake Alarm Installer ==="
|
||||
|
||||
# 0. Install system dependencies
|
||||
echo "[0/7] Checking system dependencies..."
|
||||
# 1. Install this package + its dependencies into system Python -------------
|
||||
echo "[1/9] Installing wake_alarm + dependencies for /usr/bin/python..."
|
||||
/usr/bin/python3 -m pip install --user --break-system-packages -e "$REPO_DIR"
|
||||
echo " Installed. Verifying import..."
|
||||
/usr/bin/python3 -c "import wake_alarm; import gatelock" \
|
||||
&& echo " wake_alarm and gatelock import cleanly from the system interpreter."
|
||||
|
||||
# 2. Install system dependencies
|
||||
echo "[2/9] Checking system dependencies..."
|
||||
if ! command -v speaker-test &>/dev/null; then
|
||||
echo " Installing alsa-utils (required for speaker-test)..."
|
||||
sudo pacman -S --noconfirm alsa-utils
|
||||
@ -37,26 +49,23 @@ else
|
||||
echo " alsa-utils already installed"
|
||||
fi
|
||||
|
||||
# 1. Install systemd user service
|
||||
echo "[1/7] Installing systemd user service..."
|
||||
# 3. Install systemd user service
|
||||
echo "[3/9] Installing systemd user service..."
|
||||
mkdir -p "$SYSTEMD_USER_DIR"
|
||||
cp "$SERVICE_FILE" "$SYSTEMD_USER_DIR/wake-alarm.service"
|
||||
systemctl --user daemon-reload
|
||||
echo " Installed to $SYSTEMD_USER_DIR/wake-alarm.service"
|
||||
|
||||
# 2. Enable service
|
||||
echo "[2/7] Enabling wake-alarm.service..."
|
||||
systemctl --user enable wake-alarm.service
|
||||
echo " Service enabled (will start on next boot)"
|
||||
|
||||
# 3. Install systemd-sleep hook (restarts alarm after hibernate resume)
|
||||
echo "[3/7] Installing systemd-sleep hook..."
|
||||
# 4. Install systemd-sleep hook (restarts alarm after hibernate resume)
|
||||
echo "[4/9] Installing systemd-sleep hook..."
|
||||
sudo cp "$SLEEP_HOOK_SRC" "$SLEEP_HOOK_DST"
|
||||
sudo chmod 0755 "$SLEEP_HOOK_DST"
|
||||
echo " Installed to $SLEEP_HOOK_DST"
|
||||
|
||||
# 4. Add sudoers entry for rtcwake (requires root)
|
||||
echo "[4/7] Setting up sudoers for rtcwake..."
|
||||
# 5. Add sudoers entry for rtcwake (requires root)
|
||||
echo "[5/9] Setting up sudoers for rtcwake..."
|
||||
SUDOERS_LINE="$USER ALL=(root) NOPASSWD: $RTCWAKE_BIN"
|
||||
if [[ -f "$SUDOERS_FILE" ]] && grep -qF "$SUDOERS_LINE" "$SUDOERS_FILE"; then
|
||||
echo " Sudoers entry already exists"
|
||||
@ -67,15 +76,15 @@ else
|
||||
echo " Added: $SUDOERS_LINE"
|
||||
fi
|
||||
|
||||
# 5. Install shutdown wrapper (/usr/local/bin/shutdown shadows /usr/bin/shutdown)
|
||||
echo "[5/7] Installing shutdown wrapper..."
|
||||
# 6. Install shutdown wrapper (/usr/local/bin/shutdown shadows /usr/bin/shutdown)
|
||||
echo "[6/9] Installing shutdown wrapper..."
|
||||
sudo cp "$SHUTDOWN_WRAPPER_SRC" "$SHUTDOWN_WRAPPER_DST"
|
||||
sudo chmod 0755 "$SHUTDOWN_WRAPPER_DST"
|
||||
echo " Installed to $SHUTDOWN_WRAPPER_DST"
|
||||
echo " 'shutdown now' will now hibernate (not poweroff) on alarm nights."
|
||||
|
||||
# 6. Install fan-control script and its sudoers entry
|
||||
echo "[6/7] Installing fan-control script..."
|
||||
# 7. Install fan-control script and its sudoers entry
|
||||
echo "[7/9] Installing fan-control script..."
|
||||
sudo cp "$FANS_SCRIPT_SRC" "$FANS_SCRIPT_DST"
|
||||
sudo chmod 0755 "$FANS_SCRIPT_DST"
|
||||
FANS_SUDOERS_LINE="$USER ALL=(root) NOPASSWD: $FANS_SCRIPT_DST"
|
||||
@ -88,8 +97,8 @@ else
|
||||
echo " Added fan sudoers entry"
|
||||
fi
|
||||
|
||||
# 7. Install python-kasa (AUR) for TP-Link Tapo P110 smart-plug control
|
||||
echo "[7/8] Installing python-kasa (AUR)..."
|
||||
# 8. Install python-kasa (AUR) for TP-Link Tapo P110 smart-plug control
|
||||
echo "[8/9] Installing python-kasa (AUR)..."
|
||||
if python -c 'import kasa' 2>/dev/null; then
|
||||
echo " python-kasa already installed"
|
||||
elif command -v yay &>/dev/null; then
|
||||
@ -102,10 +111,10 @@ if [[ ! -f "$HOME/.config/wake_alarm/tapo.json" ]]; then
|
||||
echo " Create it (mode 0600) with keys: host, email, password."
|
||||
fi
|
||||
|
||||
# 8. Install ddcutil for DDC/CI monitor power control
|
||||
# 9. Install ddcutil for DDC/CI monitor power control
|
||||
# ddcutil lets the alarm force the G27Q on via DDC/CI even when the monitor
|
||||
# was physically powered off (power button), bypassing DPMS limitations.
|
||||
echo "[8/8] Installing ddcutil (DDC/CI monitor power control)..."
|
||||
echo "[9/9] Installing ddcutil (DDC/CI monitor power control)..."
|
||||
if command -v ddcutil &>/dev/null; then
|
||||
echo " ddcutil already installed"
|
||||
else
|
||||
@ -128,4 +137,4 @@ echo "=== Installation complete ==="
|
||||
echo "The wake alarm will activate on boot for alarm days (Mon, Fri, Sat, Sun)."
|
||||
echo "After hibernate resume the sleep hook will restart the alarm service."
|
||||
echo "Fans will ramp to 100% while the alarm is active, then restore automatically."
|
||||
echo "To test now: python -m python_pkg.wake_alarm._alarm --demo"
|
||||
echo "To test now: python -m wake_alarm._alarm --demo"
|
||||
181
pyproject.toml
Normal file
181
pyproject.toml
Normal file
@ -0,0 +1,181 @@
|
||||
[project]
|
||||
name = "wake-alarm"
|
||||
version = "1.0.0"
|
||||
description = "Hibernate-scheduled weekend wake alarm with fan ramp and smart-plug control"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"gatelock @ git+https://github.com/kuhyx/gatelock@v0.1.0",
|
||||
"python-kasa>=0.7",
|
||||
]
|
||||
|
||||
[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 = ["wake_alarm"]
|
||||
|
||||
[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"
|
||||
# "tests" is a basename match: test suites intentionally use patterns
|
||||
# (protected-access, magic-value comparisons) that don't apply to source
|
||||
# code, matching testsAndMisc's pylint scope (tests are linted by ruff, not
|
||||
# pylint there either).
|
||||
ignore = [".venv", "__pycache__", "tests"]
|
||||
ignore-patterns = [".*\\.pyi$"]
|
||||
|
||||
[tool.pylint.messages_control]
|
||||
# Enable all checks by disabling disable
|
||||
enable = "all"
|
||||
# Globally disabled checks. Each is either a stylistic preference that conflicts
|
||||
# with deliberate, clearer code, or a structural false positive that cannot be
|
||||
# rewritten without harming readability. Everything else stays at max strictness.
|
||||
disable = [
|
||||
# use-implicit-booleaness family (C1803/C1804/C1805): pylint wants
|
||||
# `not x` / `not s` instead of `x == 0` / `s == ""`. Explicit comparisons
|
||||
# against 0 and "" state numeric/string intent more clearly than truthiness
|
||||
# (and are not equivalent when the value may be None), so we keep them.
|
||||
"use-implicit-booleaness-not-comparison",
|
||||
"use-implicit-booleaness-not-comparison-to-string",
|
||||
"use-implicit-booleaness-not-comparison-to-zero",
|
||||
# consider-using-with (R1732): several subprocess.Popen calls are
|
||||
# intentionally fire-and-forget — the process must outlive the calling
|
||||
# scope and is polled/killed later, so a `with` block is wrong here.
|
||||
"consider-using-with",
|
||||
]
|
||||
|
||||
[tool.pylint.design]
|
||||
min-public-methods = 0
|
||||
max-module-lines = 1000
|
||||
max-attributes = 10
|
||||
|
||||
[tool.pylint.typecheck]
|
||||
# unittest.mock.MagicMock generates assertion/introspection methods at runtime.
|
||||
# wave.open(path, "wb") returns a Wave_write, but pylint's stdlib stub infers the
|
||||
# read-mode Wave_read overload and wrongly reports its setter/writer methods as
|
||||
# missing — list them so the write API is recognised.
|
||||
generated-members = [
|
||||
".*\\.assert_called_once_with",
|
||||
".*\\.assert_called_once",
|
||||
".*\\.assert_called",
|
||||
".*\\.assert_not_called",
|
||||
".*\\.assert_any_call",
|
||||
".*\\.call_args",
|
||||
".*\\.call_args_list",
|
||||
".*\\.call_count",
|
||||
".*\\.setnchannels",
|
||||
".*\\.setsampwidth",
|
||||
".*\\.setframerate",
|
||||
".*\\.writeframes",
|
||||
".*\\.writeframesraw",
|
||||
]
|
||||
|
||||
[tool.bandit]
|
||||
exclude_dirs = ["tests", ".venv"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["wake_alarm/tests"]
|
||||
python_files = ["test_*.py", "*_test.py"]
|
||||
python_classes = ["Test*"]
|
||||
python_functions = ["test_*"]
|
||||
addopts = [
|
||||
"-v",
|
||||
"--strict-markers",
|
||||
"--strict-config",
|
||||
"-ra",
|
||||
"--cov=wake_alarm",
|
||||
"--cov-branch",
|
||||
"--cov-report=term-missing",
|
||||
"--cov-report=lcov",
|
||||
]
|
||||
filterwarnings = [
|
||||
"error",
|
||||
"ignore::DeprecationWarning",
|
||||
"default::pytest.PytestUnraisableExceptionWarning",
|
||||
]
|
||||
|
||||
[tool.coverage.run]
|
||||
source = ["wake_alarm"]
|
||||
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"]
|
||||
18
requirements.txt
Normal file
18
requirements.txt
Normal file
@ -0,0 +1,18 @@
|
||||
# Wake Alarm — runtime + development dependencies
|
||||
# Runtime: tkinter/json/hmac/wave/subprocess (stdlib) plus gatelock and
|
||||
# python-kasa below (python-kasa is optional at runtime; the module degrades
|
||||
# gracefully if it's missing, but is required here for mypy/pylint/tests).
|
||||
bandit>=1.7.0
|
||||
codespell>=2.2.0
|
||||
coverage>=7.4.0
|
||||
gatelock @ git+https://github.com/kuhyx/gatelock@v0.1.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
|
||||
python-kasa>=0.7
|
||||
ruff>=0.8.0
|
||||
26
scripts/check_file_length.py
Executable file
26
scripts/check_file_length.py
Executable file
@ -0,0 +1,26 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Pre-commit hook: fail if any file exceeds MAX_LINES lines."""
|
||||
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
MAX_LINES = 500
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""Return 1 if any file exceeds the line limit, else 0."""
|
||||
failed = False
|
||||
for filepath in sys.argv[1:]:
|
||||
try:
|
||||
with Path(filepath).open(encoding="utf-8", errors="replace") as fh:
|
||||
count = sum(1 for _ in fh)
|
||||
except OSError:
|
||||
failed = True
|
||||
continue
|
||||
if count > MAX_LINES:
|
||||
failed = True
|
||||
return 1 if failed else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@ -11,7 +11,7 @@ set -euo pipefail
|
||||
|
||||
REAL_SHUTDOWN=/usr/bin/shutdown
|
||||
RTCWAKE=/usr/sbin/rtcwake
|
||||
WAKE_AFTER_HOURS=8 # Must match WAKE_AFTER_HOURS in python_pkg/wake_alarm/_constants.py
|
||||
WAKE_AFTER_HOURS=8 # Must match WAKE_AFTER_HOURS in wake_alarm/_constants.py
|
||||
|
||||
# Pass through reboots and cancel commands unchanged.
|
||||
for arg in "$@"; do
|
||||
20
wake-alarm.service
Normal file
20
wake-alarm.service
Normal file
@ -0,0 +1,20 @@
|
||||
[Unit]
|
||||
Description=Weekend Wake Alarm
|
||||
After=graphical-session.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
# DISPLAY mirrors workout-locker.service: without DISPLAY the alarm crashes
|
||||
# on cold boot with "no display name and no $DISPLAY" (Tk can't open the X
|
||||
# server) before systemd retries. The short sleep lets the X session export
|
||||
# DISPLAY into the user environment first.
|
||||
# No PYTHONPATH needed: wake_alarm is pip-installed (see install.sh /
|
||||
# README), so /usr/bin/python finds it via user site-packages.
|
||||
Environment=DISPLAY=:0
|
||||
ExecStartPre=/bin/sleep 1
|
||||
ExecStart=/usr/bin/python -m wake_alarm._alarm --production
|
||||
Restart=on-failure
|
||||
RestartSec=10
|
||||
|
||||
[Install]
|
||||
WantedBy=graphical-session.target
|
||||
@ -20,9 +20,8 @@ import tkinter as tk
|
||||
|
||||
from gatelock import GateRoot, LockConfig, LockWindow
|
||||
|
||||
from python_pkg.shared.logging_setup import configure_logging
|
||||
from python_pkg.wake_alarm._alarm_display import _restore_display, _wake_display
|
||||
from python_pkg.wake_alarm._audio import (
|
||||
from wake_alarm._alarm_display import _restore_display, _wake_display
|
||||
from wake_alarm._audio import (
|
||||
_activate_alarm_audio,
|
||||
_beep_loud,
|
||||
_beep_medium,
|
||||
@ -34,11 +33,11 @@ from python_pkg.wake_alarm._audio import (
|
||||
_set_max_brightness,
|
||||
_warn_if_no_real_sink,
|
||||
)
|
||||
from python_pkg.wake_alarm._challenges import (
|
||||
from wake_alarm._challenges import (
|
||||
_Challenge,
|
||||
_make_challenge,
|
||||
)
|
||||
from python_pkg.wake_alarm._constants import (
|
||||
from wake_alarm._constants import (
|
||||
ALARM_DAYS,
|
||||
DISMISS_CODE_REFRESH_SECONDS,
|
||||
DISMISS_FLASH_SECONDS,
|
||||
@ -51,8 +50,9 @@ from python_pkg.wake_alarm._constants import (
|
||||
PHASE_SOFT_END,
|
||||
SOFT_BEEP_INTERVAL,
|
||||
)
|
||||
from python_pkg.wake_alarm._smart_plug import turn_off_plug, turn_on_plug
|
||||
from python_pkg.wake_alarm._state import (
|
||||
from wake_alarm._logging_setup import configure_logging
|
||||
from wake_alarm._smart_plug import turn_off_plug, turn_on_plug
|
||||
from wake_alarm._state import (
|
||||
save_wake_state,
|
||||
was_alarm_dismissed_today,
|
||||
was_workout_logged_today,
|
||||
|
||||
@ -14,7 +14,7 @@ import tempfile
|
||||
import time
|
||||
import wave
|
||||
|
||||
from python_pkg.wake_alarm._constants import (
|
||||
from wake_alarm._constants import (
|
||||
ALARM_AUDIO_CARD,
|
||||
ALARM_AUDIO_PROFILE,
|
||||
ALARM_AUDIO_SINK,
|
||||
|
||||
@ -10,7 +10,7 @@ from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
|
||||
from python_pkg.wake_alarm._constants import DISMISS_CODE_LENGTH, DISMISS_FLASH_SECONDS
|
||||
from wake_alarm._constants import DISMISS_CODE_LENGTH, DISMISS_FLASH_SECONDS
|
||||
|
||||
# Uppercase alphanumeric chars with visually ambiguous characters removed:
|
||||
# O/0 (oh vs zero) and I/1 (capital-i vs one) are excluded so the code is
|
||||
|
||||
13
wake_alarm/_logging_setup.py
Normal file
13
wake_alarm/_logging_setup.py
Normal file
@ -0,0 +1,13 @@
|
||||
"""Logging configuration for the wake_alarm entry point."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
|
||||
def configure_logging() -> None:
|
||||
"""Configure root logging with the standard daemon format and level."""
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s %(name)s %(levelname)s %(message)s",
|
||||
)
|
||||
@ -22,7 +22,7 @@ import json
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from python_pkg.wake_alarm._constants import (
|
||||
from wake_alarm._constants import (
|
||||
TAPO_CONFIG_FILE,
|
||||
TAPO_TIMEOUT_SECONDS,
|
||||
)
|
||||
|
||||
@ -11,7 +11,7 @@ from gatelock.log_integrity import (
|
||||
verify_entry_hmac,
|
||||
)
|
||||
|
||||
from python_pkg.wake_alarm._constants import WAKE_STATE_FILE, WORKOUT_LOG_FILE
|
||||
from wake_alarm._constants import WAKE_STATE_FILE, WORKOUT_LOG_FILE
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@ -13,11 +13,11 @@ import pytest
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Generator
|
||||
|
||||
from python_pkg.wake_alarm._alarm import (
|
||||
from wake_alarm._alarm import (
|
||||
_is_alarm_day,
|
||||
_should_run_alarm,
|
||||
)
|
||||
from python_pkg.wake_alarm._audio import (
|
||||
from wake_alarm._audio import (
|
||||
_beep_loud,
|
||||
_beep_medium,
|
||||
_beep_soft,
|
||||
@ -27,11 +27,11 @@ from python_pkg.wake_alarm._audio import (
|
||||
_restore_fans,
|
||||
_speaker_test_path,
|
||||
)
|
||||
from python_pkg.wake_alarm._challenges import (
|
||||
from wake_alarm._challenges import (
|
||||
_DISMISS_CHARS,
|
||||
_generate_code,
|
||||
)
|
||||
from python_pkg.wake_alarm._constants import (
|
||||
from wake_alarm._constants import (
|
||||
DISMISS_CODE_LENGTH,
|
||||
)
|
||||
|
||||
@ -60,9 +60,9 @@ def _block_real_tk() -> Generator[MagicMock]:
|
||||
"""Prevent any real Tk windows in tests."""
|
||||
mock = _make_mock_tk()
|
||||
with (
|
||||
patch("python_pkg.wake_alarm._alarm.tk", mock),
|
||||
patch("wake_alarm._alarm.tk", mock),
|
||||
patch(
|
||||
"python_pkg.wake_alarm._alarm.GateRoot",
|
||||
"wake_alarm._alarm.GateRoot",
|
||||
return_value=mock.Tk.return_value,
|
||||
),
|
||||
):
|
||||
@ -74,9 +74,9 @@ def mock_tk_module() -> Generator[MagicMock]:
|
||||
"""Provide explicit access to the mocked tk module."""
|
||||
mock = _make_mock_tk()
|
||||
with (
|
||||
patch("python_pkg.wake_alarm._alarm.tk", mock),
|
||||
patch("wake_alarm._alarm.tk", mock),
|
||||
patch(
|
||||
"python_pkg.wake_alarm._alarm.GateRoot",
|
||||
"wake_alarm._alarm.GateRoot",
|
||||
return_value=mock.Tk.return_value,
|
||||
),
|
||||
):
|
||||
@ -117,7 +117,7 @@ class TestIsAlarmDay:
|
||||
|
||||
# Create a date that is Monday
|
||||
with patch(
|
||||
"python_pkg.wake_alarm._alarm.datetime",
|
||||
"wake_alarm._alarm.datetime",
|
||||
) as mock_dt:
|
||||
mock_now = MagicMock()
|
||||
mock_now.weekday.return_value = 0 # Monday
|
||||
@ -128,7 +128,7 @@ class TestIsAlarmDay:
|
||||
def test_tuesday_is_not_alarm_day(self) -> None:
|
||||
"""Tuesday (weekday=1) is NOT an alarm day."""
|
||||
with patch(
|
||||
"python_pkg.wake_alarm._alarm.datetime",
|
||||
"wake_alarm._alarm.datetime",
|
||||
) as mock_dt:
|
||||
mock_now = MagicMock()
|
||||
mock_now.weekday.return_value = 1 # Tuesday
|
||||
@ -138,7 +138,7 @@ class TestIsAlarmDay:
|
||||
def test_friday_is_alarm_day(self) -> None:
|
||||
"""Friday (weekday=4) is an alarm day."""
|
||||
with patch(
|
||||
"python_pkg.wake_alarm._alarm.datetime",
|
||||
"wake_alarm._alarm.datetime",
|
||||
) as mock_dt:
|
||||
mock_now = MagicMock()
|
||||
mock_now.weekday.return_value = 4 # Friday
|
||||
@ -148,7 +148,7 @@ class TestIsAlarmDay:
|
||||
def test_saturday_is_alarm_day(self) -> None:
|
||||
"""Saturday (weekday=5) is an alarm day."""
|
||||
with patch(
|
||||
"python_pkg.wake_alarm._alarm.datetime",
|
||||
"wake_alarm._alarm.datetime",
|
||||
) as mock_dt:
|
||||
mock_now = MagicMock()
|
||||
mock_now.weekday.return_value = 5
|
||||
@ -158,7 +158,7 @@ class TestIsAlarmDay:
|
||||
def test_sunday_is_alarm_day(self) -> None:
|
||||
"""Sunday (weekday=6) is an alarm day."""
|
||||
with patch(
|
||||
"python_pkg.wake_alarm._alarm.datetime",
|
||||
"wake_alarm._alarm.datetime",
|
||||
) as mock_dt:
|
||||
mock_now = MagicMock()
|
||||
mock_now.weekday.return_value = 6
|
||||
@ -168,7 +168,7 @@ class TestIsAlarmDay:
|
||||
def test_wednesday_is_not_alarm_day(self) -> None:
|
||||
"""Wednesday (weekday=2) is NOT an alarm day."""
|
||||
with patch(
|
||||
"python_pkg.wake_alarm._alarm.datetime",
|
||||
"wake_alarm._alarm.datetime",
|
||||
) as mock_dt:
|
||||
mock_now = MagicMock()
|
||||
mock_now.weekday.return_value = 2
|
||||
@ -182,7 +182,7 @@ class TestSpeakerTestPath:
|
||||
def test_returns_path_when_found(self) -> None:
|
||||
"""Return full path when speaker-test is available."""
|
||||
with patch(
|
||||
"python_pkg.wake_alarm._audio.shutil.which",
|
||||
"wake_alarm._audio.shutil.which",
|
||||
return_value="/usr/bin/speaker-test",
|
||||
):
|
||||
assert _speaker_test_path() == "/usr/bin/speaker-test"
|
||||
@ -191,7 +191,7 @@ class TestSpeakerTestPath:
|
||||
"""Raise FileNotFoundError when speaker-test is missing."""
|
||||
with (
|
||||
patch(
|
||||
"python_pkg.wake_alarm._audio.shutil.which",
|
||||
"wake_alarm._audio.shutil.which",
|
||||
return_value=None,
|
||||
),
|
||||
pytest.raises(FileNotFoundError, match="speaker-test not found"),
|
||||
@ -204,7 +204,7 @@ class TestBeepFunctions:
|
||||
|
||||
def test_beep_soft_writes_bell(self) -> None:
|
||||
"""_beep_soft writes terminal bell character."""
|
||||
with patch("python_pkg.wake_alarm._audio.sys") as mock_sys:
|
||||
with patch("wake_alarm._audio.sys") as mock_sys:
|
||||
mock_sys.stdout = MagicMock()
|
||||
_beep_soft()
|
||||
mock_sys.stdout.write.assert_called_once_with("\a")
|
||||
@ -212,13 +212,13 @@ class TestBeepFunctions:
|
||||
|
||||
def test_beep_medium_delegates_to_play_tone(self) -> None:
|
||||
"""_beep_medium just delegates to _play_tone."""
|
||||
with patch("python_pkg.wake_alarm._audio._play_tone") as mock_play:
|
||||
with patch("wake_alarm._audio._play_tone") as mock_play:
|
||||
_beep_medium(frequency=800)
|
||||
mock_play.assert_called_once_with(800)
|
||||
|
||||
def test_beep_loud_delegates_to_play_tone(self) -> None:
|
||||
"""_beep_loud just delegates to _play_tone."""
|
||||
with patch("python_pkg.wake_alarm._audio._play_tone") as mock_play:
|
||||
with patch("wake_alarm._audio._play_tone") as mock_play:
|
||||
_beep_loud(frequency=1200)
|
||||
mock_play.assert_called_once_with(1200)
|
||||
|
||||
@ -229,7 +229,7 @@ class TestShouldRunAlarm:
|
||||
def test_returns_false_on_non_alarm_day(self) -> None:
|
||||
"""Return False when today is not an alarm day."""
|
||||
with patch(
|
||||
"python_pkg.wake_alarm._alarm._is_alarm_day",
|
||||
"wake_alarm._alarm._is_alarm_day",
|
||||
return_value=False,
|
||||
):
|
||||
assert _should_run_alarm() is False
|
||||
@ -238,11 +238,11 @@ class TestShouldRunAlarm:
|
||||
"""Return False when alarm was already dismissed today."""
|
||||
with (
|
||||
patch(
|
||||
"python_pkg.wake_alarm._alarm._is_alarm_day",
|
||||
"wake_alarm._alarm._is_alarm_day",
|
||||
return_value=True,
|
||||
),
|
||||
patch(
|
||||
"python_pkg.wake_alarm._alarm.was_alarm_dismissed_today",
|
||||
"wake_alarm._alarm.was_alarm_dismissed_today",
|
||||
return_value=True,
|
||||
),
|
||||
):
|
||||
@ -252,15 +252,15 @@ class TestShouldRunAlarm:
|
||||
"""Return True when today is alarm day and not yet dismissed."""
|
||||
with (
|
||||
patch(
|
||||
"python_pkg.wake_alarm._alarm._is_alarm_day",
|
||||
"wake_alarm._alarm._is_alarm_day",
|
||||
return_value=True,
|
||||
),
|
||||
patch(
|
||||
"python_pkg.wake_alarm._alarm.was_alarm_dismissed_today",
|
||||
"wake_alarm._alarm.was_alarm_dismissed_today",
|
||||
return_value=False,
|
||||
),
|
||||
patch(
|
||||
"python_pkg.wake_alarm._alarm.was_workout_logged_today",
|
||||
"wake_alarm._alarm.was_workout_logged_today",
|
||||
return_value=False,
|
||||
),
|
||||
):
|
||||
@ -270,15 +270,15 @@ class TestShouldRunAlarm:
|
||||
"""Return False when workout was already logged today."""
|
||||
with (
|
||||
patch(
|
||||
"python_pkg.wake_alarm._alarm._is_alarm_day",
|
||||
"wake_alarm._alarm._is_alarm_day",
|
||||
return_value=True,
|
||||
),
|
||||
patch(
|
||||
"python_pkg.wake_alarm._alarm.was_alarm_dismissed_today",
|
||||
"wake_alarm._alarm.was_alarm_dismissed_today",
|
||||
return_value=False,
|
||||
),
|
||||
patch(
|
||||
"python_pkg.wake_alarm._alarm.was_workout_logged_today",
|
||||
"wake_alarm._alarm.was_workout_logged_today",
|
||||
return_value=True,
|
||||
),
|
||||
):
|
||||
@ -292,10 +292,10 @@ class TestPlayOnExtraDevices:
|
||||
"""_play_on_extra_devices spawns speaker-test with PIPEWIRE_NODE set."""
|
||||
with (
|
||||
patch(
|
||||
"python_pkg.wake_alarm._audio._speaker_test_path",
|
||||
"wake_alarm._audio._speaker_test_path",
|
||||
return_value="/usr/bin/speaker-test",
|
||||
),
|
||||
patch("python_pkg.wake_alarm._audio.subprocess.Popen") as mock_popen,
|
||||
patch("wake_alarm._audio.subprocess.Popen") as mock_popen,
|
||||
):
|
||||
_play_on_extra_devices(1000)
|
||||
mock_popen.assert_called_once()
|
||||
@ -311,10 +311,10 @@ class TestPlayOnExtraDevices:
|
||||
"""_play_on_extra_devices does nothing when speaker-test is absent."""
|
||||
with (
|
||||
patch(
|
||||
"python_pkg.wake_alarm._audio._speaker_test_path",
|
||||
"wake_alarm._audio._speaker_test_path",
|
||||
side_effect=FileNotFoundError("not found"),
|
||||
),
|
||||
patch("python_pkg.wake_alarm._audio.subprocess.Popen") as mock_popen,
|
||||
patch("wake_alarm._audio.subprocess.Popen") as mock_popen,
|
||||
):
|
||||
_play_on_extra_devices(1000)
|
||||
mock_popen.assert_not_called()
|
||||
@ -323,11 +323,11 @@ class TestPlayOnExtraDevices:
|
||||
"""_play_on_extra_devices silently ignores OSError from Popen."""
|
||||
with (
|
||||
patch(
|
||||
"python_pkg.wake_alarm._audio._speaker_test_path",
|
||||
"wake_alarm._audio._speaker_test_path",
|
||||
return_value="/usr/bin/speaker-test",
|
||||
),
|
||||
patch(
|
||||
"python_pkg.wake_alarm._audio.subprocess.Popen",
|
||||
"wake_alarm._audio.subprocess.Popen",
|
||||
side_effect=OSError("device busy"),
|
||||
),
|
||||
):
|
||||
@ -374,18 +374,18 @@ class TestMaxFans:
|
||||
|
||||
def test_returns_false_when_no_hwmon(self) -> None:
|
||||
"""No fan controller → returns False immediately."""
|
||||
with patch("python_pkg.wake_alarm._audio._find_fan_hwmon", return_value=None):
|
||||
with patch("wake_alarm._audio._find_fan_hwmon", return_value=None):
|
||||
assert _max_fans() is False
|
||||
|
||||
def test_returns_false_on_script_oserror(self, tmp_path: pathlib.Path) -> None:
|
||||
"""OSError running fan script → returns False."""
|
||||
with (
|
||||
patch(
|
||||
"python_pkg.wake_alarm._audio._find_fan_hwmon",
|
||||
"wake_alarm._audio._find_fan_hwmon",
|
||||
return_value=str(tmp_path),
|
||||
),
|
||||
patch(
|
||||
"python_pkg.wake_alarm._audio.subprocess.run",
|
||||
"wake_alarm._audio.subprocess.run",
|
||||
side_effect=OSError("not found"),
|
||||
),
|
||||
):
|
||||
@ -395,11 +395,11 @@ class TestMaxFans:
|
||||
"""TimeoutExpired running fan script → returns False."""
|
||||
with (
|
||||
patch(
|
||||
"python_pkg.wake_alarm._audio._find_fan_hwmon",
|
||||
"wake_alarm._audio._find_fan_hwmon",
|
||||
return_value=str(tmp_path),
|
||||
),
|
||||
patch(
|
||||
"python_pkg.wake_alarm._audio.subprocess.run",
|
||||
"wake_alarm._audio.subprocess.run",
|
||||
side_effect=subprocess.TimeoutExpired("fan", 5),
|
||||
),
|
||||
):
|
||||
@ -411,11 +411,11 @@ class TestMaxFans:
|
||||
mock_result.returncode = 1
|
||||
with (
|
||||
patch(
|
||||
"python_pkg.wake_alarm._audio._find_fan_hwmon",
|
||||
"wake_alarm._audio._find_fan_hwmon",
|
||||
return_value=str(tmp_path),
|
||||
),
|
||||
patch(
|
||||
"python_pkg.wake_alarm._audio.subprocess.run",
|
||||
"wake_alarm._audio.subprocess.run",
|
||||
return_value=mock_result,
|
||||
),
|
||||
):
|
||||
@ -427,11 +427,11 @@ class TestMaxFans:
|
||||
mock_result.returncode = 0
|
||||
with (
|
||||
patch(
|
||||
"python_pkg.wake_alarm._audio._find_fan_hwmon",
|
||||
"wake_alarm._audio._find_fan_hwmon",
|
||||
return_value=str(tmp_path),
|
||||
),
|
||||
patch(
|
||||
"python_pkg.wake_alarm._audio.subprocess.run",
|
||||
"wake_alarm._audio.subprocess.run",
|
||||
return_value=mock_result,
|
||||
),
|
||||
):
|
||||
@ -443,13 +443,13 @@ class TestRestoreFans:
|
||||
|
||||
def test_noop_when_inactive(self) -> None:
|
||||
"""False state → subprocess.run is never called."""
|
||||
with patch("python_pkg.wake_alarm._audio.subprocess.run") as mock_run:
|
||||
with patch("wake_alarm._audio.subprocess.run") as mock_run:
|
||||
_restore_fans(active=False)
|
||||
mock_run.assert_not_called()
|
||||
|
||||
def test_calls_fan_script_restore(self) -> None:
|
||||
"""Active state → fan script called with restore (no args)."""
|
||||
with patch("python_pkg.wake_alarm._audio.subprocess.run") as mock_run:
|
||||
with patch("wake_alarm._audio.subprocess.run") as mock_run:
|
||||
mock_run.return_value.returncode = 0
|
||||
_restore_fans(active=True)
|
||||
mock_run.assert_called_once()
|
||||
@ -459,7 +459,7 @@ class TestRestoreFans:
|
||||
def test_ignores_oserror_on_restore(self) -> None:
|
||||
"""OSError from fan script is silently suppressed."""
|
||||
with patch(
|
||||
"python_pkg.wake_alarm._audio.subprocess.run",
|
||||
"wake_alarm._audio.subprocess.run",
|
||||
side_effect=OSError("no script"),
|
||||
):
|
||||
_restore_fans(active=True) # must not raise
|
||||
@ -467,7 +467,7 @@ class TestRestoreFans:
|
||||
def test_ignores_timeout_on_restore(self) -> None:
|
||||
"""TimeoutExpired from fan script is silently suppressed."""
|
||||
with patch(
|
||||
"python_pkg.wake_alarm._audio.subprocess.run",
|
||||
"wake_alarm._audio.subprocess.run",
|
||||
side_effect=subprocess.TimeoutExpired("fan", 5),
|
||||
):
|
||||
_restore_fans(active=True) # must not raise
|
||||
|
||||
@ -12,7 +12,7 @@ if TYPE_CHECKING:
|
||||
from collections.abc import Iterator
|
||||
import pathlib
|
||||
|
||||
from python_pkg.wake_alarm._audio import (
|
||||
from wake_alarm._audio import (
|
||||
_beep_pcspkr,
|
||||
_ensure_tone_wav,
|
||||
_play_tone,
|
||||
@ -27,8 +27,8 @@ class TestSetMaxBrightness:
|
||||
def test_noop_when_xrandr_missing(self) -> None:
|
||||
"""No xrandr on PATH → subprocess.run never called."""
|
||||
with (
|
||||
patch("python_pkg.wake_alarm._audio.shutil.which", return_value=None),
|
||||
patch("python_pkg.wake_alarm._audio.subprocess.run") as mock_run,
|
||||
patch("wake_alarm._audio.shutil.which", return_value=None),
|
||||
patch("wake_alarm._audio.subprocess.run") as mock_run,
|
||||
):
|
||||
_set_max_brightness()
|
||||
mock_run.assert_not_called()
|
||||
@ -37,11 +37,11 @@ class TestSetMaxBrightness:
|
||||
"""OSError from xrandr --query is suppressed."""
|
||||
with (
|
||||
patch(
|
||||
"python_pkg.wake_alarm._audio.shutil.which",
|
||||
"wake_alarm._audio.shutil.which",
|
||||
return_value="/usr/bin/xrandr",
|
||||
),
|
||||
patch(
|
||||
"python_pkg.wake_alarm._audio.subprocess.run",
|
||||
"wake_alarm._audio.subprocess.run",
|
||||
side_effect=OSError("no display"),
|
||||
),
|
||||
):
|
||||
@ -51,11 +51,11 @@ class TestSetMaxBrightness:
|
||||
"""TimeoutExpired from xrandr --query is suppressed."""
|
||||
with (
|
||||
patch(
|
||||
"python_pkg.wake_alarm._audio.shutil.which",
|
||||
"wake_alarm._audio.shutil.which",
|
||||
return_value="/usr/bin/xrandr",
|
||||
),
|
||||
patch(
|
||||
"python_pkg.wake_alarm._audio.subprocess.run",
|
||||
"wake_alarm._audio.subprocess.run",
|
||||
side_effect=subprocess.TimeoutExpired("xrandr", 5),
|
||||
),
|
||||
):
|
||||
@ -75,10 +75,10 @@ class TestSetMaxBrightness:
|
||||
|
||||
with (
|
||||
patch(
|
||||
"python_pkg.wake_alarm._audio.shutil.which",
|
||||
"wake_alarm._audio.shutil.which",
|
||||
return_value="/usr/bin/xrandr",
|
||||
),
|
||||
patch("python_pkg.wake_alarm._audio.subprocess.run", side_effect=fake_run),
|
||||
patch("wake_alarm._audio.subprocess.run", side_effect=fake_run),
|
||||
):
|
||||
_set_max_brightness()
|
||||
|
||||
@ -93,11 +93,11 @@ class TestSetMaxBrightness:
|
||||
mock_result.stdout = "Screen 0: minimum 320\nHDMI-0 disconnected\n"
|
||||
with (
|
||||
patch(
|
||||
"python_pkg.wake_alarm._audio.shutil.which",
|
||||
"wake_alarm._audio.shutil.which",
|
||||
return_value="/usr/bin/xrandr",
|
||||
),
|
||||
patch(
|
||||
"python_pkg.wake_alarm._audio.subprocess.run",
|
||||
"wake_alarm._audio.subprocess.run",
|
||||
return_value=mock_result,
|
||||
) as mock_run,
|
||||
):
|
||||
@ -120,11 +120,11 @@ class TestSetMaxBrightness:
|
||||
|
||||
with (
|
||||
patch(
|
||||
"python_pkg.wake_alarm._audio.shutil.which",
|
||||
"wake_alarm._audio.shutil.which",
|
||||
return_value="/usr/bin/xrandr",
|
||||
),
|
||||
patch(
|
||||
"python_pkg.wake_alarm._audio.subprocess.run",
|
||||
"wake_alarm._audio.subprocess.run",
|
||||
side_effect=_run_side_effect,
|
||||
),
|
||||
):
|
||||
@ -136,11 +136,11 @@ class TestEnsureToneWav:
|
||||
|
||||
def test_generates_and_caches(self, tmp_path: pathlib.Path) -> None:
|
||||
"""First call generates the WAV; second call returns the cached path."""
|
||||
from python_pkg.wake_alarm import _audio as alarm_mod
|
||||
from wake_alarm import _audio as alarm_mod
|
||||
|
||||
alarm_mod._TONE_CACHE.clear()
|
||||
with patch(
|
||||
"python_pkg.wake_alarm._audio.tempfile.gettempdir",
|
||||
"wake_alarm._audio.tempfile.gettempdir",
|
||||
return_value=str(tmp_path),
|
||||
):
|
||||
path1 = _ensure_tone_wav(440)
|
||||
@ -148,7 +148,7 @@ class TestEnsureToneWav:
|
||||
size = path1.stat().st_size
|
||||
assert size > 0
|
||||
# Second call must hit the cache (no regeneration).
|
||||
with patch("python_pkg.wake_alarm._audio.wave.open") as mock_open:
|
||||
with patch("wake_alarm._audio.wave.open") as mock_open:
|
||||
path2 = _ensure_tone_wav(440)
|
||||
mock_open.assert_not_called()
|
||||
assert path2 == path1
|
||||
@ -159,11 +159,11 @@ class TestEnsureToneWav:
|
||||
tmp_path: pathlib.Path,
|
||||
) -> None:
|
||||
"""If the cached file was deleted, regenerate it."""
|
||||
from python_pkg.wake_alarm._audio import _TONE_CACHE
|
||||
from wake_alarm._audio import _TONE_CACHE
|
||||
|
||||
_TONE_CACHE.clear()
|
||||
with patch(
|
||||
"python_pkg.wake_alarm._audio.tempfile.gettempdir",
|
||||
"wake_alarm._audio.tempfile.gettempdir",
|
||||
return_value=str(tmp_path),
|
||||
):
|
||||
path1 = _ensure_tone_wav(880)
|
||||
@ -184,7 +184,7 @@ class TestTryPlayer:
|
||||
wav = tmp_path / "x.wav"
|
||||
wav.write_bytes(b"\x00")
|
||||
with patch(
|
||||
"python_pkg.wake_alarm._audio.shutil.which",
|
||||
"wake_alarm._audio.shutil.which",
|
||||
return_value=None,
|
||||
):
|
||||
assert _try_player("paplay", wav) is False
|
||||
@ -197,11 +197,11 @@ class TestTryPlayer:
|
||||
result.returncode = 0
|
||||
with (
|
||||
patch(
|
||||
"python_pkg.wake_alarm._audio.shutil.which",
|
||||
"wake_alarm._audio.shutil.which",
|
||||
return_value="/usr/bin/paplay",
|
||||
),
|
||||
patch(
|
||||
"python_pkg.wake_alarm._audio.subprocess.run",
|
||||
"wake_alarm._audio.subprocess.run",
|
||||
return_value=result,
|
||||
),
|
||||
):
|
||||
@ -219,11 +219,11 @@ class TestTryPlayer:
|
||||
result.stderr = b"boom"
|
||||
with (
|
||||
patch(
|
||||
"python_pkg.wake_alarm._audio.shutil.which",
|
||||
"wake_alarm._audio.shutil.which",
|
||||
return_value="/usr/bin/paplay",
|
||||
),
|
||||
patch(
|
||||
"python_pkg.wake_alarm._audio.subprocess.run",
|
||||
"wake_alarm._audio.subprocess.run",
|
||||
return_value=result,
|
||||
),
|
||||
):
|
||||
@ -235,11 +235,11 @@ class TestTryPlayer:
|
||||
wav.write_bytes(b"\x00")
|
||||
with (
|
||||
patch(
|
||||
"python_pkg.wake_alarm._audio.shutil.which",
|
||||
"wake_alarm._audio.shutil.which",
|
||||
return_value="/usr/bin/paplay",
|
||||
),
|
||||
patch(
|
||||
"python_pkg.wake_alarm._audio.subprocess.run",
|
||||
"wake_alarm._audio.subprocess.run",
|
||||
side_effect=subprocess.TimeoutExpired("paplay", 6),
|
||||
),
|
||||
):
|
||||
@ -251,11 +251,11 @@ class TestTryPlayer:
|
||||
wav.write_bytes(b"\x00")
|
||||
with (
|
||||
patch(
|
||||
"python_pkg.wake_alarm._audio.shutil.which",
|
||||
"wake_alarm._audio.shutil.which",
|
||||
return_value="/usr/bin/paplay",
|
||||
),
|
||||
patch(
|
||||
"python_pkg.wake_alarm._audio.subprocess.run",
|
||||
"wake_alarm._audio.subprocess.run",
|
||||
side_effect=OSError("nope"),
|
||||
),
|
||||
):
|
||||
@ -274,10 +274,10 @@ class TestBeepPcspkr:
|
||||
mock_open_ctx.__exit__.return_value = False
|
||||
with (
|
||||
patch(
|
||||
"python_pkg.wake_alarm._audio.Path.open",
|
||||
"wake_alarm._audio.Path.open",
|
||||
return_value=mock_open_ctx,
|
||||
),
|
||||
patch("python_pkg.wake_alarm._audio.time.sleep"),
|
||||
patch("wake_alarm._audio.time.sleep"),
|
||||
):
|
||||
_beep_pcspkr(1000, 0.05)
|
||||
# First write carries the frequency, second write carries 0 (stop).
|
||||
@ -287,7 +287,7 @@ class TestBeepPcspkr:
|
||||
"""OSError opening the device must not raise."""
|
||||
|
||||
with patch(
|
||||
"python_pkg.wake_alarm._audio.Path.open",
|
||||
"wake_alarm._audio.Path.open",
|
||||
side_effect=OSError("no device"),
|
||||
):
|
||||
_beep_pcspkr(1000, 0.05) # must not raise
|
||||
@ -299,7 +299,7 @@ class TestPlayTone:
|
||||
@pytest.fixture(autouse=True)
|
||||
def _silence_pcspkr(self) -> Iterator[None]:
|
||||
"""Stop tests from hitting the real /dev/input PC speaker device."""
|
||||
with patch("python_pkg.wake_alarm._audio._beep_pcspkr"):
|
||||
with patch("wake_alarm._audio._beep_pcspkr"):
|
||||
yield
|
||||
|
||||
def test_paplay_success_short_circuits(self, tmp_path: pathlib.Path) -> None:
|
||||
@ -308,15 +308,15 @@ class TestPlayTone:
|
||||
wav.write_bytes(b"\x00")
|
||||
with (
|
||||
patch(
|
||||
"python_pkg.wake_alarm._audio._ensure_tone_wav",
|
||||
"wake_alarm._audio._ensure_tone_wav",
|
||||
return_value=wav,
|
||||
),
|
||||
patch(
|
||||
"python_pkg.wake_alarm._audio._try_player",
|
||||
"wake_alarm._audio._try_player",
|
||||
return_value=True,
|
||||
) as mock_try,
|
||||
patch(
|
||||
"python_pkg.wake_alarm._audio.subprocess.run",
|
||||
"wake_alarm._audio.subprocess.run",
|
||||
) as mock_run,
|
||||
):
|
||||
_play_tone(440)
|
||||
@ -332,19 +332,19 @@ class TestPlayTone:
|
||||
wav.write_bytes(b"\x00")
|
||||
with (
|
||||
patch(
|
||||
"python_pkg.wake_alarm._audio._ensure_tone_wav",
|
||||
"wake_alarm._audio._ensure_tone_wav",
|
||||
return_value=wav,
|
||||
),
|
||||
patch(
|
||||
"python_pkg.wake_alarm._audio._try_player",
|
||||
"wake_alarm._audio._try_player",
|
||||
return_value=False,
|
||||
),
|
||||
patch(
|
||||
"python_pkg.wake_alarm._audio._speaker_test_path",
|
||||
"wake_alarm._audio._speaker_test_path",
|
||||
return_value="/usr/bin/speaker-test",
|
||||
),
|
||||
patch(
|
||||
"python_pkg.wake_alarm._audio.subprocess.run",
|
||||
"wake_alarm._audio.subprocess.run",
|
||||
) as mock_run,
|
||||
):
|
||||
_play_tone(1000)
|
||||
@ -362,18 +362,18 @@ class TestPlayTone:
|
||||
wav.write_bytes(b"\x00")
|
||||
with (
|
||||
patch(
|
||||
"python_pkg.wake_alarm._audio._ensure_tone_wav",
|
||||
"wake_alarm._audio._ensure_tone_wav",
|
||||
return_value=wav,
|
||||
),
|
||||
patch(
|
||||
"python_pkg.wake_alarm._audio._try_player",
|
||||
"wake_alarm._audio._try_player",
|
||||
return_value=False,
|
||||
),
|
||||
patch(
|
||||
"python_pkg.wake_alarm._audio._speaker_test_path",
|
||||
"wake_alarm._audio._speaker_test_path",
|
||||
side_effect=FileNotFoundError("missing"),
|
||||
),
|
||||
patch("python_pkg.wake_alarm._audio._beep_soft") as mock_soft,
|
||||
patch("wake_alarm._audio._beep_soft") as mock_soft,
|
||||
):
|
||||
_play_tone(800)
|
||||
mock_soft.assert_called_once()
|
||||
@ -387,22 +387,22 @@ class TestPlayTone:
|
||||
wav.write_bytes(b"\x00")
|
||||
with (
|
||||
patch(
|
||||
"python_pkg.wake_alarm._audio._ensure_tone_wav",
|
||||
"wake_alarm._audio._ensure_tone_wav",
|
||||
return_value=wav,
|
||||
),
|
||||
patch(
|
||||
"python_pkg.wake_alarm._audio._try_player",
|
||||
"wake_alarm._audio._try_player",
|
||||
return_value=False,
|
||||
),
|
||||
patch(
|
||||
"python_pkg.wake_alarm._audio._speaker_test_path",
|
||||
"wake_alarm._audio._speaker_test_path",
|
||||
return_value="/usr/bin/speaker-test",
|
||||
),
|
||||
patch(
|
||||
"python_pkg.wake_alarm._audio.subprocess.run",
|
||||
"wake_alarm._audio.subprocess.run",
|
||||
side_effect=subprocess.TimeoutExpired("speaker-test", 6),
|
||||
),
|
||||
patch("python_pkg.wake_alarm._audio._beep_soft") as mock_soft,
|
||||
patch("wake_alarm._audio._beep_soft") as mock_soft,
|
||||
):
|
||||
_play_tone(800)
|
||||
mock_soft.assert_called_once()
|
||||
@ -411,10 +411,10 @@ class TestPlayTone:
|
||||
"""OSError generating WAV → soft beep."""
|
||||
with (
|
||||
patch(
|
||||
"python_pkg.wake_alarm._audio._ensure_tone_wav",
|
||||
"wake_alarm._audio._ensure_tone_wav",
|
||||
side_effect=OSError("disk full"),
|
||||
),
|
||||
patch("python_pkg.wake_alarm._audio._beep_soft") as mock_soft,
|
||||
patch("wake_alarm._audio._beep_soft") as mock_soft,
|
||||
):
|
||||
_play_tone(440)
|
||||
mock_soft.assert_called_once()
|
||||
|
||||
@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from python_pkg.wake_alarm._challenges import (
|
||||
from wake_alarm._challenges import (
|
||||
_DISMISS_CHARS,
|
||||
_Challenge,
|
||||
_make_challenge,
|
||||
@ -12,7 +12,7 @@ from python_pkg.wake_alarm._challenges import (
|
||||
_make_math_challenge,
|
||||
_make_sort_challenge,
|
||||
)
|
||||
from python_pkg.wake_alarm._constants import DISMISS_FLASH_SECONDS
|
||||
from wake_alarm._constants import DISMISS_FLASH_SECONDS
|
||||
|
||||
|
||||
class TestMakeMathChallenge:
|
||||
@ -25,9 +25,9 @@ class TestMakeMathChallenge:
|
||||
def test_answer_is_correct_for_addition(self) -> None:
|
||||
"""Stored answer is numerically correct for addition."""
|
||||
with (
|
||||
patch("python_pkg.wake_alarm._challenges.secrets.choice", return_value="+"),
|
||||
patch("wake_alarm._challenges.secrets.choice", return_value="+"),
|
||||
patch(
|
||||
"python_pkg.wake_alarm._challenges.secrets.randbelow",
|
||||
"wake_alarm._challenges.secrets.randbelow",
|
||||
side_effect=[13, 35], # 10+13=23, 10+35=45
|
||||
),
|
||||
):
|
||||
@ -38,9 +38,9 @@ class TestMakeMathChallenge:
|
||||
def test_answer_is_correct_for_subtraction(self) -> None:
|
||||
"""Stored answer is numerically correct for subtraction."""
|
||||
with (
|
||||
patch("python_pkg.wake_alarm._challenges.secrets.choice", return_value="-"),
|
||||
patch("wake_alarm._challenges.secrets.choice", return_value="-"),
|
||||
patch(
|
||||
"python_pkg.wake_alarm._challenges.secrets.randbelow",
|
||||
"wake_alarm._challenges.secrets.randbelow",
|
||||
side_effect=[30, 7], # 20+30=50, 10+7=17
|
||||
),
|
||||
):
|
||||
@ -51,9 +51,9 @@ class TestMakeMathChallenge:
|
||||
def test_answer_is_correct_for_multiplication(self) -> None:
|
||||
"""Stored answer is numerically correct for multiplication."""
|
||||
with (
|
||||
patch("python_pkg.wake_alarm._challenges.secrets.choice", return_value="*"),
|
||||
patch("wake_alarm._challenges.secrets.choice", return_value="*"),
|
||||
patch(
|
||||
"python_pkg.wake_alarm._challenges.secrets.randbelow",
|
||||
"wake_alarm._challenges.secrets.randbelow",
|
||||
side_effect=[3, 4], # 12+3=15, 3+4=7
|
||||
),
|
||||
):
|
||||
|
||||
@ -5,7 +5,7 @@ from __future__ import annotations
|
||||
import subprocess
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from python_pkg.wake_alarm._alarm_display import (
|
||||
from wake_alarm._alarm_display import (
|
||||
_ddcutil_power_on,
|
||||
_restore_display,
|
||||
_wake_display,
|
||||
@ -19,10 +19,10 @@ class TestDdcutilPowerOn:
|
||||
"""_ddcutil_power_on does nothing when ddcutil is not on PATH."""
|
||||
with (
|
||||
patch(
|
||||
"python_pkg.wake_alarm._alarm_display.shutil.which",
|
||||
"wake_alarm._alarm_display.shutil.which",
|
||||
return_value=None,
|
||||
),
|
||||
patch("python_pkg.wake_alarm._alarm_display.subprocess.run") as mock_run,
|
||||
patch("wake_alarm._alarm_display.subprocess.run") as mock_run,
|
||||
):
|
||||
_ddcutil_power_on()
|
||||
mock_run.assert_not_called()
|
||||
@ -31,10 +31,10 @@ class TestDdcutilPowerOn:
|
||||
"""_ddcutil_power_on sends setvcp D6 01 when ddcutil is found."""
|
||||
with (
|
||||
patch(
|
||||
"python_pkg.wake_alarm._alarm_display.shutil.which",
|
||||
"wake_alarm._alarm_display.shutil.which",
|
||||
return_value="/usr/bin/ddcutil",
|
||||
),
|
||||
patch("python_pkg.wake_alarm._alarm_display.subprocess.run") as mock_run,
|
||||
patch("wake_alarm._alarm_display.subprocess.run") as mock_run,
|
||||
):
|
||||
_ddcutil_power_on()
|
||||
mock_run.assert_called_once()
|
||||
@ -45,11 +45,11 @@ class TestDdcutilPowerOn:
|
||||
"""_ddcutil_power_on logs success when setvcp returns 0."""
|
||||
with (
|
||||
patch(
|
||||
"python_pkg.wake_alarm._alarm_display.shutil.which",
|
||||
"wake_alarm._alarm_display.shutil.which",
|
||||
return_value="/usr/bin/ddcutil",
|
||||
),
|
||||
patch(
|
||||
"python_pkg.wake_alarm._alarm_display.subprocess.run",
|
||||
"wake_alarm._alarm_display.subprocess.run",
|
||||
return_value=MagicMock(returncode=0),
|
||||
),
|
||||
):
|
||||
@ -59,11 +59,11 @@ class TestDdcutilPowerOn:
|
||||
"""_ddcutil_power_on does not raise on TimeoutExpired."""
|
||||
with (
|
||||
patch(
|
||||
"python_pkg.wake_alarm._alarm_display.shutil.which",
|
||||
"wake_alarm._alarm_display.shutil.which",
|
||||
return_value="/usr/bin/ddcutil",
|
||||
),
|
||||
patch(
|
||||
"python_pkg.wake_alarm._alarm_display.subprocess.run",
|
||||
"wake_alarm._alarm_display.subprocess.run",
|
||||
side_effect=subprocess.TimeoutExpired(cmd="ddcutil", timeout=10),
|
||||
),
|
||||
):
|
||||
@ -73,11 +73,11 @@ class TestDdcutilPowerOn:
|
||||
"""_ddcutil_power_on does not raise on OSError."""
|
||||
with (
|
||||
patch(
|
||||
"python_pkg.wake_alarm._alarm_display.shutil.which",
|
||||
"wake_alarm._alarm_display.shutil.which",
|
||||
return_value="/usr/bin/ddcutil",
|
||||
),
|
||||
patch(
|
||||
"python_pkg.wake_alarm._alarm_display.subprocess.run",
|
||||
"wake_alarm._alarm_display.subprocess.run",
|
||||
side_effect=OSError("no device"),
|
||||
),
|
||||
):
|
||||
@ -91,10 +91,10 @@ class TestDisplayHelpers:
|
||||
"""_wake_display skips xset commands but still attempts ddcutil."""
|
||||
with (
|
||||
patch(
|
||||
"python_pkg.wake_alarm._alarm_display.shutil.which",
|
||||
"wake_alarm._alarm_display.shutil.which",
|
||||
return_value=None,
|
||||
),
|
||||
patch("python_pkg.wake_alarm._alarm_display.subprocess.run") as mock_run,
|
||||
patch("wake_alarm._alarm_display.subprocess.run") as mock_run,
|
||||
):
|
||||
_wake_display()
|
||||
mock_run.assert_not_called()
|
||||
@ -103,10 +103,10 @@ class TestDisplayHelpers:
|
||||
"""_wake_display runs ddcutil setvcp, xset dpms force on, xset s off."""
|
||||
with (
|
||||
patch(
|
||||
"python_pkg.wake_alarm._alarm_display.shutil.which",
|
||||
"wake_alarm._alarm_display.shutil.which",
|
||||
return_value="/usr/bin/xset",
|
||||
),
|
||||
patch("python_pkg.wake_alarm._alarm_display.subprocess.run") as mock_run,
|
||||
patch("wake_alarm._alarm_display.subprocess.run") as mock_run,
|
||||
):
|
||||
_wake_display()
|
||||
# 1 ddcutil setvcp call + 2 xset calls
|
||||
@ -120,10 +120,10 @@ class TestDisplayHelpers:
|
||||
"""_restore_display does nothing when xset is not on PATH."""
|
||||
with (
|
||||
patch(
|
||||
"python_pkg.wake_alarm._alarm_display.shutil.which",
|
||||
"wake_alarm._alarm_display.shutil.which",
|
||||
return_value=None,
|
||||
),
|
||||
patch("python_pkg.wake_alarm._alarm_display.subprocess.run") as mock_run,
|
||||
patch("wake_alarm._alarm_display.subprocess.run") as mock_run,
|
||||
):
|
||||
_restore_display()
|
||||
mock_run.assert_not_called()
|
||||
@ -132,10 +132,10 @@ class TestDisplayHelpers:
|
||||
"""_restore_display re-enables the screensaver via xset when present."""
|
||||
with (
|
||||
patch(
|
||||
"python_pkg.wake_alarm._alarm_display.shutil.which",
|
||||
"wake_alarm._alarm_display.shutil.which",
|
||||
return_value="/usr/bin/xset",
|
||||
),
|
||||
patch("python_pkg.wake_alarm._alarm_display.subprocess.run") as mock_run,
|
||||
patch("wake_alarm._alarm_display.subprocess.run") as mock_run,
|
||||
):
|
||||
_restore_display()
|
||||
mock_run.assert_called_once_with(
|
||||
|
||||
@ -11,7 +11,7 @@ import pytest
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Generator
|
||||
|
||||
from python_pkg.wake_alarm._alarm import (
|
||||
from wake_alarm._alarm import (
|
||||
WakeAlarm,
|
||||
main,
|
||||
)
|
||||
@ -41,9 +41,9 @@ def _block_real_tk() -> Generator[MagicMock]:
|
||||
"""Prevent any real Tk windows in tests."""
|
||||
mock = _make_mock_tk()
|
||||
with (
|
||||
patch("python_pkg.wake_alarm._alarm.tk", mock),
|
||||
patch("wake_alarm._alarm.tk", mock),
|
||||
patch(
|
||||
"python_pkg.wake_alarm._alarm.GateRoot",
|
||||
"wake_alarm._alarm.GateRoot",
|
||||
return_value=mock.Tk.return_value,
|
||||
),
|
||||
):
|
||||
@ -54,17 +54,17 @@ def _block_real_tk() -> Generator[MagicMock]:
|
||||
def _block_extra_devices() -> Generator[MagicMock]:
|
||||
"""Prevent real subprocess.Popen calls for extra ALSA devices."""
|
||||
with (
|
||||
patch("python_pkg.wake_alarm._alarm._play_on_extra_devices") as mock,
|
||||
patch("python_pkg.wake_alarm._alarm._max_fans", return_value=False),
|
||||
patch("python_pkg.wake_alarm._alarm._restore_fans"),
|
||||
patch("python_pkg.wake_alarm._alarm._set_max_brightness"),
|
||||
patch("python_pkg.wake_alarm._alarm._wake_display"),
|
||||
patch("python_pkg.wake_alarm._alarm._restore_display"),
|
||||
patch("python_pkg.wake_alarm._alarm._warn_if_no_real_sink"),
|
||||
patch("python_pkg.wake_alarm._alarm._activate_alarm_audio", return_value=None),
|
||||
patch("python_pkg.wake_alarm._alarm._restore_alarm_audio"),
|
||||
patch("python_pkg.wake_alarm._alarm.turn_on_plug"),
|
||||
patch("python_pkg.wake_alarm._alarm.turn_off_plug"),
|
||||
patch("wake_alarm._alarm._play_on_extra_devices") as mock,
|
||||
patch("wake_alarm._alarm._max_fans", return_value=False),
|
||||
patch("wake_alarm._alarm._restore_fans"),
|
||||
patch("wake_alarm._alarm._set_max_brightness"),
|
||||
patch("wake_alarm._alarm._wake_display"),
|
||||
patch("wake_alarm._alarm._restore_display"),
|
||||
patch("wake_alarm._alarm._warn_if_no_real_sink"),
|
||||
patch("wake_alarm._alarm._activate_alarm_audio", return_value=None),
|
||||
patch("wake_alarm._alarm._restore_alarm_audio"),
|
||||
patch("wake_alarm._alarm.turn_on_plug"),
|
||||
patch("wake_alarm._alarm.turn_off_plug"),
|
||||
):
|
||||
yield mock
|
||||
|
||||
@ -74,9 +74,9 @@ def mock_tk_module() -> Generator[MagicMock]:
|
||||
"""Provide explicit access to the mocked tk module."""
|
||||
mock = _make_mock_tk()
|
||||
with (
|
||||
patch("python_pkg.wake_alarm._alarm.tk", mock),
|
||||
patch("wake_alarm._alarm.tk", mock),
|
||||
patch(
|
||||
"python_pkg.wake_alarm._alarm.GateRoot",
|
||||
"wake_alarm._alarm.GateRoot",
|
||||
return_value=mock.Tk.return_value,
|
||||
),
|
||||
):
|
||||
@ -137,13 +137,13 @@ class TestWakeAlarmDismiss:
|
||||
mock_tk_module: MagicMock,
|
||||
) -> None:
|
||||
"""Entering the correct answer for every required round dismisses the alarm."""
|
||||
from python_pkg.wake_alarm._constants import DISMISS_ROUNDS_REQUIRED
|
||||
from wake_alarm._constants import DISMISS_ROUNDS_REQUIRED
|
||||
|
||||
alarm = WakeAlarm(demo_mode=True)
|
||||
mock_entry = mock_tk_module.Entry.return_value
|
||||
|
||||
with patch(
|
||||
"python_pkg.wake_alarm._alarm.save_wake_state",
|
||||
"wake_alarm._alarm.save_wake_state",
|
||||
) as mock_save:
|
||||
for _ in range(DISMISS_ROUNDS_REQUIRED):
|
||||
mock_entry.get.return_value = alarm._progress.current_challenge.answer
|
||||
@ -174,15 +174,13 @@ class TestWakeAlarmDismiss:
|
||||
mock_tk_module: MagicMock,
|
||||
) -> None:
|
||||
"""When next challenge is math, no flash countdown is started."""
|
||||
from python_pkg.wake_alarm._challenges import _Challenge
|
||||
from wake_alarm._challenges import _Challenge
|
||||
|
||||
alarm = WakeAlarm(demo_mode=True)
|
||||
mock_entry = mock_tk_module.Entry.return_value
|
||||
mock_entry.get.return_value = alarm._progress.current_challenge.answer
|
||||
next_math = _Challenge(kind="math", display="2 + 2 = ?", answer="4", hint="x")
|
||||
with patch(
|
||||
"python_pkg.wake_alarm._alarm._make_challenge", return_value=next_math
|
||||
):
|
||||
with patch("wake_alarm._alarm._make_challenge", return_value=next_math):
|
||||
alarm._on_submit()
|
||||
|
||||
assert alarm._progress.current_challenge.kind == "math"
|
||||
@ -194,7 +192,7 @@ class TestWakeAlarmDismiss:
|
||||
mock_tk_module: MagicMock,
|
||||
) -> None:
|
||||
"""Entering the wrong answer shows an error without dismissing."""
|
||||
from python_pkg.wake_alarm._alarm import _Challenge
|
||||
from wake_alarm._alarm import _Challenge
|
||||
|
||||
alarm = WakeAlarm(demo_mode=True)
|
||||
# Use a pinned math challenge so the non-flash wrong-answer branch is covered.
|
||||
@ -218,7 +216,7 @@ class TestWakeAlarmDismiss:
|
||||
alarm = WakeAlarm(demo_mode=True)
|
||||
|
||||
with patch(
|
||||
"python_pkg.wake_alarm._alarm.save_wake_state",
|
||||
"wake_alarm._alarm.save_wake_state",
|
||||
) as mock_save:
|
||||
alarm._on_skip_window_expired()
|
||||
|
||||
@ -251,14 +249,14 @@ class TestWakeAlarmDismiss:
|
||||
mock_tk_module: MagicMock,
|
||||
) -> None:
|
||||
"""Typing all rounds after the skip window stops the alarm without a skip."""
|
||||
from python_pkg.wake_alarm._constants import DISMISS_ROUNDS_REQUIRED
|
||||
from wake_alarm._constants import DISMISS_ROUNDS_REQUIRED
|
||||
|
||||
alarm = WakeAlarm(demo_mode=True)
|
||||
alarm._progress.skip_earnable = False
|
||||
mock_entry = mock_tk_module.Entry.return_value
|
||||
|
||||
with patch(
|
||||
"python_pkg.wake_alarm._alarm.save_wake_state",
|
||||
"wake_alarm._alarm.save_wake_state",
|
||||
) as mock_save:
|
||||
for _ in range(DISMISS_ROUNDS_REQUIRED):
|
||||
mock_entry.get.return_value = alarm._progress.current_challenge.answer
|
||||
@ -276,10 +274,10 @@ class TestMain:
|
||||
"""main() returns early when not an alarm day."""
|
||||
with (
|
||||
patch(
|
||||
"python_pkg.wake_alarm._alarm._should_run_alarm",
|
||||
"wake_alarm._alarm._should_run_alarm",
|
||||
return_value=False,
|
||||
),
|
||||
patch("python_pkg.wake_alarm._alarm.sys") as mock_sys,
|
||||
patch("wake_alarm._alarm.sys") as mock_sys,
|
||||
):
|
||||
mock_sys.argv = ["alarm"]
|
||||
main() # Should just return without error
|
||||
@ -292,11 +290,11 @@ class TestMain:
|
||||
del mock_tk_module
|
||||
with (
|
||||
patch(
|
||||
"python_pkg.wake_alarm._alarm._should_run_alarm",
|
||||
"wake_alarm._alarm._should_run_alarm",
|
||||
return_value=True,
|
||||
),
|
||||
patch(
|
||||
"python_pkg.wake_alarm._alarm.sys",
|
||||
"wake_alarm._alarm.sys",
|
||||
) as mock_sys,
|
||||
patch.object(WakeAlarm, "run") as mock_run,
|
||||
patch.object(WakeAlarm, "__init__", return_value=None),
|
||||
@ -313,10 +311,10 @@ class TestMain:
|
||||
del mock_tk_module
|
||||
with (
|
||||
patch(
|
||||
"python_pkg.wake_alarm._alarm._should_run_alarm",
|
||||
"wake_alarm._alarm._should_run_alarm",
|
||||
return_value=False,
|
||||
) as mock_gate,
|
||||
patch("python_pkg.wake_alarm._alarm.sys") as mock_sys,
|
||||
patch("wake_alarm._alarm.sys") as mock_sys,
|
||||
patch.object(WakeAlarm, "run") as mock_run,
|
||||
patch.object(WakeAlarm, "__init__", return_value=None),
|
||||
):
|
||||
@ -377,7 +375,7 @@ class TestBeepLoop:
|
||||
alarm._stop_beep.set()
|
||||
# Loop should exit immediately
|
||||
with patch(
|
||||
"python_pkg.wake_alarm._alarm._beep_soft",
|
||||
"wake_alarm._alarm._beep_soft",
|
||||
):
|
||||
alarm._beep_loop()
|
||||
alarm._stop_beep.set()
|
||||
|
||||
@ -11,10 +11,10 @@ import pytest
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Generator
|
||||
|
||||
from python_pkg.wake_alarm._alarm import (
|
||||
from wake_alarm._alarm import (
|
||||
WakeAlarm,
|
||||
)
|
||||
from python_pkg.wake_alarm._constants import (
|
||||
from wake_alarm._constants import (
|
||||
PHASE_MEDIUM_END,
|
||||
PHASE_SOFT_END,
|
||||
)
|
||||
@ -40,9 +40,9 @@ def _block_real_tk() -> Generator[MagicMock]:
|
||||
"""Prevent any real Tk windows in tests."""
|
||||
mock = _make_mock_tk()
|
||||
with (
|
||||
patch("python_pkg.wake_alarm._alarm.tk", mock),
|
||||
patch("wake_alarm._alarm.tk", mock),
|
||||
patch(
|
||||
"python_pkg.wake_alarm._alarm.GateRoot",
|
||||
"wake_alarm._alarm.GateRoot",
|
||||
return_value=mock.Tk.return_value,
|
||||
),
|
||||
):
|
||||
@ -53,17 +53,17 @@ def _block_real_tk() -> Generator[MagicMock]:
|
||||
def _block_extra_devices() -> Generator[MagicMock]:
|
||||
"""Prevent real subprocess calls for extra ALSA devices and hardware."""
|
||||
with (
|
||||
patch("python_pkg.wake_alarm._alarm._play_on_extra_devices") as mock,
|
||||
patch("python_pkg.wake_alarm._alarm._max_fans", return_value=False),
|
||||
patch("python_pkg.wake_alarm._alarm._restore_fans"),
|
||||
patch("python_pkg.wake_alarm._alarm._set_max_brightness"),
|
||||
patch("python_pkg.wake_alarm._alarm._wake_display"),
|
||||
patch("python_pkg.wake_alarm._alarm._restore_display"),
|
||||
patch("python_pkg.wake_alarm._alarm._warn_if_no_real_sink"),
|
||||
patch("python_pkg.wake_alarm._alarm._activate_alarm_audio", return_value=None),
|
||||
patch("python_pkg.wake_alarm._alarm._restore_alarm_audio"),
|
||||
patch("python_pkg.wake_alarm._alarm.turn_on_plug"),
|
||||
patch("python_pkg.wake_alarm._alarm.turn_off_plug"),
|
||||
patch("wake_alarm._alarm._play_on_extra_devices") as mock,
|
||||
patch("wake_alarm._alarm._max_fans", return_value=False),
|
||||
patch("wake_alarm._alarm._restore_fans"),
|
||||
patch("wake_alarm._alarm._set_max_brightness"),
|
||||
patch("wake_alarm._alarm._wake_display"),
|
||||
patch("wake_alarm._alarm._restore_display"),
|
||||
patch("wake_alarm._alarm._warn_if_no_real_sink"),
|
||||
patch("wake_alarm._alarm._activate_alarm_audio", return_value=None),
|
||||
patch("wake_alarm._alarm._restore_alarm_audio"),
|
||||
patch("wake_alarm._alarm.turn_on_plug"),
|
||||
patch("wake_alarm._alarm.turn_off_plug"),
|
||||
):
|
||||
yield mock
|
||||
|
||||
@ -73,9 +73,9 @@ def mock_tk_module() -> Generator[MagicMock]:
|
||||
"""Provide explicit access to the mocked tk module."""
|
||||
mock = _make_mock_tk()
|
||||
with (
|
||||
patch("python_pkg.wake_alarm._alarm.tk", mock),
|
||||
patch("wake_alarm._alarm.tk", mock),
|
||||
patch(
|
||||
"python_pkg.wake_alarm._alarm.GateRoot",
|
||||
"wake_alarm._alarm.GateRoot",
|
||||
return_value=mock.Tk.return_value,
|
||||
),
|
||||
):
|
||||
@ -106,7 +106,7 @@ class TestBeepLoopPhases:
|
||||
|
||||
with (
|
||||
patch(
|
||||
"python_pkg.wake_alarm._alarm._beep_medium",
|
||||
"wake_alarm._alarm._beep_medium",
|
||||
side_effect=stop_after_one,
|
||||
) as mock_beep,
|
||||
):
|
||||
@ -135,7 +135,7 @@ class TestBeepLoopPhases:
|
||||
|
||||
with (
|
||||
patch(
|
||||
"python_pkg.wake_alarm._alarm._beep_loud",
|
||||
"wake_alarm._alarm._beep_loud",
|
||||
side_effect=stop_after_one,
|
||||
) as mock_beep,
|
||||
):
|
||||
@ -220,7 +220,7 @@ class TestFlashChallenge:
|
||||
mock_tk_module: MagicMock,
|
||||
) -> None:
|
||||
"""_flash_tick counts down per second and hides the code at zero."""
|
||||
from python_pkg.wake_alarm._alarm import _Challenge
|
||||
from wake_alarm._alarm import _Challenge
|
||||
|
||||
alarm = WakeAlarm(demo_mode=True)
|
||||
alarm._progress.current_challenge = _Challenge(
|
||||
@ -269,7 +269,7 @@ class TestFlashChallenge:
|
||||
mock_tk_module: MagicMock,
|
||||
) -> None:
|
||||
"""Wrong flash answer restores the code and restarts the countdown."""
|
||||
from python_pkg.wake_alarm._alarm import _Challenge
|
||||
from wake_alarm._alarm import _Challenge
|
||||
|
||||
alarm = WakeAlarm(demo_mode=True)
|
||||
alarm._progress.current_challenge = _Challenge(
|
||||
@ -294,7 +294,7 @@ class TestFlashChallenge:
|
||||
mock_tk_module: MagicMock,
|
||||
) -> None:
|
||||
"""When the next-round challenge is flash, the countdown starts immediately."""
|
||||
from python_pkg.wake_alarm._alarm import _Challenge
|
||||
from wake_alarm._alarm import _Challenge
|
||||
|
||||
alarm = WakeAlarm(demo_mode=True)
|
||||
alarm._progress.current_challenge = _Challenge(
|
||||
@ -306,9 +306,7 @@ class TestFlashChallenge:
|
||||
mock_entry = mock_tk_module.Entry.return_value
|
||||
mock_entry.get.return_value = "4"
|
||||
|
||||
with patch(
|
||||
"python_pkg.wake_alarm._alarm._make_challenge", return_value=next_flash
|
||||
):
|
||||
with patch("wake_alarm._alarm._make_challenge", return_value=next_flash):
|
||||
alarm._on_submit()
|
||||
|
||||
assert alarm._progress.current_challenge.kind == "flash"
|
||||
@ -330,7 +328,7 @@ class TestDismissWithoutSkip:
|
||||
alarm._view.container.winfo_children.return_value = [mock_widget]
|
||||
|
||||
with patch(
|
||||
"python_pkg.wake_alarm._alarm.save_wake_state",
|
||||
"wake_alarm._alarm.save_wake_state",
|
||||
) as mock_save:
|
||||
alarm._dismiss_alarm(earned_skip=False)
|
||||
|
||||
|
||||
@ -11,7 +11,7 @@ import pytest
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Generator
|
||||
|
||||
from python_pkg.wake_alarm._alarm import WakeAlarm
|
||||
from wake_alarm._alarm import WakeAlarm
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers (duplicated from part 1 so this file is self-contained)
|
||||
@ -38,9 +38,9 @@ def _block_real_tk() -> Generator[MagicMock]:
|
||||
"""Prevent any real Tk windows in tests."""
|
||||
mock = _make_mock_tk()
|
||||
with (
|
||||
patch("python_pkg.wake_alarm._alarm.tk", mock),
|
||||
patch("wake_alarm._alarm.tk", mock),
|
||||
patch(
|
||||
"python_pkg.wake_alarm._alarm.GateRoot",
|
||||
"wake_alarm._alarm.GateRoot",
|
||||
return_value=mock.Tk.return_value,
|
||||
),
|
||||
):
|
||||
@ -51,17 +51,17 @@ def _block_real_tk() -> Generator[MagicMock]:
|
||||
def _block_extra_devices() -> Generator[MagicMock]:
|
||||
"""Prevent real subprocess.Popen calls for extra ALSA devices."""
|
||||
with (
|
||||
patch("python_pkg.wake_alarm._alarm._play_on_extra_devices") as mock,
|
||||
patch("python_pkg.wake_alarm._alarm._max_fans", return_value=False),
|
||||
patch("python_pkg.wake_alarm._alarm._restore_fans"),
|
||||
patch("python_pkg.wake_alarm._alarm._set_max_brightness"),
|
||||
patch("python_pkg.wake_alarm._alarm._wake_display"),
|
||||
patch("python_pkg.wake_alarm._alarm._restore_display"),
|
||||
patch("python_pkg.wake_alarm._alarm._warn_if_no_real_sink"),
|
||||
patch("python_pkg.wake_alarm._alarm._activate_alarm_audio", return_value=None),
|
||||
patch("python_pkg.wake_alarm._alarm._restore_alarm_audio"),
|
||||
patch("python_pkg.wake_alarm._alarm.turn_on_plug"),
|
||||
patch("python_pkg.wake_alarm._alarm.turn_off_plug"),
|
||||
patch("wake_alarm._alarm._play_on_extra_devices") as mock,
|
||||
patch("wake_alarm._alarm._max_fans", return_value=False),
|
||||
patch("wake_alarm._alarm._restore_fans"),
|
||||
patch("wake_alarm._alarm._set_max_brightness"),
|
||||
patch("wake_alarm._alarm._wake_display"),
|
||||
patch("wake_alarm._alarm._restore_display"),
|
||||
patch("wake_alarm._alarm._warn_if_no_real_sink"),
|
||||
patch("wake_alarm._alarm._activate_alarm_audio", return_value=None),
|
||||
patch("wake_alarm._alarm._restore_alarm_audio"),
|
||||
patch("wake_alarm._alarm.turn_on_plug"),
|
||||
patch("wake_alarm._alarm.turn_off_plug"),
|
||||
):
|
||||
yield mock
|
||||
|
||||
@ -71,9 +71,9 @@ def mock_tk_module() -> Generator[MagicMock]:
|
||||
"""Provide explicit access to the mocked tk module."""
|
||||
mock = _make_mock_tk()
|
||||
with (
|
||||
patch("python_pkg.wake_alarm._alarm.tk", mock),
|
||||
patch("wake_alarm._alarm.tk", mock),
|
||||
patch(
|
||||
"python_pkg.wake_alarm._alarm.GateRoot",
|
||||
"wake_alarm._alarm.GateRoot",
|
||||
return_value=mock.Tk.return_value,
|
||||
),
|
||||
):
|
||||
@ -133,7 +133,7 @@ class TestClose:
|
||||
del mock_tk_module
|
||||
alarm = WakeAlarm(demo_mode=True)
|
||||
alarm._hardware.fan_state = True
|
||||
with patch("python_pkg.wake_alarm._alarm._restore_fans") as mock_restore:
|
||||
with patch("wake_alarm._alarm._restore_fans") as mock_restore:
|
||||
alarm.on_close()
|
||||
mock_restore.assert_called_once_with(active=True)
|
||||
alarm._stop_beep.set()
|
||||
@ -147,7 +147,7 @@ class TestClose:
|
||||
alarm = WakeAlarm(demo_mode=True)
|
||||
alarm._hardware.audio_restore = "jbl_sink"
|
||||
with patch(
|
||||
"python_pkg.wake_alarm._alarm._restore_alarm_audio",
|
||||
"wake_alarm._alarm._restore_alarm_audio",
|
||||
) as mock_restore:
|
||||
alarm.on_close()
|
||||
mock_restore.assert_called_once_with("jbl_sink")
|
||||
|
||||
@ -5,8 +5,8 @@ from __future__ import annotations
|
||||
import subprocess
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from python_pkg.wake_alarm._alarm import _parse_args
|
||||
from python_pkg.wake_alarm._audio import (
|
||||
from wake_alarm._alarm import _parse_args
|
||||
from wake_alarm._audio import (
|
||||
_activate_alarm_audio,
|
||||
_alarm_sink_present,
|
||||
_current_default_sink,
|
||||
@ -22,11 +22,11 @@ class TestWarnIfNoRealSink:
|
||||
"""No pactl on PATH → warns and returns."""
|
||||
with (
|
||||
patch(
|
||||
"python_pkg.wake_alarm._audio.shutil.which",
|
||||
"wake_alarm._audio.shutil.which",
|
||||
return_value=None,
|
||||
),
|
||||
patch(
|
||||
"python_pkg.wake_alarm._audio.subprocess.run",
|
||||
"wake_alarm._audio.subprocess.run",
|
||||
) as mock_run,
|
||||
):
|
||||
_warn_if_no_real_sink()
|
||||
@ -38,14 +38,14 @@ class TestWarnIfNoRealSink:
|
||||
result.stdout = b"4319\tauto_null\tPipeWire\tfloat32le 2ch 48000Hz\tIDLE\n"
|
||||
with (
|
||||
patch(
|
||||
"python_pkg.wake_alarm._audio.shutil.which",
|
||||
"wake_alarm._audio.shutil.which",
|
||||
return_value="/usr/bin/pactl",
|
||||
),
|
||||
patch(
|
||||
"python_pkg.wake_alarm._audio.subprocess.run",
|
||||
"wake_alarm._audio.subprocess.run",
|
||||
return_value=result,
|
||||
),
|
||||
patch("python_pkg.wake_alarm._audio._logger") as mock_log,
|
||||
patch("wake_alarm._audio._logger") as mock_log,
|
||||
):
|
||||
_warn_if_no_real_sink()
|
||||
mock_log.warning.assert_called()
|
||||
@ -56,14 +56,14 @@ class TestWarnIfNoRealSink:
|
||||
result.stdout = b"1\talsa_output.pci-0000_01_00.1.hdmi-stereo\tPipeWire\t-\t-\n"
|
||||
with (
|
||||
patch(
|
||||
"python_pkg.wake_alarm._audio.shutil.which",
|
||||
"wake_alarm._audio.shutil.which",
|
||||
return_value="/usr/bin/pactl",
|
||||
),
|
||||
patch(
|
||||
"python_pkg.wake_alarm._audio.subprocess.run",
|
||||
"wake_alarm._audio.subprocess.run",
|
||||
return_value=result,
|
||||
),
|
||||
patch("python_pkg.wake_alarm._audio._logger") as mock_log,
|
||||
patch("wake_alarm._audio._logger") as mock_log,
|
||||
):
|
||||
_warn_if_no_real_sink()
|
||||
mock_log.info.assert_called()
|
||||
@ -73,11 +73,11 @@ class TestWarnIfNoRealSink:
|
||||
"""OSError/TimeoutExpired running pactl → warning, no raise."""
|
||||
with (
|
||||
patch(
|
||||
"python_pkg.wake_alarm._audio.shutil.which",
|
||||
"wake_alarm._audio.shutil.which",
|
||||
return_value="/usr/bin/pactl",
|
||||
),
|
||||
patch(
|
||||
"python_pkg.wake_alarm._audio.subprocess.run",
|
||||
"wake_alarm._audio.subprocess.run",
|
||||
side_effect=subprocess.TimeoutExpired("pactl", 5),
|
||||
),
|
||||
):
|
||||
@ -89,11 +89,11 @@ class TestAlarmSinkPresent:
|
||||
|
||||
def test_true_when_sink_listed(self) -> None:
|
||||
"""Returns True when the alarm sink name appears in pactl output."""
|
||||
from python_pkg.wake_alarm._constants import ALARM_AUDIO_SINK
|
||||
from wake_alarm._constants import ALARM_AUDIO_SINK
|
||||
|
||||
proc = MagicMock(stdout=ALARM_AUDIO_SINK.encode() + b"\tPipeWire\n")
|
||||
with patch(
|
||||
"python_pkg.wake_alarm._audio.subprocess.run",
|
||||
"wake_alarm._audio.subprocess.run",
|
||||
return_value=proc,
|
||||
):
|
||||
assert _alarm_sink_present("/usr/bin/pactl") is True
|
||||
@ -102,7 +102,7 @@ class TestAlarmSinkPresent:
|
||||
"""Returns False when the alarm sink is not in pactl output."""
|
||||
proc = MagicMock(stdout=b"auto_null\tPipeWire\n")
|
||||
with patch(
|
||||
"python_pkg.wake_alarm._audio.subprocess.run",
|
||||
"wake_alarm._audio.subprocess.run",
|
||||
return_value=proc,
|
||||
):
|
||||
assert _alarm_sink_present("/usr/bin/pactl") is False
|
||||
@ -110,7 +110,7 @@ class TestAlarmSinkPresent:
|
||||
def test_false_on_subprocess_error(self) -> None:
|
||||
"""OSError while listing sinks → False, no raise."""
|
||||
with patch(
|
||||
"python_pkg.wake_alarm._audio.subprocess.run",
|
||||
"wake_alarm._audio.subprocess.run",
|
||||
side_effect=OSError("boom"),
|
||||
):
|
||||
assert _alarm_sink_present("/usr/bin/pactl") is False
|
||||
@ -123,7 +123,7 @@ class TestCurrentDefaultSink:
|
||||
"""Returns the trimmed default sink name."""
|
||||
proc = MagicMock(stdout=b"jbl_sink\n")
|
||||
with patch(
|
||||
"python_pkg.wake_alarm._audio.subprocess.run",
|
||||
"wake_alarm._audio.subprocess.run",
|
||||
return_value=proc,
|
||||
):
|
||||
assert _current_default_sink("/usr/bin/pactl") == "jbl_sink"
|
||||
@ -132,7 +132,7 @@ class TestCurrentDefaultSink:
|
||||
"""Empty output → None."""
|
||||
proc = MagicMock(stdout=b"\n")
|
||||
with patch(
|
||||
"python_pkg.wake_alarm._audio.subprocess.run",
|
||||
"wake_alarm._audio.subprocess.run",
|
||||
return_value=proc,
|
||||
):
|
||||
assert _current_default_sink("/usr/bin/pactl") is None
|
||||
@ -140,7 +140,7 @@ class TestCurrentDefaultSink:
|
||||
def test_returns_none_on_error(self) -> None:
|
||||
"""TimeoutExpired → None, no raise."""
|
||||
with patch(
|
||||
"python_pkg.wake_alarm._audio.subprocess.run",
|
||||
"wake_alarm._audio.subprocess.run",
|
||||
side_effect=subprocess.TimeoutExpired("pactl", 3),
|
||||
):
|
||||
assert _current_default_sink("/usr/bin/pactl") is None
|
||||
@ -152,8 +152,8 @@ class TestActivateAlarmAudio:
|
||||
def test_returns_none_when_pactl_missing(self) -> None:
|
||||
"""No pactl on PATH → returns None without touching audio."""
|
||||
with (
|
||||
patch("python_pkg.wake_alarm._audio.shutil.which", return_value=None),
|
||||
patch("python_pkg.wake_alarm._audio.subprocess.run") as mock_run,
|
||||
patch("wake_alarm._audio.shutil.which", return_value=None),
|
||||
patch("wake_alarm._audio.subprocess.run") as mock_run,
|
||||
):
|
||||
assert _activate_alarm_audio() is None
|
||||
mock_run.assert_not_called()
|
||||
@ -162,23 +162,23 @@ class TestActivateAlarmAudio:
|
||||
"""Sink present → routes audio there and returns prior default sink."""
|
||||
with (
|
||||
patch(
|
||||
"python_pkg.wake_alarm._audio.shutil.which",
|
||||
"wake_alarm._audio.shutil.which",
|
||||
return_value="/usr/bin/pactl",
|
||||
),
|
||||
patch(
|
||||
"python_pkg.wake_alarm._audio._alarm_sink_present",
|
||||
"wake_alarm._audio._alarm_sink_present",
|
||||
return_value=True,
|
||||
),
|
||||
patch(
|
||||
"python_pkg.wake_alarm._audio._current_default_sink",
|
||||
"wake_alarm._audio._current_default_sink",
|
||||
return_value="jbl_sink",
|
||||
),
|
||||
patch("python_pkg.wake_alarm._audio.subprocess.run") as mock_run,
|
||||
patch("wake_alarm._audio.subprocess.run") as mock_run,
|
||||
):
|
||||
result = _activate_alarm_audio()
|
||||
assert result == "jbl_sink"
|
||||
cmds = [call.args[0] for call in mock_run.call_args_list]
|
||||
from python_pkg.wake_alarm._constants import (
|
||||
from wake_alarm._constants import (
|
||||
ALARM_AUDIO_CARD,
|
||||
ALARM_AUDIO_PROFILE,
|
||||
ALARM_AUDIO_SINK,
|
||||
@ -196,15 +196,15 @@ class TestActivateAlarmAudio:
|
||||
"""Sink never shows up → returns None after polling (no raise)."""
|
||||
with (
|
||||
patch(
|
||||
"python_pkg.wake_alarm._audio.shutil.which",
|
||||
"wake_alarm._audio.shutil.which",
|
||||
return_value="/usr/bin/pactl",
|
||||
),
|
||||
patch(
|
||||
"python_pkg.wake_alarm._audio._alarm_sink_present",
|
||||
"wake_alarm._audio._alarm_sink_present",
|
||||
return_value=False,
|
||||
),
|
||||
patch("python_pkg.wake_alarm._audio.time.sleep") as mock_sleep,
|
||||
patch("python_pkg.wake_alarm._audio.subprocess.run"),
|
||||
patch("wake_alarm._audio.time.sleep") as mock_sleep,
|
||||
patch("wake_alarm._audio.subprocess.run"),
|
||||
):
|
||||
assert _activate_alarm_audio() is None
|
||||
mock_sleep.assert_called()
|
||||
@ -213,19 +213,19 @@ class TestActivateAlarmAudio:
|
||||
"""Sink absent then present → sleeps once, then routes audio."""
|
||||
with (
|
||||
patch(
|
||||
"python_pkg.wake_alarm._audio.shutil.which",
|
||||
"wake_alarm._audio.shutil.which",
|
||||
return_value="/usr/bin/pactl",
|
||||
),
|
||||
patch(
|
||||
"python_pkg.wake_alarm._audio._alarm_sink_present",
|
||||
"wake_alarm._audio._alarm_sink_present",
|
||||
side_effect=[False, True],
|
||||
),
|
||||
patch(
|
||||
"python_pkg.wake_alarm._audio._current_default_sink",
|
||||
"wake_alarm._audio._current_default_sink",
|
||||
return_value="old",
|
||||
),
|
||||
patch("python_pkg.wake_alarm._audio.time.sleep") as mock_sleep,
|
||||
patch("python_pkg.wake_alarm._audio.subprocess.run"),
|
||||
patch("wake_alarm._audio.time.sleep") as mock_sleep,
|
||||
patch("wake_alarm._audio.subprocess.run"),
|
||||
):
|
||||
assert _activate_alarm_audio() == "old"
|
||||
mock_sleep.assert_called_once()
|
||||
@ -236,15 +236,15 @@ class TestRestoreAlarmAudio:
|
||||
|
||||
def test_none_is_noop(self) -> None:
|
||||
"""None default → does nothing, no pactl lookup."""
|
||||
with patch("python_pkg.wake_alarm._audio.shutil.which") as mock_which:
|
||||
with patch("wake_alarm._audio.shutil.which") as mock_which:
|
||||
_restore_alarm_audio(None)
|
||||
mock_which.assert_not_called()
|
||||
|
||||
def test_no_pactl_returns_silently(self) -> None:
|
||||
"""Default present but pactl missing → no raise, no run."""
|
||||
with (
|
||||
patch("python_pkg.wake_alarm._audio.shutil.which", return_value=None),
|
||||
patch("python_pkg.wake_alarm._audio.subprocess.run") as mock_run,
|
||||
patch("wake_alarm._audio.shutil.which", return_value=None),
|
||||
patch("wake_alarm._audio.subprocess.run") as mock_run,
|
||||
):
|
||||
_restore_alarm_audio("jbl_sink")
|
||||
mock_run.assert_not_called()
|
||||
@ -253,10 +253,10 @@ class TestRestoreAlarmAudio:
|
||||
"""Calls set-default-sink with the captured prior default."""
|
||||
with (
|
||||
patch(
|
||||
"python_pkg.wake_alarm._audio.shutil.which",
|
||||
"wake_alarm._audio.shutil.which",
|
||||
return_value="/usr/bin/pactl",
|
||||
),
|
||||
patch("python_pkg.wake_alarm._audio.subprocess.run") as mock_run,
|
||||
patch("wake_alarm._audio.subprocess.run") as mock_run,
|
||||
):
|
||||
_restore_alarm_audio("jbl_sink")
|
||||
cmds = [call.args[0] for call in mock_run.call_args_list]
|
||||
|
||||
@ -9,8 +9,8 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from python_pkg.wake_alarm import _smart_plug
|
||||
from python_pkg.wake_alarm._smart_plug import (
|
||||
from wake_alarm import _smart_plug
|
||||
from wake_alarm._smart_plug import (
|
||||
_connect,
|
||||
_load_config,
|
||||
_run,
|
||||
|
||||
@ -8,7 +8,7 @@ from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from python_pkg.wake_alarm._state import (
|
||||
from wake_alarm._state import (
|
||||
_today_str,
|
||||
has_workout_skip_today,
|
||||
load_wake_state,
|
||||
@ -31,7 +31,7 @@ def wake_state_file(tmp_path: Path) -> Path:
|
||||
def _patch_wake_state_file(wake_state_file: Path) -> None:
|
||||
"""Redirect WAKE_STATE_FILE to tmp_path for all tests."""
|
||||
with patch(
|
||||
"python_pkg.wake_alarm._state.WAKE_STATE_FILE",
|
||||
"wake_alarm._state.WAKE_STATE_FILE",
|
||||
wake_state_file,
|
||||
):
|
||||
yield
|
||||
@ -54,7 +54,7 @@ class TestSaveWakeState:
|
||||
def test_saves_with_hmac(self, wake_state_file: Path) -> None:
|
||||
"""Save state with HMAC signature when key is available."""
|
||||
with patch(
|
||||
"python_pkg.wake_alarm._state.compute_entry_hmac",
|
||||
"wake_alarm._state.compute_entry_hmac",
|
||||
return_value="fakesig",
|
||||
):
|
||||
result = save_wake_state(
|
||||
@ -72,7 +72,7 @@ class TestSaveWakeState:
|
||||
def test_saves_without_hmac(self, wake_state_file: Path) -> None:
|
||||
"""Save unsigned state when HMAC key is unavailable."""
|
||||
with patch(
|
||||
"python_pkg.wake_alarm._state.compute_entry_hmac",
|
||||
"wake_alarm._state.compute_entry_hmac",
|
||||
return_value=None,
|
||||
):
|
||||
result = save_wake_state(
|
||||
@ -89,11 +89,11 @@ class TestSaveWakeState:
|
||||
"""Return False when file cannot be written."""
|
||||
with (
|
||||
patch(
|
||||
"python_pkg.wake_alarm._state.compute_entry_hmac",
|
||||
"wake_alarm._state.compute_entry_hmac",
|
||||
return_value="sig",
|
||||
),
|
||||
patch(
|
||||
"python_pkg.wake_alarm._state.WAKE_STATE_FILE",
|
||||
"wake_alarm._state.WAKE_STATE_FILE",
|
||||
wake_state_file / "nonexistent_dir" / "file.json",
|
||||
),
|
||||
):
|
||||
@ -147,7 +147,7 @@ class TestLoadWakeState:
|
||||
}
|
||||
wake_state_file.write_text(json.dumps(state))
|
||||
with patch(
|
||||
"python_pkg.wake_alarm._state.verify_entry_hmac",
|
||||
"wake_alarm._state.verify_entry_hmac",
|
||||
return_value=False,
|
||||
):
|
||||
assert load_wake_state() is None
|
||||
@ -165,7 +165,7 @@ class TestLoadWakeState:
|
||||
}
|
||||
wake_state_file.write_text(json.dumps(state))
|
||||
with patch(
|
||||
"python_pkg.wake_alarm._state.verify_entry_hmac",
|
||||
"wake_alarm._state.verify_entry_hmac",
|
||||
return_value=True,
|
||||
):
|
||||
result = load_wake_state()
|
||||
@ -194,7 +194,7 @@ class TestHasWorkoutSkipToday:
|
||||
}
|
||||
wake_state_file.write_text(json.dumps(state))
|
||||
with patch(
|
||||
"python_pkg.wake_alarm._state.verify_entry_hmac",
|
||||
"wake_alarm._state.verify_entry_hmac",
|
||||
return_value=True,
|
||||
):
|
||||
assert has_workout_skip_today() is True
|
||||
@ -212,7 +212,7 @@ class TestHasWorkoutSkipToday:
|
||||
}
|
||||
wake_state_file.write_text(json.dumps(state))
|
||||
with patch(
|
||||
"python_pkg.wake_alarm._state.verify_entry_hmac",
|
||||
"wake_alarm._state.verify_entry_hmac",
|
||||
return_value=True,
|
||||
):
|
||||
assert has_workout_skip_today() is False
|
||||
@ -238,7 +238,7 @@ class TestWasAlarmDismissedToday:
|
||||
}
|
||||
wake_state_file.write_text(json.dumps(state))
|
||||
with patch(
|
||||
"python_pkg.wake_alarm._state.verify_entry_hmac",
|
||||
"wake_alarm._state.verify_entry_hmac",
|
||||
return_value=True,
|
||||
):
|
||||
assert was_alarm_dismissed_today() is True
|
||||
@ -256,7 +256,7 @@ class TestWasAlarmDismissedToday:
|
||||
}
|
||||
wake_state_file.write_text(json.dumps(state))
|
||||
with patch(
|
||||
"python_pkg.wake_alarm._state.verify_entry_hmac",
|
||||
"wake_alarm._state.verify_entry_hmac",
|
||||
return_value=True,
|
||||
):
|
||||
assert was_alarm_dismissed_today() is False
|
||||
@ -268,7 +268,7 @@ class TestWasWorkoutLoggedToday:
|
||||
def test_returns_false_when_file_missing(self, tmp_path: Path) -> None:
|
||||
"""Return False when the workout log file does not exist."""
|
||||
with patch(
|
||||
"python_pkg.wake_alarm._state.WORKOUT_LOG_FILE",
|
||||
"wake_alarm._state.WORKOUT_LOG_FILE",
|
||||
tmp_path / "workout_log.json",
|
||||
):
|
||||
assert was_workout_logged_today() is False
|
||||
@ -278,7 +278,7 @@ class TestWasWorkoutLoggedToday:
|
||||
log_file = tmp_path / "workout_log.json"
|
||||
log_file.write_text("not json {{{")
|
||||
with patch(
|
||||
"python_pkg.wake_alarm._state.WORKOUT_LOG_FILE",
|
||||
"wake_alarm._state.WORKOUT_LOG_FILE",
|
||||
log_file,
|
||||
):
|
||||
assert was_workout_logged_today() is False
|
||||
@ -288,7 +288,7 @@ class TestWasWorkoutLoggedToday:
|
||||
log_file = tmp_path / "workout_log.json"
|
||||
log_file.write_text(json.dumps([1, 2, 3]))
|
||||
with patch(
|
||||
"python_pkg.wake_alarm._state.WORKOUT_LOG_FILE",
|
||||
"wake_alarm._state.WORKOUT_LOG_FILE",
|
||||
log_file,
|
||||
):
|
||||
assert was_workout_logged_today() is False
|
||||
@ -298,7 +298,7 @@ class TestWasWorkoutLoggedToday:
|
||||
log_file = tmp_path / "workout_log.json"
|
||||
log_file.write_text(json.dumps({"1999-01-01": {"type": "old"}}))
|
||||
with patch(
|
||||
"python_pkg.wake_alarm._state.WORKOUT_LOG_FILE",
|
||||
"wake_alarm._state.WORKOUT_LOG_FILE",
|
||||
log_file,
|
||||
):
|
||||
assert was_workout_logged_today() is False
|
||||
@ -308,7 +308,7 @@ class TestWasWorkoutLoggedToday:
|
||||
log_file = tmp_path / "workout_log.json"
|
||||
log_file.write_text(json.dumps({_today_str(): {"type": "phone_verified"}}))
|
||||
with patch(
|
||||
"python_pkg.wake_alarm._state.WORKOUT_LOG_FILE",
|
||||
"wake_alarm._state.WORKOUT_LOG_FILE",
|
||||
log_file,
|
||||
):
|
||||
assert was_workout_logged_today() is True
|
||||
|
||||
@ -1,20 +0,0 @@
|
||||
[Unit]
|
||||
Description=Weekend Wake Alarm
|
||||
After=graphical-session.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
# DISPLAY/PYTHONPATH mirror workout-locker.service: without DISPLAY the alarm
|
||||
# crashes on cold boot with "no display name and no $DISPLAY" (Tk can't open
|
||||
# the X server) before systemd retries. The short sleep lets the X session
|
||||
# export DISPLAY into the user environment first.
|
||||
Environment=DISPLAY=:0
|
||||
Environment=PYTHONPATH=%h/testsAndMisc
|
||||
ExecStartPre=/bin/sleep 1
|
||||
ExecStart=/usr/bin/python -m python_pkg.wake_alarm._alarm --production
|
||||
WorkingDirectory=%h/testsAndMisc
|
||||
Restart=on-failure
|
||||
RestartSec=10
|
||||
|
||||
[Install]
|
||||
WantedBy=graphical-session.target
|
||||
@ -1,6 +0,0 @@
|
||||
{
|
||||
"date": "2026-06-14",
|
||||
"dismissed_at": "2026-06-14T05:01:28.589654+00:00",
|
||||
"skip_workout": true,
|
||||
"hmac": "b472bf9b0874ff3f6f460cace7965d53cdfce823ee6f2d1f91914e43f003e92b"
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user