mirror of
https://github.com/kuhyx/wake-alarm.git
synced 2026-07-04 13:23: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
|
# Usage: bash install.sh
|
||||||
#
|
#
|
||||||
# What it does:
|
# What it does:
|
||||||
# 1. Copies wake-alarm.service to ~/.config/systemd/user/
|
# 1. Installs wake_alarm + dependencies for /usr/bin/python
|
||||||
# 2. Enables and starts the service
|
# 2. Installs system dependencies (alsa-utils, ddcutil)
|
||||||
# 3. Installs the systemd-sleep hook (restarts alarm after hibernate resume)
|
# 3. Copies wake-alarm.service to ~/.config/systemd/user/ and enables it
|
||||||
# 4. Adds a sudoers entry for passwordless rtcwake
|
# 4. Installs the systemd-sleep hook (restarts alarm after hibernate resume)
|
||||||
# 5. Installs shutdown wrapper so "shutdown now" also hibernates on alarm nights
|
# 5. Adds a sudoers entry for passwordless rtcwake
|
||||||
# 6. Installs fan-control script so alarm can max fans on wake
|
# 6. Installs shutdown wrapper so "shutdown now" also hibernates on alarm nights
|
||||||
# 7. Installs python-kasa (AUR) so the alarm can toggle a Tapo P110 smart plug
|
# 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
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Split declare/assign so the command-substitution exit code is not masked (SC2155).
|
||||||
SCRIPT_DIR="$(dirname "$(readlink -f "$0")")"
|
SCRIPT_DIR="$(dirname "$(readlink -f "$0")")"
|
||||||
|
readonly SCRIPT_DIR
|
||||||
|
readonly REPO_DIR="$SCRIPT_DIR"
|
||||||
SERVICE_FILE="$SCRIPT_DIR/wake-alarm.service"
|
SERVICE_FILE="$SCRIPT_DIR/wake-alarm.service"
|
||||||
SLEEP_HOOK_SRC="$SCRIPT_DIR/sleep-hook.sh"
|
SLEEP_HOOK_SRC="$SCRIPT_DIR/sleep-hook.sh"
|
||||||
SHUTDOWN_WRAPPER_SRC="$SCRIPT_DIR/shutdown-wrapper.sh"
|
SHUTDOWN_WRAPPER_SRC="$SCRIPT_DIR/shutdown-wrapper.sh"
|
||||||
@ -28,8 +33,15 @@ RTCWAKE_BIN="/usr/sbin/rtcwake"
|
|||||||
|
|
||||||
echo "=== Weekend Wake Alarm Installer ==="
|
echo "=== Weekend Wake Alarm Installer ==="
|
||||||
|
|
||||||
# 0. Install system dependencies
|
# 1. Install this package + its dependencies into system Python -------------
|
||||||
echo "[0/7] Checking system dependencies..."
|
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
|
if ! command -v speaker-test &>/dev/null; then
|
||||||
echo " Installing alsa-utils (required for speaker-test)..."
|
echo " Installing alsa-utils (required for speaker-test)..."
|
||||||
sudo pacman -S --noconfirm alsa-utils
|
sudo pacman -S --noconfirm alsa-utils
|
||||||
@ -37,26 +49,23 @@ else
|
|||||||
echo " alsa-utils already installed"
|
echo " alsa-utils already installed"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 1. Install systemd user service
|
# 3. Install systemd user service
|
||||||
echo "[1/7] Installing systemd user service..."
|
echo "[3/9] Installing systemd user service..."
|
||||||
mkdir -p "$SYSTEMD_USER_DIR"
|
mkdir -p "$SYSTEMD_USER_DIR"
|
||||||
cp "$SERVICE_FILE" "$SYSTEMD_USER_DIR/wake-alarm.service"
|
cp "$SERVICE_FILE" "$SYSTEMD_USER_DIR/wake-alarm.service"
|
||||||
systemctl --user daemon-reload
|
systemctl --user daemon-reload
|
||||||
echo " Installed to $SYSTEMD_USER_DIR/wake-alarm.service"
|
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
|
systemctl --user enable wake-alarm.service
|
||||||
echo " Service enabled (will start on next boot)"
|
echo " Service enabled (will start on next boot)"
|
||||||
|
|
||||||
# 3. Install systemd-sleep hook (restarts alarm after hibernate resume)
|
# 4. Install systemd-sleep hook (restarts alarm after hibernate resume)
|
||||||
echo "[3/7] Installing systemd-sleep hook..."
|
echo "[4/9] Installing systemd-sleep hook..."
|
||||||
sudo cp "$SLEEP_HOOK_SRC" "$SLEEP_HOOK_DST"
|
sudo cp "$SLEEP_HOOK_SRC" "$SLEEP_HOOK_DST"
|
||||||
sudo chmod 0755 "$SLEEP_HOOK_DST"
|
sudo chmod 0755 "$SLEEP_HOOK_DST"
|
||||||
echo " Installed to $SLEEP_HOOK_DST"
|
echo " Installed to $SLEEP_HOOK_DST"
|
||||||
|
|
||||||
# 4. Add sudoers entry for rtcwake (requires root)
|
# 5. Add sudoers entry for rtcwake (requires root)
|
||||||
echo "[4/7] Setting up sudoers for rtcwake..."
|
echo "[5/9] Setting up sudoers for rtcwake..."
|
||||||
SUDOERS_LINE="$USER ALL=(root) NOPASSWD: $RTCWAKE_BIN"
|
SUDOERS_LINE="$USER ALL=(root) NOPASSWD: $RTCWAKE_BIN"
|
||||||
if [[ -f "$SUDOERS_FILE" ]] && grep -qF "$SUDOERS_LINE" "$SUDOERS_FILE"; then
|
if [[ -f "$SUDOERS_FILE" ]] && grep -qF "$SUDOERS_LINE" "$SUDOERS_FILE"; then
|
||||||
echo " Sudoers entry already exists"
|
echo " Sudoers entry already exists"
|
||||||
@ -67,15 +76,15 @@ else
|
|||||||
echo " Added: $SUDOERS_LINE"
|
echo " Added: $SUDOERS_LINE"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 5. Install shutdown wrapper (/usr/local/bin/shutdown shadows /usr/bin/shutdown)
|
# 6. Install shutdown wrapper (/usr/local/bin/shutdown shadows /usr/bin/shutdown)
|
||||||
echo "[5/7] Installing shutdown wrapper..."
|
echo "[6/9] Installing shutdown wrapper..."
|
||||||
sudo cp "$SHUTDOWN_WRAPPER_SRC" "$SHUTDOWN_WRAPPER_DST"
|
sudo cp "$SHUTDOWN_WRAPPER_SRC" "$SHUTDOWN_WRAPPER_DST"
|
||||||
sudo chmod 0755 "$SHUTDOWN_WRAPPER_DST"
|
sudo chmod 0755 "$SHUTDOWN_WRAPPER_DST"
|
||||||
echo " Installed to $SHUTDOWN_WRAPPER_DST"
|
echo " Installed to $SHUTDOWN_WRAPPER_DST"
|
||||||
echo " 'shutdown now' will now hibernate (not poweroff) on alarm nights."
|
echo " 'shutdown now' will now hibernate (not poweroff) on alarm nights."
|
||||||
|
|
||||||
# 6. Install fan-control script and its sudoers entry
|
# 7. Install fan-control script and its sudoers entry
|
||||||
echo "[6/7] Installing fan-control script..."
|
echo "[7/9] Installing fan-control script..."
|
||||||
sudo cp "$FANS_SCRIPT_SRC" "$FANS_SCRIPT_DST"
|
sudo cp "$FANS_SCRIPT_SRC" "$FANS_SCRIPT_DST"
|
||||||
sudo chmod 0755 "$FANS_SCRIPT_DST"
|
sudo chmod 0755 "$FANS_SCRIPT_DST"
|
||||||
FANS_SUDOERS_LINE="$USER ALL=(root) NOPASSWD: $FANS_SCRIPT_DST"
|
FANS_SUDOERS_LINE="$USER ALL=(root) NOPASSWD: $FANS_SCRIPT_DST"
|
||||||
@ -88,8 +97,8 @@ else
|
|||||||
echo " Added fan sudoers entry"
|
echo " Added fan sudoers entry"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 7. Install python-kasa (AUR) for TP-Link Tapo P110 smart-plug control
|
# 8. Install python-kasa (AUR) for TP-Link Tapo P110 smart-plug control
|
||||||
echo "[7/8] Installing python-kasa (AUR)..."
|
echo "[8/9] Installing python-kasa (AUR)..."
|
||||||
if python -c 'import kasa' 2>/dev/null; then
|
if python -c 'import kasa' 2>/dev/null; then
|
||||||
echo " python-kasa already installed"
|
echo " python-kasa already installed"
|
||||||
elif command -v yay &>/dev/null; then
|
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."
|
echo " Create it (mode 0600) with keys: host, email, password."
|
||||||
fi
|
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
|
# ddcutil lets the alarm force the G27Q on via DDC/CI even when the monitor
|
||||||
# was physically powered off (power button), bypassing DPMS limitations.
|
# 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
|
if command -v ddcutil &>/dev/null; then
|
||||||
echo " ddcutil already installed"
|
echo " ddcutil already installed"
|
||||||
else
|
else
|
||||||
@ -128,4 +137,4 @@ echo "=== Installation complete ==="
|
|||||||
echo "The wake alarm will activate on boot for alarm days (Mon, Fri, Sat, Sun)."
|
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 "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 "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
|
REAL_SHUTDOWN=/usr/bin/shutdown
|
||||||
RTCWAKE=/usr/sbin/rtcwake
|
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.
|
# Pass through reboots and cancel commands unchanged.
|
||||||
for arg in "$@"; do
|
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 gatelock import GateRoot, LockConfig, LockWindow
|
||||||
|
|
||||||
from python_pkg.shared.logging_setup import configure_logging
|
from wake_alarm._alarm_display import _restore_display, _wake_display
|
||||||
from python_pkg.wake_alarm._alarm_display import _restore_display, _wake_display
|
from wake_alarm._audio import (
|
||||||
from python_pkg.wake_alarm._audio import (
|
|
||||||
_activate_alarm_audio,
|
_activate_alarm_audio,
|
||||||
_beep_loud,
|
_beep_loud,
|
||||||
_beep_medium,
|
_beep_medium,
|
||||||
@ -34,11 +33,11 @@ from python_pkg.wake_alarm._audio import (
|
|||||||
_set_max_brightness,
|
_set_max_brightness,
|
||||||
_warn_if_no_real_sink,
|
_warn_if_no_real_sink,
|
||||||
)
|
)
|
||||||
from python_pkg.wake_alarm._challenges import (
|
from wake_alarm._challenges import (
|
||||||
_Challenge,
|
_Challenge,
|
||||||
_make_challenge,
|
_make_challenge,
|
||||||
)
|
)
|
||||||
from python_pkg.wake_alarm._constants import (
|
from wake_alarm._constants import (
|
||||||
ALARM_DAYS,
|
ALARM_DAYS,
|
||||||
DISMISS_CODE_REFRESH_SECONDS,
|
DISMISS_CODE_REFRESH_SECONDS,
|
||||||
DISMISS_FLASH_SECONDS,
|
DISMISS_FLASH_SECONDS,
|
||||||
@ -51,8 +50,9 @@ from python_pkg.wake_alarm._constants import (
|
|||||||
PHASE_SOFT_END,
|
PHASE_SOFT_END,
|
||||||
SOFT_BEEP_INTERVAL,
|
SOFT_BEEP_INTERVAL,
|
||||||
)
|
)
|
||||||
from python_pkg.wake_alarm._smart_plug import turn_off_plug, turn_on_plug
|
from wake_alarm._logging_setup import configure_logging
|
||||||
from python_pkg.wake_alarm._state import (
|
from wake_alarm._smart_plug import turn_off_plug, turn_on_plug
|
||||||
|
from wake_alarm._state import (
|
||||||
save_wake_state,
|
save_wake_state,
|
||||||
was_alarm_dismissed_today,
|
was_alarm_dismissed_today,
|
||||||
was_workout_logged_today,
|
was_workout_logged_today,
|
||||||
|
|||||||
@ -14,7 +14,7 @@ import tempfile
|
|||||||
import time
|
import time
|
||||||
import wave
|
import wave
|
||||||
|
|
||||||
from python_pkg.wake_alarm._constants import (
|
from wake_alarm._constants import (
|
||||||
ALARM_AUDIO_CARD,
|
ALARM_AUDIO_CARD,
|
||||||
ALARM_AUDIO_PROFILE,
|
ALARM_AUDIO_PROFILE,
|
||||||
ALARM_AUDIO_SINK,
|
ALARM_AUDIO_SINK,
|
||||||
|
|||||||
@ -10,7 +10,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import secrets
|
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:
|
# 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
|
# 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
|
import logging
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from python_pkg.wake_alarm._constants import (
|
from wake_alarm._constants import (
|
||||||
TAPO_CONFIG_FILE,
|
TAPO_CONFIG_FILE,
|
||||||
TAPO_TIMEOUT_SECONDS,
|
TAPO_TIMEOUT_SECONDS,
|
||||||
)
|
)
|
||||||
|
|||||||
@ -11,7 +11,7 @@ from gatelock.log_integrity import (
|
|||||||
verify_entry_hmac,
|
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__)
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|||||||
@ -13,11 +13,11 @@ import pytest
|
|||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import Generator
|
from collections.abc import Generator
|
||||||
|
|
||||||
from python_pkg.wake_alarm._alarm import (
|
from wake_alarm._alarm import (
|
||||||
_is_alarm_day,
|
_is_alarm_day,
|
||||||
_should_run_alarm,
|
_should_run_alarm,
|
||||||
)
|
)
|
||||||
from python_pkg.wake_alarm._audio import (
|
from wake_alarm._audio import (
|
||||||
_beep_loud,
|
_beep_loud,
|
||||||
_beep_medium,
|
_beep_medium,
|
||||||
_beep_soft,
|
_beep_soft,
|
||||||
@ -27,11 +27,11 @@ from python_pkg.wake_alarm._audio import (
|
|||||||
_restore_fans,
|
_restore_fans,
|
||||||
_speaker_test_path,
|
_speaker_test_path,
|
||||||
)
|
)
|
||||||
from python_pkg.wake_alarm._challenges import (
|
from wake_alarm._challenges import (
|
||||||
_DISMISS_CHARS,
|
_DISMISS_CHARS,
|
||||||
_generate_code,
|
_generate_code,
|
||||||
)
|
)
|
||||||
from python_pkg.wake_alarm._constants import (
|
from wake_alarm._constants import (
|
||||||
DISMISS_CODE_LENGTH,
|
DISMISS_CODE_LENGTH,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -60,9 +60,9 @@ def _block_real_tk() -> Generator[MagicMock]:
|
|||||||
"""Prevent any real Tk windows in tests."""
|
"""Prevent any real Tk windows in tests."""
|
||||||
mock = _make_mock_tk()
|
mock = _make_mock_tk()
|
||||||
with (
|
with (
|
||||||
patch("python_pkg.wake_alarm._alarm.tk", mock),
|
patch("wake_alarm._alarm.tk", mock),
|
||||||
patch(
|
patch(
|
||||||
"python_pkg.wake_alarm._alarm.GateRoot",
|
"wake_alarm._alarm.GateRoot",
|
||||||
return_value=mock.Tk.return_value,
|
return_value=mock.Tk.return_value,
|
||||||
),
|
),
|
||||||
):
|
):
|
||||||
@ -74,9 +74,9 @@ def mock_tk_module() -> Generator[MagicMock]:
|
|||||||
"""Provide explicit access to the mocked tk module."""
|
"""Provide explicit access to the mocked tk module."""
|
||||||
mock = _make_mock_tk()
|
mock = _make_mock_tk()
|
||||||
with (
|
with (
|
||||||
patch("python_pkg.wake_alarm._alarm.tk", mock),
|
patch("wake_alarm._alarm.tk", mock),
|
||||||
patch(
|
patch(
|
||||||
"python_pkg.wake_alarm._alarm.GateRoot",
|
"wake_alarm._alarm.GateRoot",
|
||||||
return_value=mock.Tk.return_value,
|
return_value=mock.Tk.return_value,
|
||||||
),
|
),
|
||||||
):
|
):
|
||||||
@ -117,7 +117,7 @@ class TestIsAlarmDay:
|
|||||||
|
|
||||||
# Create a date that is Monday
|
# Create a date that is Monday
|
||||||
with patch(
|
with patch(
|
||||||
"python_pkg.wake_alarm._alarm.datetime",
|
"wake_alarm._alarm.datetime",
|
||||||
) as mock_dt:
|
) as mock_dt:
|
||||||
mock_now = MagicMock()
|
mock_now = MagicMock()
|
||||||
mock_now.weekday.return_value = 0 # Monday
|
mock_now.weekday.return_value = 0 # Monday
|
||||||
@ -128,7 +128,7 @@ class TestIsAlarmDay:
|
|||||||
def test_tuesday_is_not_alarm_day(self) -> None:
|
def test_tuesday_is_not_alarm_day(self) -> None:
|
||||||
"""Tuesday (weekday=1) is NOT an alarm day."""
|
"""Tuesday (weekday=1) is NOT an alarm day."""
|
||||||
with patch(
|
with patch(
|
||||||
"python_pkg.wake_alarm._alarm.datetime",
|
"wake_alarm._alarm.datetime",
|
||||||
) as mock_dt:
|
) as mock_dt:
|
||||||
mock_now = MagicMock()
|
mock_now = MagicMock()
|
||||||
mock_now.weekday.return_value = 1 # Tuesday
|
mock_now.weekday.return_value = 1 # Tuesday
|
||||||
@ -138,7 +138,7 @@ class TestIsAlarmDay:
|
|||||||
def test_friday_is_alarm_day(self) -> None:
|
def test_friday_is_alarm_day(self) -> None:
|
||||||
"""Friday (weekday=4) is an alarm day."""
|
"""Friday (weekday=4) is an alarm day."""
|
||||||
with patch(
|
with patch(
|
||||||
"python_pkg.wake_alarm._alarm.datetime",
|
"wake_alarm._alarm.datetime",
|
||||||
) as mock_dt:
|
) as mock_dt:
|
||||||
mock_now = MagicMock()
|
mock_now = MagicMock()
|
||||||
mock_now.weekday.return_value = 4 # Friday
|
mock_now.weekday.return_value = 4 # Friday
|
||||||
@ -148,7 +148,7 @@ class TestIsAlarmDay:
|
|||||||
def test_saturday_is_alarm_day(self) -> None:
|
def test_saturday_is_alarm_day(self) -> None:
|
||||||
"""Saturday (weekday=5) is an alarm day."""
|
"""Saturday (weekday=5) is an alarm day."""
|
||||||
with patch(
|
with patch(
|
||||||
"python_pkg.wake_alarm._alarm.datetime",
|
"wake_alarm._alarm.datetime",
|
||||||
) as mock_dt:
|
) as mock_dt:
|
||||||
mock_now = MagicMock()
|
mock_now = MagicMock()
|
||||||
mock_now.weekday.return_value = 5
|
mock_now.weekday.return_value = 5
|
||||||
@ -158,7 +158,7 @@ class TestIsAlarmDay:
|
|||||||
def test_sunday_is_alarm_day(self) -> None:
|
def test_sunday_is_alarm_day(self) -> None:
|
||||||
"""Sunday (weekday=6) is an alarm day."""
|
"""Sunday (weekday=6) is an alarm day."""
|
||||||
with patch(
|
with patch(
|
||||||
"python_pkg.wake_alarm._alarm.datetime",
|
"wake_alarm._alarm.datetime",
|
||||||
) as mock_dt:
|
) as mock_dt:
|
||||||
mock_now = MagicMock()
|
mock_now = MagicMock()
|
||||||
mock_now.weekday.return_value = 6
|
mock_now.weekday.return_value = 6
|
||||||
@ -168,7 +168,7 @@ class TestIsAlarmDay:
|
|||||||
def test_wednesday_is_not_alarm_day(self) -> None:
|
def test_wednesday_is_not_alarm_day(self) -> None:
|
||||||
"""Wednesday (weekday=2) is NOT an alarm day."""
|
"""Wednesday (weekday=2) is NOT an alarm day."""
|
||||||
with patch(
|
with patch(
|
||||||
"python_pkg.wake_alarm._alarm.datetime",
|
"wake_alarm._alarm.datetime",
|
||||||
) as mock_dt:
|
) as mock_dt:
|
||||||
mock_now = MagicMock()
|
mock_now = MagicMock()
|
||||||
mock_now.weekday.return_value = 2
|
mock_now.weekday.return_value = 2
|
||||||
@ -182,7 +182,7 @@ class TestSpeakerTestPath:
|
|||||||
def test_returns_path_when_found(self) -> None:
|
def test_returns_path_when_found(self) -> None:
|
||||||
"""Return full path when speaker-test is available."""
|
"""Return full path when speaker-test is available."""
|
||||||
with patch(
|
with patch(
|
||||||
"python_pkg.wake_alarm._audio.shutil.which",
|
"wake_alarm._audio.shutil.which",
|
||||||
return_value="/usr/bin/speaker-test",
|
return_value="/usr/bin/speaker-test",
|
||||||
):
|
):
|
||||||
assert _speaker_test_path() == "/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."""
|
"""Raise FileNotFoundError when speaker-test is missing."""
|
||||||
with (
|
with (
|
||||||
patch(
|
patch(
|
||||||
"python_pkg.wake_alarm._audio.shutil.which",
|
"wake_alarm._audio.shutil.which",
|
||||||
return_value=None,
|
return_value=None,
|
||||||
),
|
),
|
||||||
pytest.raises(FileNotFoundError, match="speaker-test not found"),
|
pytest.raises(FileNotFoundError, match="speaker-test not found"),
|
||||||
@ -204,7 +204,7 @@ class TestBeepFunctions:
|
|||||||
|
|
||||||
def test_beep_soft_writes_bell(self) -> None:
|
def test_beep_soft_writes_bell(self) -> None:
|
||||||
"""_beep_soft writes terminal bell character."""
|
"""_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()
|
mock_sys.stdout = MagicMock()
|
||||||
_beep_soft()
|
_beep_soft()
|
||||||
mock_sys.stdout.write.assert_called_once_with("\a")
|
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:
|
def test_beep_medium_delegates_to_play_tone(self) -> None:
|
||||||
"""_beep_medium just delegates to _play_tone."""
|
"""_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)
|
_beep_medium(frequency=800)
|
||||||
mock_play.assert_called_once_with(800)
|
mock_play.assert_called_once_with(800)
|
||||||
|
|
||||||
def test_beep_loud_delegates_to_play_tone(self) -> None:
|
def test_beep_loud_delegates_to_play_tone(self) -> None:
|
||||||
"""_beep_loud just delegates to _play_tone."""
|
"""_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)
|
_beep_loud(frequency=1200)
|
||||||
mock_play.assert_called_once_with(1200)
|
mock_play.assert_called_once_with(1200)
|
||||||
|
|
||||||
@ -229,7 +229,7 @@ class TestShouldRunAlarm:
|
|||||||
def test_returns_false_on_non_alarm_day(self) -> None:
|
def test_returns_false_on_non_alarm_day(self) -> None:
|
||||||
"""Return False when today is not an alarm day."""
|
"""Return False when today is not an alarm day."""
|
||||||
with patch(
|
with patch(
|
||||||
"python_pkg.wake_alarm._alarm._is_alarm_day",
|
"wake_alarm._alarm._is_alarm_day",
|
||||||
return_value=False,
|
return_value=False,
|
||||||
):
|
):
|
||||||
assert _should_run_alarm() is False
|
assert _should_run_alarm() is False
|
||||||
@ -238,11 +238,11 @@ class TestShouldRunAlarm:
|
|||||||
"""Return False when alarm was already dismissed today."""
|
"""Return False when alarm was already dismissed today."""
|
||||||
with (
|
with (
|
||||||
patch(
|
patch(
|
||||||
"python_pkg.wake_alarm._alarm._is_alarm_day",
|
"wake_alarm._alarm._is_alarm_day",
|
||||||
return_value=True,
|
return_value=True,
|
||||||
),
|
),
|
||||||
patch(
|
patch(
|
||||||
"python_pkg.wake_alarm._alarm.was_alarm_dismissed_today",
|
"wake_alarm._alarm.was_alarm_dismissed_today",
|
||||||
return_value=True,
|
return_value=True,
|
||||||
),
|
),
|
||||||
):
|
):
|
||||||
@ -252,15 +252,15 @@ class TestShouldRunAlarm:
|
|||||||
"""Return True when today is alarm day and not yet dismissed."""
|
"""Return True when today is alarm day and not yet dismissed."""
|
||||||
with (
|
with (
|
||||||
patch(
|
patch(
|
||||||
"python_pkg.wake_alarm._alarm._is_alarm_day",
|
"wake_alarm._alarm._is_alarm_day",
|
||||||
return_value=True,
|
return_value=True,
|
||||||
),
|
),
|
||||||
patch(
|
patch(
|
||||||
"python_pkg.wake_alarm._alarm.was_alarm_dismissed_today",
|
"wake_alarm._alarm.was_alarm_dismissed_today",
|
||||||
return_value=False,
|
return_value=False,
|
||||||
),
|
),
|
||||||
patch(
|
patch(
|
||||||
"python_pkg.wake_alarm._alarm.was_workout_logged_today",
|
"wake_alarm._alarm.was_workout_logged_today",
|
||||||
return_value=False,
|
return_value=False,
|
||||||
),
|
),
|
||||||
):
|
):
|
||||||
@ -270,15 +270,15 @@ class TestShouldRunAlarm:
|
|||||||
"""Return False when workout was already logged today."""
|
"""Return False when workout was already logged today."""
|
||||||
with (
|
with (
|
||||||
patch(
|
patch(
|
||||||
"python_pkg.wake_alarm._alarm._is_alarm_day",
|
"wake_alarm._alarm._is_alarm_day",
|
||||||
return_value=True,
|
return_value=True,
|
||||||
),
|
),
|
||||||
patch(
|
patch(
|
||||||
"python_pkg.wake_alarm._alarm.was_alarm_dismissed_today",
|
"wake_alarm._alarm.was_alarm_dismissed_today",
|
||||||
return_value=False,
|
return_value=False,
|
||||||
),
|
),
|
||||||
patch(
|
patch(
|
||||||
"python_pkg.wake_alarm._alarm.was_workout_logged_today",
|
"wake_alarm._alarm.was_workout_logged_today",
|
||||||
return_value=True,
|
return_value=True,
|
||||||
),
|
),
|
||||||
):
|
):
|
||||||
@ -292,10 +292,10 @@ class TestPlayOnExtraDevices:
|
|||||||
"""_play_on_extra_devices spawns speaker-test with PIPEWIRE_NODE set."""
|
"""_play_on_extra_devices spawns speaker-test with PIPEWIRE_NODE set."""
|
||||||
with (
|
with (
|
||||||
patch(
|
patch(
|
||||||
"python_pkg.wake_alarm._audio._speaker_test_path",
|
"wake_alarm._audio._speaker_test_path",
|
||||||
return_value="/usr/bin/speaker-test",
|
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)
|
_play_on_extra_devices(1000)
|
||||||
mock_popen.assert_called_once()
|
mock_popen.assert_called_once()
|
||||||
@ -311,10 +311,10 @@ class TestPlayOnExtraDevices:
|
|||||||
"""_play_on_extra_devices does nothing when speaker-test is absent."""
|
"""_play_on_extra_devices does nothing when speaker-test is absent."""
|
||||||
with (
|
with (
|
||||||
patch(
|
patch(
|
||||||
"python_pkg.wake_alarm._audio._speaker_test_path",
|
"wake_alarm._audio._speaker_test_path",
|
||||||
side_effect=FileNotFoundError("not found"),
|
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)
|
_play_on_extra_devices(1000)
|
||||||
mock_popen.assert_not_called()
|
mock_popen.assert_not_called()
|
||||||
@ -323,11 +323,11 @@ class TestPlayOnExtraDevices:
|
|||||||
"""_play_on_extra_devices silently ignores OSError from Popen."""
|
"""_play_on_extra_devices silently ignores OSError from Popen."""
|
||||||
with (
|
with (
|
||||||
patch(
|
patch(
|
||||||
"python_pkg.wake_alarm._audio._speaker_test_path",
|
"wake_alarm._audio._speaker_test_path",
|
||||||
return_value="/usr/bin/speaker-test",
|
return_value="/usr/bin/speaker-test",
|
||||||
),
|
),
|
||||||
patch(
|
patch(
|
||||||
"python_pkg.wake_alarm._audio.subprocess.Popen",
|
"wake_alarm._audio.subprocess.Popen",
|
||||||
side_effect=OSError("device busy"),
|
side_effect=OSError("device busy"),
|
||||||
),
|
),
|
||||||
):
|
):
|
||||||
@ -374,18 +374,18 @@ class TestMaxFans:
|
|||||||
|
|
||||||
def test_returns_false_when_no_hwmon(self) -> None:
|
def test_returns_false_when_no_hwmon(self) -> None:
|
||||||
"""No fan controller → returns False immediately."""
|
"""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
|
assert _max_fans() is False
|
||||||
|
|
||||||
def test_returns_false_on_script_oserror(self, tmp_path: pathlib.Path) -> None:
|
def test_returns_false_on_script_oserror(self, tmp_path: pathlib.Path) -> None:
|
||||||
"""OSError running fan script → returns False."""
|
"""OSError running fan script → returns False."""
|
||||||
with (
|
with (
|
||||||
patch(
|
patch(
|
||||||
"python_pkg.wake_alarm._audio._find_fan_hwmon",
|
"wake_alarm._audio._find_fan_hwmon",
|
||||||
return_value=str(tmp_path),
|
return_value=str(tmp_path),
|
||||||
),
|
),
|
||||||
patch(
|
patch(
|
||||||
"python_pkg.wake_alarm._audio.subprocess.run",
|
"wake_alarm._audio.subprocess.run",
|
||||||
side_effect=OSError("not found"),
|
side_effect=OSError("not found"),
|
||||||
),
|
),
|
||||||
):
|
):
|
||||||
@ -395,11 +395,11 @@ class TestMaxFans:
|
|||||||
"""TimeoutExpired running fan script → returns False."""
|
"""TimeoutExpired running fan script → returns False."""
|
||||||
with (
|
with (
|
||||||
patch(
|
patch(
|
||||||
"python_pkg.wake_alarm._audio._find_fan_hwmon",
|
"wake_alarm._audio._find_fan_hwmon",
|
||||||
return_value=str(tmp_path),
|
return_value=str(tmp_path),
|
||||||
),
|
),
|
||||||
patch(
|
patch(
|
||||||
"python_pkg.wake_alarm._audio.subprocess.run",
|
"wake_alarm._audio.subprocess.run",
|
||||||
side_effect=subprocess.TimeoutExpired("fan", 5),
|
side_effect=subprocess.TimeoutExpired("fan", 5),
|
||||||
),
|
),
|
||||||
):
|
):
|
||||||
@ -411,11 +411,11 @@ class TestMaxFans:
|
|||||||
mock_result.returncode = 1
|
mock_result.returncode = 1
|
||||||
with (
|
with (
|
||||||
patch(
|
patch(
|
||||||
"python_pkg.wake_alarm._audio._find_fan_hwmon",
|
"wake_alarm._audio._find_fan_hwmon",
|
||||||
return_value=str(tmp_path),
|
return_value=str(tmp_path),
|
||||||
),
|
),
|
||||||
patch(
|
patch(
|
||||||
"python_pkg.wake_alarm._audio.subprocess.run",
|
"wake_alarm._audio.subprocess.run",
|
||||||
return_value=mock_result,
|
return_value=mock_result,
|
||||||
),
|
),
|
||||||
):
|
):
|
||||||
@ -427,11 +427,11 @@ class TestMaxFans:
|
|||||||
mock_result.returncode = 0
|
mock_result.returncode = 0
|
||||||
with (
|
with (
|
||||||
patch(
|
patch(
|
||||||
"python_pkg.wake_alarm._audio._find_fan_hwmon",
|
"wake_alarm._audio._find_fan_hwmon",
|
||||||
return_value=str(tmp_path),
|
return_value=str(tmp_path),
|
||||||
),
|
),
|
||||||
patch(
|
patch(
|
||||||
"python_pkg.wake_alarm._audio.subprocess.run",
|
"wake_alarm._audio.subprocess.run",
|
||||||
return_value=mock_result,
|
return_value=mock_result,
|
||||||
),
|
),
|
||||||
):
|
):
|
||||||
@ -443,13 +443,13 @@ class TestRestoreFans:
|
|||||||
|
|
||||||
def test_noop_when_inactive(self) -> None:
|
def test_noop_when_inactive(self) -> None:
|
||||||
"""False state → subprocess.run is never called."""
|
"""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)
|
_restore_fans(active=False)
|
||||||
mock_run.assert_not_called()
|
mock_run.assert_not_called()
|
||||||
|
|
||||||
def test_calls_fan_script_restore(self) -> None:
|
def test_calls_fan_script_restore(self) -> None:
|
||||||
"""Active state → fan script called with restore (no args)."""
|
"""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
|
mock_run.return_value.returncode = 0
|
||||||
_restore_fans(active=True)
|
_restore_fans(active=True)
|
||||||
mock_run.assert_called_once()
|
mock_run.assert_called_once()
|
||||||
@ -459,7 +459,7 @@ class TestRestoreFans:
|
|||||||
def test_ignores_oserror_on_restore(self) -> None:
|
def test_ignores_oserror_on_restore(self) -> None:
|
||||||
"""OSError from fan script is silently suppressed."""
|
"""OSError from fan script is silently suppressed."""
|
||||||
with patch(
|
with patch(
|
||||||
"python_pkg.wake_alarm._audio.subprocess.run",
|
"wake_alarm._audio.subprocess.run",
|
||||||
side_effect=OSError("no script"),
|
side_effect=OSError("no script"),
|
||||||
):
|
):
|
||||||
_restore_fans(active=True) # must not raise
|
_restore_fans(active=True) # must not raise
|
||||||
@ -467,7 +467,7 @@ class TestRestoreFans:
|
|||||||
def test_ignores_timeout_on_restore(self) -> None:
|
def test_ignores_timeout_on_restore(self) -> None:
|
||||||
"""TimeoutExpired from fan script is silently suppressed."""
|
"""TimeoutExpired from fan script is silently suppressed."""
|
||||||
with patch(
|
with patch(
|
||||||
"python_pkg.wake_alarm._audio.subprocess.run",
|
"wake_alarm._audio.subprocess.run",
|
||||||
side_effect=subprocess.TimeoutExpired("fan", 5),
|
side_effect=subprocess.TimeoutExpired("fan", 5),
|
||||||
):
|
):
|
||||||
_restore_fans(active=True) # must not raise
|
_restore_fans(active=True) # must not raise
|
||||||
|
|||||||
@ -12,7 +12,7 @@ if TYPE_CHECKING:
|
|||||||
from collections.abc import Iterator
|
from collections.abc import Iterator
|
||||||
import pathlib
|
import pathlib
|
||||||
|
|
||||||
from python_pkg.wake_alarm._audio import (
|
from wake_alarm._audio import (
|
||||||
_beep_pcspkr,
|
_beep_pcspkr,
|
||||||
_ensure_tone_wav,
|
_ensure_tone_wav,
|
||||||
_play_tone,
|
_play_tone,
|
||||||
@ -27,8 +27,8 @@ class TestSetMaxBrightness:
|
|||||||
def test_noop_when_xrandr_missing(self) -> None:
|
def test_noop_when_xrandr_missing(self) -> None:
|
||||||
"""No xrandr on PATH → subprocess.run never called."""
|
"""No xrandr on PATH → subprocess.run never called."""
|
||||||
with (
|
with (
|
||||||
patch("python_pkg.wake_alarm._audio.shutil.which", return_value=None),
|
patch("wake_alarm._audio.shutil.which", return_value=None),
|
||||||
patch("python_pkg.wake_alarm._audio.subprocess.run") as mock_run,
|
patch("wake_alarm._audio.subprocess.run") as mock_run,
|
||||||
):
|
):
|
||||||
_set_max_brightness()
|
_set_max_brightness()
|
||||||
mock_run.assert_not_called()
|
mock_run.assert_not_called()
|
||||||
@ -37,11 +37,11 @@ class TestSetMaxBrightness:
|
|||||||
"""OSError from xrandr --query is suppressed."""
|
"""OSError from xrandr --query is suppressed."""
|
||||||
with (
|
with (
|
||||||
patch(
|
patch(
|
||||||
"python_pkg.wake_alarm._audio.shutil.which",
|
"wake_alarm._audio.shutil.which",
|
||||||
return_value="/usr/bin/xrandr",
|
return_value="/usr/bin/xrandr",
|
||||||
),
|
),
|
||||||
patch(
|
patch(
|
||||||
"python_pkg.wake_alarm._audio.subprocess.run",
|
"wake_alarm._audio.subprocess.run",
|
||||||
side_effect=OSError("no display"),
|
side_effect=OSError("no display"),
|
||||||
),
|
),
|
||||||
):
|
):
|
||||||
@ -51,11 +51,11 @@ class TestSetMaxBrightness:
|
|||||||
"""TimeoutExpired from xrandr --query is suppressed."""
|
"""TimeoutExpired from xrandr --query is suppressed."""
|
||||||
with (
|
with (
|
||||||
patch(
|
patch(
|
||||||
"python_pkg.wake_alarm._audio.shutil.which",
|
"wake_alarm._audio.shutil.which",
|
||||||
return_value="/usr/bin/xrandr",
|
return_value="/usr/bin/xrandr",
|
||||||
),
|
),
|
||||||
patch(
|
patch(
|
||||||
"python_pkg.wake_alarm._audio.subprocess.run",
|
"wake_alarm._audio.subprocess.run",
|
||||||
side_effect=subprocess.TimeoutExpired("xrandr", 5),
|
side_effect=subprocess.TimeoutExpired("xrandr", 5),
|
||||||
),
|
),
|
||||||
):
|
):
|
||||||
@ -75,10 +75,10 @@ class TestSetMaxBrightness:
|
|||||||
|
|
||||||
with (
|
with (
|
||||||
patch(
|
patch(
|
||||||
"python_pkg.wake_alarm._audio.shutil.which",
|
"wake_alarm._audio.shutil.which",
|
||||||
return_value="/usr/bin/xrandr",
|
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()
|
_set_max_brightness()
|
||||||
|
|
||||||
@ -93,11 +93,11 @@ class TestSetMaxBrightness:
|
|||||||
mock_result.stdout = "Screen 0: minimum 320\nHDMI-0 disconnected\n"
|
mock_result.stdout = "Screen 0: minimum 320\nHDMI-0 disconnected\n"
|
||||||
with (
|
with (
|
||||||
patch(
|
patch(
|
||||||
"python_pkg.wake_alarm._audio.shutil.which",
|
"wake_alarm._audio.shutil.which",
|
||||||
return_value="/usr/bin/xrandr",
|
return_value="/usr/bin/xrandr",
|
||||||
),
|
),
|
||||||
patch(
|
patch(
|
||||||
"python_pkg.wake_alarm._audio.subprocess.run",
|
"wake_alarm._audio.subprocess.run",
|
||||||
return_value=mock_result,
|
return_value=mock_result,
|
||||||
) as mock_run,
|
) as mock_run,
|
||||||
):
|
):
|
||||||
@ -120,11 +120,11 @@ class TestSetMaxBrightness:
|
|||||||
|
|
||||||
with (
|
with (
|
||||||
patch(
|
patch(
|
||||||
"python_pkg.wake_alarm._audio.shutil.which",
|
"wake_alarm._audio.shutil.which",
|
||||||
return_value="/usr/bin/xrandr",
|
return_value="/usr/bin/xrandr",
|
||||||
),
|
),
|
||||||
patch(
|
patch(
|
||||||
"python_pkg.wake_alarm._audio.subprocess.run",
|
"wake_alarm._audio.subprocess.run",
|
||||||
side_effect=_run_side_effect,
|
side_effect=_run_side_effect,
|
||||||
),
|
),
|
||||||
):
|
):
|
||||||
@ -136,11 +136,11 @@ class TestEnsureToneWav:
|
|||||||
|
|
||||||
def test_generates_and_caches(self, tmp_path: pathlib.Path) -> None:
|
def test_generates_and_caches(self, tmp_path: pathlib.Path) -> None:
|
||||||
"""First call generates the WAV; second call returns the cached path."""
|
"""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()
|
alarm_mod._TONE_CACHE.clear()
|
||||||
with patch(
|
with patch(
|
||||||
"python_pkg.wake_alarm._audio.tempfile.gettempdir",
|
"wake_alarm._audio.tempfile.gettempdir",
|
||||||
return_value=str(tmp_path),
|
return_value=str(tmp_path),
|
||||||
):
|
):
|
||||||
path1 = _ensure_tone_wav(440)
|
path1 = _ensure_tone_wav(440)
|
||||||
@ -148,7 +148,7 @@ class TestEnsureToneWav:
|
|||||||
size = path1.stat().st_size
|
size = path1.stat().st_size
|
||||||
assert size > 0
|
assert size > 0
|
||||||
# Second call must hit the cache (no regeneration).
|
# 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)
|
path2 = _ensure_tone_wav(440)
|
||||||
mock_open.assert_not_called()
|
mock_open.assert_not_called()
|
||||||
assert path2 == path1
|
assert path2 == path1
|
||||||
@ -159,11 +159,11 @@ class TestEnsureToneWav:
|
|||||||
tmp_path: pathlib.Path,
|
tmp_path: pathlib.Path,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""If the cached file was deleted, regenerate it."""
|
"""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()
|
_TONE_CACHE.clear()
|
||||||
with patch(
|
with patch(
|
||||||
"python_pkg.wake_alarm._audio.tempfile.gettempdir",
|
"wake_alarm._audio.tempfile.gettempdir",
|
||||||
return_value=str(tmp_path),
|
return_value=str(tmp_path),
|
||||||
):
|
):
|
||||||
path1 = _ensure_tone_wav(880)
|
path1 = _ensure_tone_wav(880)
|
||||||
@ -184,7 +184,7 @@ class TestTryPlayer:
|
|||||||
wav = tmp_path / "x.wav"
|
wav = tmp_path / "x.wav"
|
||||||
wav.write_bytes(b"\x00")
|
wav.write_bytes(b"\x00")
|
||||||
with patch(
|
with patch(
|
||||||
"python_pkg.wake_alarm._audio.shutil.which",
|
"wake_alarm._audio.shutil.which",
|
||||||
return_value=None,
|
return_value=None,
|
||||||
):
|
):
|
||||||
assert _try_player("paplay", wav) is False
|
assert _try_player("paplay", wav) is False
|
||||||
@ -197,11 +197,11 @@ class TestTryPlayer:
|
|||||||
result.returncode = 0
|
result.returncode = 0
|
||||||
with (
|
with (
|
||||||
patch(
|
patch(
|
||||||
"python_pkg.wake_alarm._audio.shutil.which",
|
"wake_alarm._audio.shutil.which",
|
||||||
return_value="/usr/bin/paplay",
|
return_value="/usr/bin/paplay",
|
||||||
),
|
),
|
||||||
patch(
|
patch(
|
||||||
"python_pkg.wake_alarm._audio.subprocess.run",
|
"wake_alarm._audio.subprocess.run",
|
||||||
return_value=result,
|
return_value=result,
|
||||||
),
|
),
|
||||||
):
|
):
|
||||||
@ -219,11 +219,11 @@ class TestTryPlayer:
|
|||||||
result.stderr = b"boom"
|
result.stderr = b"boom"
|
||||||
with (
|
with (
|
||||||
patch(
|
patch(
|
||||||
"python_pkg.wake_alarm._audio.shutil.which",
|
"wake_alarm._audio.shutil.which",
|
||||||
return_value="/usr/bin/paplay",
|
return_value="/usr/bin/paplay",
|
||||||
),
|
),
|
||||||
patch(
|
patch(
|
||||||
"python_pkg.wake_alarm._audio.subprocess.run",
|
"wake_alarm._audio.subprocess.run",
|
||||||
return_value=result,
|
return_value=result,
|
||||||
),
|
),
|
||||||
):
|
):
|
||||||
@ -235,11 +235,11 @@ class TestTryPlayer:
|
|||||||
wav.write_bytes(b"\x00")
|
wav.write_bytes(b"\x00")
|
||||||
with (
|
with (
|
||||||
patch(
|
patch(
|
||||||
"python_pkg.wake_alarm._audio.shutil.which",
|
"wake_alarm._audio.shutil.which",
|
||||||
return_value="/usr/bin/paplay",
|
return_value="/usr/bin/paplay",
|
||||||
),
|
),
|
||||||
patch(
|
patch(
|
||||||
"python_pkg.wake_alarm._audio.subprocess.run",
|
"wake_alarm._audio.subprocess.run",
|
||||||
side_effect=subprocess.TimeoutExpired("paplay", 6),
|
side_effect=subprocess.TimeoutExpired("paplay", 6),
|
||||||
),
|
),
|
||||||
):
|
):
|
||||||
@ -251,11 +251,11 @@ class TestTryPlayer:
|
|||||||
wav.write_bytes(b"\x00")
|
wav.write_bytes(b"\x00")
|
||||||
with (
|
with (
|
||||||
patch(
|
patch(
|
||||||
"python_pkg.wake_alarm._audio.shutil.which",
|
"wake_alarm._audio.shutil.which",
|
||||||
return_value="/usr/bin/paplay",
|
return_value="/usr/bin/paplay",
|
||||||
),
|
),
|
||||||
patch(
|
patch(
|
||||||
"python_pkg.wake_alarm._audio.subprocess.run",
|
"wake_alarm._audio.subprocess.run",
|
||||||
side_effect=OSError("nope"),
|
side_effect=OSError("nope"),
|
||||||
),
|
),
|
||||||
):
|
):
|
||||||
@ -274,10 +274,10 @@ class TestBeepPcspkr:
|
|||||||
mock_open_ctx.__exit__.return_value = False
|
mock_open_ctx.__exit__.return_value = False
|
||||||
with (
|
with (
|
||||||
patch(
|
patch(
|
||||||
"python_pkg.wake_alarm._audio.Path.open",
|
"wake_alarm._audio.Path.open",
|
||||||
return_value=mock_open_ctx,
|
return_value=mock_open_ctx,
|
||||||
),
|
),
|
||||||
patch("python_pkg.wake_alarm._audio.time.sleep"),
|
patch("wake_alarm._audio.time.sleep"),
|
||||||
):
|
):
|
||||||
_beep_pcspkr(1000, 0.05)
|
_beep_pcspkr(1000, 0.05)
|
||||||
# First write carries the frequency, second write carries 0 (stop).
|
# First write carries the frequency, second write carries 0 (stop).
|
||||||
@ -287,7 +287,7 @@ class TestBeepPcspkr:
|
|||||||
"""OSError opening the device must not raise."""
|
"""OSError opening the device must not raise."""
|
||||||
|
|
||||||
with patch(
|
with patch(
|
||||||
"python_pkg.wake_alarm._audio.Path.open",
|
"wake_alarm._audio.Path.open",
|
||||||
side_effect=OSError("no device"),
|
side_effect=OSError("no device"),
|
||||||
):
|
):
|
||||||
_beep_pcspkr(1000, 0.05) # must not raise
|
_beep_pcspkr(1000, 0.05) # must not raise
|
||||||
@ -299,7 +299,7 @@ class TestPlayTone:
|
|||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def _silence_pcspkr(self) -> Iterator[None]:
|
def _silence_pcspkr(self) -> Iterator[None]:
|
||||||
"""Stop tests from hitting the real /dev/input PC speaker device."""
|
"""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
|
yield
|
||||||
|
|
||||||
def test_paplay_success_short_circuits(self, tmp_path: pathlib.Path) -> None:
|
def test_paplay_success_short_circuits(self, tmp_path: pathlib.Path) -> None:
|
||||||
@ -308,15 +308,15 @@ class TestPlayTone:
|
|||||||
wav.write_bytes(b"\x00")
|
wav.write_bytes(b"\x00")
|
||||||
with (
|
with (
|
||||||
patch(
|
patch(
|
||||||
"python_pkg.wake_alarm._audio._ensure_tone_wav",
|
"wake_alarm._audio._ensure_tone_wav",
|
||||||
return_value=wav,
|
return_value=wav,
|
||||||
),
|
),
|
||||||
patch(
|
patch(
|
||||||
"python_pkg.wake_alarm._audio._try_player",
|
"wake_alarm._audio._try_player",
|
||||||
return_value=True,
|
return_value=True,
|
||||||
) as mock_try,
|
) as mock_try,
|
||||||
patch(
|
patch(
|
||||||
"python_pkg.wake_alarm._audio.subprocess.run",
|
"wake_alarm._audio.subprocess.run",
|
||||||
) as mock_run,
|
) as mock_run,
|
||||||
):
|
):
|
||||||
_play_tone(440)
|
_play_tone(440)
|
||||||
@ -332,19 +332,19 @@ class TestPlayTone:
|
|||||||
wav.write_bytes(b"\x00")
|
wav.write_bytes(b"\x00")
|
||||||
with (
|
with (
|
||||||
patch(
|
patch(
|
||||||
"python_pkg.wake_alarm._audio._ensure_tone_wav",
|
"wake_alarm._audio._ensure_tone_wav",
|
||||||
return_value=wav,
|
return_value=wav,
|
||||||
),
|
),
|
||||||
patch(
|
patch(
|
||||||
"python_pkg.wake_alarm._audio._try_player",
|
"wake_alarm._audio._try_player",
|
||||||
return_value=False,
|
return_value=False,
|
||||||
),
|
),
|
||||||
patch(
|
patch(
|
||||||
"python_pkg.wake_alarm._audio._speaker_test_path",
|
"wake_alarm._audio._speaker_test_path",
|
||||||
return_value="/usr/bin/speaker-test",
|
return_value="/usr/bin/speaker-test",
|
||||||
),
|
),
|
||||||
patch(
|
patch(
|
||||||
"python_pkg.wake_alarm._audio.subprocess.run",
|
"wake_alarm._audio.subprocess.run",
|
||||||
) as mock_run,
|
) as mock_run,
|
||||||
):
|
):
|
||||||
_play_tone(1000)
|
_play_tone(1000)
|
||||||
@ -362,18 +362,18 @@ class TestPlayTone:
|
|||||||
wav.write_bytes(b"\x00")
|
wav.write_bytes(b"\x00")
|
||||||
with (
|
with (
|
||||||
patch(
|
patch(
|
||||||
"python_pkg.wake_alarm._audio._ensure_tone_wav",
|
"wake_alarm._audio._ensure_tone_wav",
|
||||||
return_value=wav,
|
return_value=wav,
|
||||||
),
|
),
|
||||||
patch(
|
patch(
|
||||||
"python_pkg.wake_alarm._audio._try_player",
|
"wake_alarm._audio._try_player",
|
||||||
return_value=False,
|
return_value=False,
|
||||||
),
|
),
|
||||||
patch(
|
patch(
|
||||||
"python_pkg.wake_alarm._audio._speaker_test_path",
|
"wake_alarm._audio._speaker_test_path",
|
||||||
side_effect=FileNotFoundError("missing"),
|
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)
|
_play_tone(800)
|
||||||
mock_soft.assert_called_once()
|
mock_soft.assert_called_once()
|
||||||
@ -387,22 +387,22 @@ class TestPlayTone:
|
|||||||
wav.write_bytes(b"\x00")
|
wav.write_bytes(b"\x00")
|
||||||
with (
|
with (
|
||||||
patch(
|
patch(
|
||||||
"python_pkg.wake_alarm._audio._ensure_tone_wav",
|
"wake_alarm._audio._ensure_tone_wav",
|
||||||
return_value=wav,
|
return_value=wav,
|
||||||
),
|
),
|
||||||
patch(
|
patch(
|
||||||
"python_pkg.wake_alarm._audio._try_player",
|
"wake_alarm._audio._try_player",
|
||||||
return_value=False,
|
return_value=False,
|
||||||
),
|
),
|
||||||
patch(
|
patch(
|
||||||
"python_pkg.wake_alarm._audio._speaker_test_path",
|
"wake_alarm._audio._speaker_test_path",
|
||||||
return_value="/usr/bin/speaker-test",
|
return_value="/usr/bin/speaker-test",
|
||||||
),
|
),
|
||||||
patch(
|
patch(
|
||||||
"python_pkg.wake_alarm._audio.subprocess.run",
|
"wake_alarm._audio.subprocess.run",
|
||||||
side_effect=subprocess.TimeoutExpired("speaker-test", 6),
|
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)
|
_play_tone(800)
|
||||||
mock_soft.assert_called_once()
|
mock_soft.assert_called_once()
|
||||||
@ -411,10 +411,10 @@ class TestPlayTone:
|
|||||||
"""OSError generating WAV → soft beep."""
|
"""OSError generating WAV → soft beep."""
|
||||||
with (
|
with (
|
||||||
patch(
|
patch(
|
||||||
"python_pkg.wake_alarm._audio._ensure_tone_wav",
|
"wake_alarm._audio._ensure_tone_wav",
|
||||||
side_effect=OSError("disk full"),
|
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)
|
_play_tone(440)
|
||||||
mock_soft.assert_called_once()
|
mock_soft.assert_called_once()
|
||||||
|
|||||||
@ -4,7 +4,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
from python_pkg.wake_alarm._challenges import (
|
from wake_alarm._challenges import (
|
||||||
_DISMISS_CHARS,
|
_DISMISS_CHARS,
|
||||||
_Challenge,
|
_Challenge,
|
||||||
_make_challenge,
|
_make_challenge,
|
||||||
@ -12,7 +12,7 @@ from python_pkg.wake_alarm._challenges import (
|
|||||||
_make_math_challenge,
|
_make_math_challenge,
|
||||||
_make_sort_challenge,
|
_make_sort_challenge,
|
||||||
)
|
)
|
||||||
from python_pkg.wake_alarm._constants import DISMISS_FLASH_SECONDS
|
from wake_alarm._constants import DISMISS_FLASH_SECONDS
|
||||||
|
|
||||||
|
|
||||||
class TestMakeMathChallenge:
|
class TestMakeMathChallenge:
|
||||||
@ -25,9 +25,9 @@ class TestMakeMathChallenge:
|
|||||||
def test_answer_is_correct_for_addition(self) -> None:
|
def test_answer_is_correct_for_addition(self) -> None:
|
||||||
"""Stored answer is numerically correct for addition."""
|
"""Stored answer is numerically correct for addition."""
|
||||||
with (
|
with (
|
||||||
patch("python_pkg.wake_alarm._challenges.secrets.choice", return_value="+"),
|
patch("wake_alarm._challenges.secrets.choice", return_value="+"),
|
||||||
patch(
|
patch(
|
||||||
"python_pkg.wake_alarm._challenges.secrets.randbelow",
|
"wake_alarm._challenges.secrets.randbelow",
|
||||||
side_effect=[13, 35], # 10+13=23, 10+35=45
|
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:
|
def test_answer_is_correct_for_subtraction(self) -> None:
|
||||||
"""Stored answer is numerically correct for subtraction."""
|
"""Stored answer is numerically correct for subtraction."""
|
||||||
with (
|
with (
|
||||||
patch("python_pkg.wake_alarm._challenges.secrets.choice", return_value="-"),
|
patch("wake_alarm._challenges.secrets.choice", return_value="-"),
|
||||||
patch(
|
patch(
|
||||||
"python_pkg.wake_alarm._challenges.secrets.randbelow",
|
"wake_alarm._challenges.secrets.randbelow",
|
||||||
side_effect=[30, 7], # 20+30=50, 10+7=17
|
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:
|
def test_answer_is_correct_for_multiplication(self) -> None:
|
||||||
"""Stored answer is numerically correct for multiplication."""
|
"""Stored answer is numerically correct for multiplication."""
|
||||||
with (
|
with (
|
||||||
patch("python_pkg.wake_alarm._challenges.secrets.choice", return_value="*"),
|
patch("wake_alarm._challenges.secrets.choice", return_value="*"),
|
||||||
patch(
|
patch(
|
||||||
"python_pkg.wake_alarm._challenges.secrets.randbelow",
|
"wake_alarm._challenges.secrets.randbelow",
|
||||||
side_effect=[3, 4], # 12+3=15, 3+4=7
|
side_effect=[3, 4], # 12+3=15, 3+4=7
|
||||||
),
|
),
|
||||||
):
|
):
|
||||||
|
|||||||
@ -5,7 +5,7 @@ from __future__ import annotations
|
|||||||
import subprocess
|
import subprocess
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
from python_pkg.wake_alarm._alarm_display import (
|
from wake_alarm._alarm_display import (
|
||||||
_ddcutil_power_on,
|
_ddcutil_power_on,
|
||||||
_restore_display,
|
_restore_display,
|
||||||
_wake_display,
|
_wake_display,
|
||||||
@ -19,10 +19,10 @@ class TestDdcutilPowerOn:
|
|||||||
"""_ddcutil_power_on does nothing when ddcutil is not on PATH."""
|
"""_ddcutil_power_on does nothing when ddcutil is not on PATH."""
|
||||||
with (
|
with (
|
||||||
patch(
|
patch(
|
||||||
"python_pkg.wake_alarm._alarm_display.shutil.which",
|
"wake_alarm._alarm_display.shutil.which",
|
||||||
return_value=None,
|
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()
|
_ddcutil_power_on()
|
||||||
mock_run.assert_not_called()
|
mock_run.assert_not_called()
|
||||||
@ -31,10 +31,10 @@ class TestDdcutilPowerOn:
|
|||||||
"""_ddcutil_power_on sends setvcp D6 01 when ddcutil is found."""
|
"""_ddcutil_power_on sends setvcp D6 01 when ddcutil is found."""
|
||||||
with (
|
with (
|
||||||
patch(
|
patch(
|
||||||
"python_pkg.wake_alarm._alarm_display.shutil.which",
|
"wake_alarm._alarm_display.shutil.which",
|
||||||
return_value="/usr/bin/ddcutil",
|
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()
|
_ddcutil_power_on()
|
||||||
mock_run.assert_called_once()
|
mock_run.assert_called_once()
|
||||||
@ -45,11 +45,11 @@ class TestDdcutilPowerOn:
|
|||||||
"""_ddcutil_power_on logs success when setvcp returns 0."""
|
"""_ddcutil_power_on logs success when setvcp returns 0."""
|
||||||
with (
|
with (
|
||||||
patch(
|
patch(
|
||||||
"python_pkg.wake_alarm._alarm_display.shutil.which",
|
"wake_alarm._alarm_display.shutil.which",
|
||||||
return_value="/usr/bin/ddcutil",
|
return_value="/usr/bin/ddcutil",
|
||||||
),
|
),
|
||||||
patch(
|
patch(
|
||||||
"python_pkg.wake_alarm._alarm_display.subprocess.run",
|
"wake_alarm._alarm_display.subprocess.run",
|
||||||
return_value=MagicMock(returncode=0),
|
return_value=MagicMock(returncode=0),
|
||||||
),
|
),
|
||||||
):
|
):
|
||||||
@ -59,11 +59,11 @@ class TestDdcutilPowerOn:
|
|||||||
"""_ddcutil_power_on does not raise on TimeoutExpired."""
|
"""_ddcutil_power_on does not raise on TimeoutExpired."""
|
||||||
with (
|
with (
|
||||||
patch(
|
patch(
|
||||||
"python_pkg.wake_alarm._alarm_display.shutil.which",
|
"wake_alarm._alarm_display.shutil.which",
|
||||||
return_value="/usr/bin/ddcutil",
|
return_value="/usr/bin/ddcutil",
|
||||||
),
|
),
|
||||||
patch(
|
patch(
|
||||||
"python_pkg.wake_alarm._alarm_display.subprocess.run",
|
"wake_alarm._alarm_display.subprocess.run",
|
||||||
side_effect=subprocess.TimeoutExpired(cmd="ddcutil", timeout=10),
|
side_effect=subprocess.TimeoutExpired(cmd="ddcutil", timeout=10),
|
||||||
),
|
),
|
||||||
):
|
):
|
||||||
@ -73,11 +73,11 @@ class TestDdcutilPowerOn:
|
|||||||
"""_ddcutil_power_on does not raise on OSError."""
|
"""_ddcutil_power_on does not raise on OSError."""
|
||||||
with (
|
with (
|
||||||
patch(
|
patch(
|
||||||
"python_pkg.wake_alarm._alarm_display.shutil.which",
|
"wake_alarm._alarm_display.shutil.which",
|
||||||
return_value="/usr/bin/ddcutil",
|
return_value="/usr/bin/ddcutil",
|
||||||
),
|
),
|
||||||
patch(
|
patch(
|
||||||
"python_pkg.wake_alarm._alarm_display.subprocess.run",
|
"wake_alarm._alarm_display.subprocess.run",
|
||||||
side_effect=OSError("no device"),
|
side_effect=OSError("no device"),
|
||||||
),
|
),
|
||||||
):
|
):
|
||||||
@ -91,10 +91,10 @@ class TestDisplayHelpers:
|
|||||||
"""_wake_display skips xset commands but still attempts ddcutil."""
|
"""_wake_display skips xset commands but still attempts ddcutil."""
|
||||||
with (
|
with (
|
||||||
patch(
|
patch(
|
||||||
"python_pkg.wake_alarm._alarm_display.shutil.which",
|
"wake_alarm._alarm_display.shutil.which",
|
||||||
return_value=None,
|
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()
|
_wake_display()
|
||||||
mock_run.assert_not_called()
|
mock_run.assert_not_called()
|
||||||
@ -103,10 +103,10 @@ class TestDisplayHelpers:
|
|||||||
"""_wake_display runs ddcutil setvcp, xset dpms force on, xset s off."""
|
"""_wake_display runs ddcutil setvcp, xset dpms force on, xset s off."""
|
||||||
with (
|
with (
|
||||||
patch(
|
patch(
|
||||||
"python_pkg.wake_alarm._alarm_display.shutil.which",
|
"wake_alarm._alarm_display.shutil.which",
|
||||||
return_value="/usr/bin/xset",
|
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()
|
_wake_display()
|
||||||
# 1 ddcutil setvcp call + 2 xset calls
|
# 1 ddcutil setvcp call + 2 xset calls
|
||||||
@ -120,10 +120,10 @@ class TestDisplayHelpers:
|
|||||||
"""_restore_display does nothing when xset is not on PATH."""
|
"""_restore_display does nothing when xset is not on PATH."""
|
||||||
with (
|
with (
|
||||||
patch(
|
patch(
|
||||||
"python_pkg.wake_alarm._alarm_display.shutil.which",
|
"wake_alarm._alarm_display.shutil.which",
|
||||||
return_value=None,
|
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()
|
_restore_display()
|
||||||
mock_run.assert_not_called()
|
mock_run.assert_not_called()
|
||||||
@ -132,10 +132,10 @@ class TestDisplayHelpers:
|
|||||||
"""_restore_display re-enables the screensaver via xset when present."""
|
"""_restore_display re-enables the screensaver via xset when present."""
|
||||||
with (
|
with (
|
||||||
patch(
|
patch(
|
||||||
"python_pkg.wake_alarm._alarm_display.shutil.which",
|
"wake_alarm._alarm_display.shutil.which",
|
||||||
return_value="/usr/bin/xset",
|
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()
|
_restore_display()
|
||||||
mock_run.assert_called_once_with(
|
mock_run.assert_called_once_with(
|
||||||
|
|||||||
@ -11,7 +11,7 @@ import pytest
|
|||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import Generator
|
from collections.abc import Generator
|
||||||
|
|
||||||
from python_pkg.wake_alarm._alarm import (
|
from wake_alarm._alarm import (
|
||||||
WakeAlarm,
|
WakeAlarm,
|
||||||
main,
|
main,
|
||||||
)
|
)
|
||||||
@ -41,9 +41,9 @@ def _block_real_tk() -> Generator[MagicMock]:
|
|||||||
"""Prevent any real Tk windows in tests."""
|
"""Prevent any real Tk windows in tests."""
|
||||||
mock = _make_mock_tk()
|
mock = _make_mock_tk()
|
||||||
with (
|
with (
|
||||||
patch("python_pkg.wake_alarm._alarm.tk", mock),
|
patch("wake_alarm._alarm.tk", mock),
|
||||||
patch(
|
patch(
|
||||||
"python_pkg.wake_alarm._alarm.GateRoot",
|
"wake_alarm._alarm.GateRoot",
|
||||||
return_value=mock.Tk.return_value,
|
return_value=mock.Tk.return_value,
|
||||||
),
|
),
|
||||||
):
|
):
|
||||||
@ -54,17 +54,17 @@ def _block_real_tk() -> Generator[MagicMock]:
|
|||||||
def _block_extra_devices() -> Generator[MagicMock]:
|
def _block_extra_devices() -> Generator[MagicMock]:
|
||||||
"""Prevent real subprocess.Popen calls for extra ALSA devices."""
|
"""Prevent real subprocess.Popen calls for extra ALSA devices."""
|
||||||
with (
|
with (
|
||||||
patch("python_pkg.wake_alarm._alarm._play_on_extra_devices") as mock,
|
patch("wake_alarm._alarm._play_on_extra_devices") as mock,
|
||||||
patch("python_pkg.wake_alarm._alarm._max_fans", return_value=False),
|
patch("wake_alarm._alarm._max_fans", return_value=False),
|
||||||
patch("python_pkg.wake_alarm._alarm._restore_fans"),
|
patch("wake_alarm._alarm._restore_fans"),
|
||||||
patch("python_pkg.wake_alarm._alarm._set_max_brightness"),
|
patch("wake_alarm._alarm._set_max_brightness"),
|
||||||
patch("python_pkg.wake_alarm._alarm._wake_display"),
|
patch("wake_alarm._alarm._wake_display"),
|
||||||
patch("python_pkg.wake_alarm._alarm._restore_display"),
|
patch("wake_alarm._alarm._restore_display"),
|
||||||
patch("python_pkg.wake_alarm._alarm._warn_if_no_real_sink"),
|
patch("wake_alarm._alarm._warn_if_no_real_sink"),
|
||||||
patch("python_pkg.wake_alarm._alarm._activate_alarm_audio", return_value=None),
|
patch("wake_alarm._alarm._activate_alarm_audio", return_value=None),
|
||||||
patch("python_pkg.wake_alarm._alarm._restore_alarm_audio"),
|
patch("wake_alarm._alarm._restore_alarm_audio"),
|
||||||
patch("python_pkg.wake_alarm._alarm.turn_on_plug"),
|
patch("wake_alarm._alarm.turn_on_plug"),
|
||||||
patch("python_pkg.wake_alarm._alarm.turn_off_plug"),
|
patch("wake_alarm._alarm.turn_off_plug"),
|
||||||
):
|
):
|
||||||
yield mock
|
yield mock
|
||||||
|
|
||||||
@ -74,9 +74,9 @@ def mock_tk_module() -> Generator[MagicMock]:
|
|||||||
"""Provide explicit access to the mocked tk module."""
|
"""Provide explicit access to the mocked tk module."""
|
||||||
mock = _make_mock_tk()
|
mock = _make_mock_tk()
|
||||||
with (
|
with (
|
||||||
patch("python_pkg.wake_alarm._alarm.tk", mock),
|
patch("wake_alarm._alarm.tk", mock),
|
||||||
patch(
|
patch(
|
||||||
"python_pkg.wake_alarm._alarm.GateRoot",
|
"wake_alarm._alarm.GateRoot",
|
||||||
return_value=mock.Tk.return_value,
|
return_value=mock.Tk.return_value,
|
||||||
),
|
),
|
||||||
):
|
):
|
||||||
@ -137,13 +137,13 @@ class TestWakeAlarmDismiss:
|
|||||||
mock_tk_module: MagicMock,
|
mock_tk_module: MagicMock,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Entering the correct answer for every required round dismisses the alarm."""
|
"""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)
|
alarm = WakeAlarm(demo_mode=True)
|
||||||
mock_entry = mock_tk_module.Entry.return_value
|
mock_entry = mock_tk_module.Entry.return_value
|
||||||
|
|
||||||
with patch(
|
with patch(
|
||||||
"python_pkg.wake_alarm._alarm.save_wake_state",
|
"wake_alarm._alarm.save_wake_state",
|
||||||
) as mock_save:
|
) as mock_save:
|
||||||
for _ in range(DISMISS_ROUNDS_REQUIRED):
|
for _ in range(DISMISS_ROUNDS_REQUIRED):
|
||||||
mock_entry.get.return_value = alarm._progress.current_challenge.answer
|
mock_entry.get.return_value = alarm._progress.current_challenge.answer
|
||||||
@ -174,15 +174,13 @@ class TestWakeAlarmDismiss:
|
|||||||
mock_tk_module: MagicMock,
|
mock_tk_module: MagicMock,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""When next challenge is math, no flash countdown is started."""
|
"""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)
|
alarm = WakeAlarm(demo_mode=True)
|
||||||
mock_entry = mock_tk_module.Entry.return_value
|
mock_entry = mock_tk_module.Entry.return_value
|
||||||
mock_entry.get.return_value = alarm._progress.current_challenge.answer
|
mock_entry.get.return_value = alarm._progress.current_challenge.answer
|
||||||
next_math = _Challenge(kind="math", display="2 + 2 = ?", answer="4", hint="x")
|
next_math = _Challenge(kind="math", display="2 + 2 = ?", answer="4", hint="x")
|
||||||
with patch(
|
with patch("wake_alarm._alarm._make_challenge", return_value=next_math):
|
||||||
"python_pkg.wake_alarm._alarm._make_challenge", return_value=next_math
|
|
||||||
):
|
|
||||||
alarm._on_submit()
|
alarm._on_submit()
|
||||||
|
|
||||||
assert alarm._progress.current_challenge.kind == "math"
|
assert alarm._progress.current_challenge.kind == "math"
|
||||||
@ -194,7 +192,7 @@ class TestWakeAlarmDismiss:
|
|||||||
mock_tk_module: MagicMock,
|
mock_tk_module: MagicMock,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Entering the wrong answer shows an error without dismissing."""
|
"""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)
|
alarm = WakeAlarm(demo_mode=True)
|
||||||
# Use a pinned math challenge so the non-flash wrong-answer branch is covered.
|
# 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)
|
alarm = WakeAlarm(demo_mode=True)
|
||||||
|
|
||||||
with patch(
|
with patch(
|
||||||
"python_pkg.wake_alarm._alarm.save_wake_state",
|
"wake_alarm._alarm.save_wake_state",
|
||||||
) as mock_save:
|
) as mock_save:
|
||||||
alarm._on_skip_window_expired()
|
alarm._on_skip_window_expired()
|
||||||
|
|
||||||
@ -251,14 +249,14 @@ class TestWakeAlarmDismiss:
|
|||||||
mock_tk_module: MagicMock,
|
mock_tk_module: MagicMock,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Typing all rounds after the skip window stops the alarm without a skip."""
|
"""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 = WakeAlarm(demo_mode=True)
|
||||||
alarm._progress.skip_earnable = False
|
alarm._progress.skip_earnable = False
|
||||||
mock_entry = mock_tk_module.Entry.return_value
|
mock_entry = mock_tk_module.Entry.return_value
|
||||||
|
|
||||||
with patch(
|
with patch(
|
||||||
"python_pkg.wake_alarm._alarm.save_wake_state",
|
"wake_alarm._alarm.save_wake_state",
|
||||||
) as mock_save:
|
) as mock_save:
|
||||||
for _ in range(DISMISS_ROUNDS_REQUIRED):
|
for _ in range(DISMISS_ROUNDS_REQUIRED):
|
||||||
mock_entry.get.return_value = alarm._progress.current_challenge.answer
|
mock_entry.get.return_value = alarm._progress.current_challenge.answer
|
||||||
@ -276,10 +274,10 @@ class TestMain:
|
|||||||
"""main() returns early when not an alarm day."""
|
"""main() returns early when not an alarm day."""
|
||||||
with (
|
with (
|
||||||
patch(
|
patch(
|
||||||
"python_pkg.wake_alarm._alarm._should_run_alarm",
|
"wake_alarm._alarm._should_run_alarm",
|
||||||
return_value=False,
|
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"]
|
mock_sys.argv = ["alarm"]
|
||||||
main() # Should just return without error
|
main() # Should just return without error
|
||||||
@ -292,11 +290,11 @@ class TestMain:
|
|||||||
del mock_tk_module
|
del mock_tk_module
|
||||||
with (
|
with (
|
||||||
patch(
|
patch(
|
||||||
"python_pkg.wake_alarm._alarm._should_run_alarm",
|
"wake_alarm._alarm._should_run_alarm",
|
||||||
return_value=True,
|
return_value=True,
|
||||||
),
|
),
|
||||||
patch(
|
patch(
|
||||||
"python_pkg.wake_alarm._alarm.sys",
|
"wake_alarm._alarm.sys",
|
||||||
) as mock_sys,
|
) as mock_sys,
|
||||||
patch.object(WakeAlarm, "run") as mock_run,
|
patch.object(WakeAlarm, "run") as mock_run,
|
||||||
patch.object(WakeAlarm, "__init__", return_value=None),
|
patch.object(WakeAlarm, "__init__", return_value=None),
|
||||||
@ -313,10 +311,10 @@ class TestMain:
|
|||||||
del mock_tk_module
|
del mock_tk_module
|
||||||
with (
|
with (
|
||||||
patch(
|
patch(
|
||||||
"python_pkg.wake_alarm._alarm._should_run_alarm",
|
"wake_alarm._alarm._should_run_alarm",
|
||||||
return_value=False,
|
return_value=False,
|
||||||
) as mock_gate,
|
) 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, "run") as mock_run,
|
||||||
patch.object(WakeAlarm, "__init__", return_value=None),
|
patch.object(WakeAlarm, "__init__", return_value=None),
|
||||||
):
|
):
|
||||||
@ -377,7 +375,7 @@ class TestBeepLoop:
|
|||||||
alarm._stop_beep.set()
|
alarm._stop_beep.set()
|
||||||
# Loop should exit immediately
|
# Loop should exit immediately
|
||||||
with patch(
|
with patch(
|
||||||
"python_pkg.wake_alarm._alarm._beep_soft",
|
"wake_alarm._alarm._beep_soft",
|
||||||
):
|
):
|
||||||
alarm._beep_loop()
|
alarm._beep_loop()
|
||||||
alarm._stop_beep.set()
|
alarm._stop_beep.set()
|
||||||
|
|||||||
@ -11,10 +11,10 @@ import pytest
|
|||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import Generator
|
from collections.abc import Generator
|
||||||
|
|
||||||
from python_pkg.wake_alarm._alarm import (
|
from wake_alarm._alarm import (
|
||||||
WakeAlarm,
|
WakeAlarm,
|
||||||
)
|
)
|
||||||
from python_pkg.wake_alarm._constants import (
|
from wake_alarm._constants import (
|
||||||
PHASE_MEDIUM_END,
|
PHASE_MEDIUM_END,
|
||||||
PHASE_SOFT_END,
|
PHASE_SOFT_END,
|
||||||
)
|
)
|
||||||
@ -40,9 +40,9 @@ def _block_real_tk() -> Generator[MagicMock]:
|
|||||||
"""Prevent any real Tk windows in tests."""
|
"""Prevent any real Tk windows in tests."""
|
||||||
mock = _make_mock_tk()
|
mock = _make_mock_tk()
|
||||||
with (
|
with (
|
||||||
patch("python_pkg.wake_alarm._alarm.tk", mock),
|
patch("wake_alarm._alarm.tk", mock),
|
||||||
patch(
|
patch(
|
||||||
"python_pkg.wake_alarm._alarm.GateRoot",
|
"wake_alarm._alarm.GateRoot",
|
||||||
return_value=mock.Tk.return_value,
|
return_value=mock.Tk.return_value,
|
||||||
),
|
),
|
||||||
):
|
):
|
||||||
@ -53,17 +53,17 @@ def _block_real_tk() -> Generator[MagicMock]:
|
|||||||
def _block_extra_devices() -> Generator[MagicMock]:
|
def _block_extra_devices() -> Generator[MagicMock]:
|
||||||
"""Prevent real subprocess calls for extra ALSA devices and hardware."""
|
"""Prevent real subprocess calls for extra ALSA devices and hardware."""
|
||||||
with (
|
with (
|
||||||
patch("python_pkg.wake_alarm._alarm._play_on_extra_devices") as mock,
|
patch("wake_alarm._alarm._play_on_extra_devices") as mock,
|
||||||
patch("python_pkg.wake_alarm._alarm._max_fans", return_value=False),
|
patch("wake_alarm._alarm._max_fans", return_value=False),
|
||||||
patch("python_pkg.wake_alarm._alarm._restore_fans"),
|
patch("wake_alarm._alarm._restore_fans"),
|
||||||
patch("python_pkg.wake_alarm._alarm._set_max_brightness"),
|
patch("wake_alarm._alarm._set_max_brightness"),
|
||||||
patch("python_pkg.wake_alarm._alarm._wake_display"),
|
patch("wake_alarm._alarm._wake_display"),
|
||||||
patch("python_pkg.wake_alarm._alarm._restore_display"),
|
patch("wake_alarm._alarm._restore_display"),
|
||||||
patch("python_pkg.wake_alarm._alarm._warn_if_no_real_sink"),
|
patch("wake_alarm._alarm._warn_if_no_real_sink"),
|
||||||
patch("python_pkg.wake_alarm._alarm._activate_alarm_audio", return_value=None),
|
patch("wake_alarm._alarm._activate_alarm_audio", return_value=None),
|
||||||
patch("python_pkg.wake_alarm._alarm._restore_alarm_audio"),
|
patch("wake_alarm._alarm._restore_alarm_audio"),
|
||||||
patch("python_pkg.wake_alarm._alarm.turn_on_plug"),
|
patch("wake_alarm._alarm.turn_on_plug"),
|
||||||
patch("python_pkg.wake_alarm._alarm.turn_off_plug"),
|
patch("wake_alarm._alarm.turn_off_plug"),
|
||||||
):
|
):
|
||||||
yield mock
|
yield mock
|
||||||
|
|
||||||
@ -73,9 +73,9 @@ def mock_tk_module() -> Generator[MagicMock]:
|
|||||||
"""Provide explicit access to the mocked tk module."""
|
"""Provide explicit access to the mocked tk module."""
|
||||||
mock = _make_mock_tk()
|
mock = _make_mock_tk()
|
||||||
with (
|
with (
|
||||||
patch("python_pkg.wake_alarm._alarm.tk", mock),
|
patch("wake_alarm._alarm.tk", mock),
|
||||||
patch(
|
patch(
|
||||||
"python_pkg.wake_alarm._alarm.GateRoot",
|
"wake_alarm._alarm.GateRoot",
|
||||||
return_value=mock.Tk.return_value,
|
return_value=mock.Tk.return_value,
|
||||||
),
|
),
|
||||||
):
|
):
|
||||||
@ -106,7 +106,7 @@ class TestBeepLoopPhases:
|
|||||||
|
|
||||||
with (
|
with (
|
||||||
patch(
|
patch(
|
||||||
"python_pkg.wake_alarm._alarm._beep_medium",
|
"wake_alarm._alarm._beep_medium",
|
||||||
side_effect=stop_after_one,
|
side_effect=stop_after_one,
|
||||||
) as mock_beep,
|
) as mock_beep,
|
||||||
):
|
):
|
||||||
@ -135,7 +135,7 @@ class TestBeepLoopPhases:
|
|||||||
|
|
||||||
with (
|
with (
|
||||||
patch(
|
patch(
|
||||||
"python_pkg.wake_alarm._alarm._beep_loud",
|
"wake_alarm._alarm._beep_loud",
|
||||||
side_effect=stop_after_one,
|
side_effect=stop_after_one,
|
||||||
) as mock_beep,
|
) as mock_beep,
|
||||||
):
|
):
|
||||||
@ -220,7 +220,7 @@ class TestFlashChallenge:
|
|||||||
mock_tk_module: MagicMock,
|
mock_tk_module: MagicMock,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""_flash_tick counts down per second and hides the code at zero."""
|
"""_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 = WakeAlarm(demo_mode=True)
|
||||||
alarm._progress.current_challenge = _Challenge(
|
alarm._progress.current_challenge = _Challenge(
|
||||||
@ -269,7 +269,7 @@ class TestFlashChallenge:
|
|||||||
mock_tk_module: MagicMock,
|
mock_tk_module: MagicMock,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Wrong flash answer restores the code and restarts the countdown."""
|
"""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 = WakeAlarm(demo_mode=True)
|
||||||
alarm._progress.current_challenge = _Challenge(
|
alarm._progress.current_challenge = _Challenge(
|
||||||
@ -294,7 +294,7 @@ class TestFlashChallenge:
|
|||||||
mock_tk_module: MagicMock,
|
mock_tk_module: MagicMock,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""When the next-round challenge is flash, the countdown starts immediately."""
|
"""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 = WakeAlarm(demo_mode=True)
|
||||||
alarm._progress.current_challenge = _Challenge(
|
alarm._progress.current_challenge = _Challenge(
|
||||||
@ -306,9 +306,7 @@ class TestFlashChallenge:
|
|||||||
mock_entry = mock_tk_module.Entry.return_value
|
mock_entry = mock_tk_module.Entry.return_value
|
||||||
mock_entry.get.return_value = "4"
|
mock_entry.get.return_value = "4"
|
||||||
|
|
||||||
with patch(
|
with patch("wake_alarm._alarm._make_challenge", return_value=next_flash):
|
||||||
"python_pkg.wake_alarm._alarm._make_challenge", return_value=next_flash
|
|
||||||
):
|
|
||||||
alarm._on_submit()
|
alarm._on_submit()
|
||||||
|
|
||||||
assert alarm._progress.current_challenge.kind == "flash"
|
assert alarm._progress.current_challenge.kind == "flash"
|
||||||
@ -330,7 +328,7 @@ class TestDismissWithoutSkip:
|
|||||||
alarm._view.container.winfo_children.return_value = [mock_widget]
|
alarm._view.container.winfo_children.return_value = [mock_widget]
|
||||||
|
|
||||||
with patch(
|
with patch(
|
||||||
"python_pkg.wake_alarm._alarm.save_wake_state",
|
"wake_alarm._alarm.save_wake_state",
|
||||||
) as mock_save:
|
) as mock_save:
|
||||||
alarm._dismiss_alarm(earned_skip=False)
|
alarm._dismiss_alarm(earned_skip=False)
|
||||||
|
|
||||||
|
|||||||
@ -11,7 +11,7 @@ import pytest
|
|||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import Generator
|
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)
|
# 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."""
|
"""Prevent any real Tk windows in tests."""
|
||||||
mock = _make_mock_tk()
|
mock = _make_mock_tk()
|
||||||
with (
|
with (
|
||||||
patch("python_pkg.wake_alarm._alarm.tk", mock),
|
patch("wake_alarm._alarm.tk", mock),
|
||||||
patch(
|
patch(
|
||||||
"python_pkg.wake_alarm._alarm.GateRoot",
|
"wake_alarm._alarm.GateRoot",
|
||||||
return_value=mock.Tk.return_value,
|
return_value=mock.Tk.return_value,
|
||||||
),
|
),
|
||||||
):
|
):
|
||||||
@ -51,17 +51,17 @@ def _block_real_tk() -> Generator[MagicMock]:
|
|||||||
def _block_extra_devices() -> Generator[MagicMock]:
|
def _block_extra_devices() -> Generator[MagicMock]:
|
||||||
"""Prevent real subprocess.Popen calls for extra ALSA devices."""
|
"""Prevent real subprocess.Popen calls for extra ALSA devices."""
|
||||||
with (
|
with (
|
||||||
patch("python_pkg.wake_alarm._alarm._play_on_extra_devices") as mock,
|
patch("wake_alarm._alarm._play_on_extra_devices") as mock,
|
||||||
patch("python_pkg.wake_alarm._alarm._max_fans", return_value=False),
|
patch("wake_alarm._alarm._max_fans", return_value=False),
|
||||||
patch("python_pkg.wake_alarm._alarm._restore_fans"),
|
patch("wake_alarm._alarm._restore_fans"),
|
||||||
patch("python_pkg.wake_alarm._alarm._set_max_brightness"),
|
patch("wake_alarm._alarm._set_max_brightness"),
|
||||||
patch("python_pkg.wake_alarm._alarm._wake_display"),
|
patch("wake_alarm._alarm._wake_display"),
|
||||||
patch("python_pkg.wake_alarm._alarm._restore_display"),
|
patch("wake_alarm._alarm._restore_display"),
|
||||||
patch("python_pkg.wake_alarm._alarm._warn_if_no_real_sink"),
|
patch("wake_alarm._alarm._warn_if_no_real_sink"),
|
||||||
patch("python_pkg.wake_alarm._alarm._activate_alarm_audio", return_value=None),
|
patch("wake_alarm._alarm._activate_alarm_audio", return_value=None),
|
||||||
patch("python_pkg.wake_alarm._alarm._restore_alarm_audio"),
|
patch("wake_alarm._alarm._restore_alarm_audio"),
|
||||||
patch("python_pkg.wake_alarm._alarm.turn_on_plug"),
|
patch("wake_alarm._alarm.turn_on_plug"),
|
||||||
patch("python_pkg.wake_alarm._alarm.turn_off_plug"),
|
patch("wake_alarm._alarm.turn_off_plug"),
|
||||||
):
|
):
|
||||||
yield mock
|
yield mock
|
||||||
|
|
||||||
@ -71,9 +71,9 @@ def mock_tk_module() -> Generator[MagicMock]:
|
|||||||
"""Provide explicit access to the mocked tk module."""
|
"""Provide explicit access to the mocked tk module."""
|
||||||
mock = _make_mock_tk()
|
mock = _make_mock_tk()
|
||||||
with (
|
with (
|
||||||
patch("python_pkg.wake_alarm._alarm.tk", mock),
|
patch("wake_alarm._alarm.tk", mock),
|
||||||
patch(
|
patch(
|
||||||
"python_pkg.wake_alarm._alarm.GateRoot",
|
"wake_alarm._alarm.GateRoot",
|
||||||
return_value=mock.Tk.return_value,
|
return_value=mock.Tk.return_value,
|
||||||
),
|
),
|
||||||
):
|
):
|
||||||
@ -133,7 +133,7 @@ class TestClose:
|
|||||||
del mock_tk_module
|
del mock_tk_module
|
||||||
alarm = WakeAlarm(demo_mode=True)
|
alarm = WakeAlarm(demo_mode=True)
|
||||||
alarm._hardware.fan_state = 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()
|
alarm.on_close()
|
||||||
mock_restore.assert_called_once_with(active=True)
|
mock_restore.assert_called_once_with(active=True)
|
||||||
alarm._stop_beep.set()
|
alarm._stop_beep.set()
|
||||||
@ -147,7 +147,7 @@ class TestClose:
|
|||||||
alarm = WakeAlarm(demo_mode=True)
|
alarm = WakeAlarm(demo_mode=True)
|
||||||
alarm._hardware.audio_restore = "jbl_sink"
|
alarm._hardware.audio_restore = "jbl_sink"
|
||||||
with patch(
|
with patch(
|
||||||
"python_pkg.wake_alarm._alarm._restore_alarm_audio",
|
"wake_alarm._alarm._restore_alarm_audio",
|
||||||
) as mock_restore:
|
) as mock_restore:
|
||||||
alarm.on_close()
|
alarm.on_close()
|
||||||
mock_restore.assert_called_once_with("jbl_sink")
|
mock_restore.assert_called_once_with("jbl_sink")
|
||||||
|
|||||||
@ -5,8 +5,8 @@ from __future__ import annotations
|
|||||||
import subprocess
|
import subprocess
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
from python_pkg.wake_alarm._alarm import _parse_args
|
from wake_alarm._alarm import _parse_args
|
||||||
from python_pkg.wake_alarm._audio import (
|
from wake_alarm._audio import (
|
||||||
_activate_alarm_audio,
|
_activate_alarm_audio,
|
||||||
_alarm_sink_present,
|
_alarm_sink_present,
|
||||||
_current_default_sink,
|
_current_default_sink,
|
||||||
@ -22,11 +22,11 @@ class TestWarnIfNoRealSink:
|
|||||||
"""No pactl on PATH → warns and returns."""
|
"""No pactl on PATH → warns and returns."""
|
||||||
with (
|
with (
|
||||||
patch(
|
patch(
|
||||||
"python_pkg.wake_alarm._audio.shutil.which",
|
"wake_alarm._audio.shutil.which",
|
||||||
return_value=None,
|
return_value=None,
|
||||||
),
|
),
|
||||||
patch(
|
patch(
|
||||||
"python_pkg.wake_alarm._audio.subprocess.run",
|
"wake_alarm._audio.subprocess.run",
|
||||||
) as mock_run,
|
) as mock_run,
|
||||||
):
|
):
|
||||||
_warn_if_no_real_sink()
|
_warn_if_no_real_sink()
|
||||||
@ -38,14 +38,14 @@ class TestWarnIfNoRealSink:
|
|||||||
result.stdout = b"4319\tauto_null\tPipeWire\tfloat32le 2ch 48000Hz\tIDLE\n"
|
result.stdout = b"4319\tauto_null\tPipeWire\tfloat32le 2ch 48000Hz\tIDLE\n"
|
||||||
with (
|
with (
|
||||||
patch(
|
patch(
|
||||||
"python_pkg.wake_alarm._audio.shutil.which",
|
"wake_alarm._audio.shutil.which",
|
||||||
return_value="/usr/bin/pactl",
|
return_value="/usr/bin/pactl",
|
||||||
),
|
),
|
||||||
patch(
|
patch(
|
||||||
"python_pkg.wake_alarm._audio.subprocess.run",
|
"wake_alarm._audio.subprocess.run",
|
||||||
return_value=result,
|
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()
|
_warn_if_no_real_sink()
|
||||||
mock_log.warning.assert_called()
|
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"
|
result.stdout = b"1\talsa_output.pci-0000_01_00.1.hdmi-stereo\tPipeWire\t-\t-\n"
|
||||||
with (
|
with (
|
||||||
patch(
|
patch(
|
||||||
"python_pkg.wake_alarm._audio.shutil.which",
|
"wake_alarm._audio.shutil.which",
|
||||||
return_value="/usr/bin/pactl",
|
return_value="/usr/bin/pactl",
|
||||||
),
|
),
|
||||||
patch(
|
patch(
|
||||||
"python_pkg.wake_alarm._audio.subprocess.run",
|
"wake_alarm._audio.subprocess.run",
|
||||||
return_value=result,
|
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()
|
_warn_if_no_real_sink()
|
||||||
mock_log.info.assert_called()
|
mock_log.info.assert_called()
|
||||||
@ -73,11 +73,11 @@ class TestWarnIfNoRealSink:
|
|||||||
"""OSError/TimeoutExpired running pactl → warning, no raise."""
|
"""OSError/TimeoutExpired running pactl → warning, no raise."""
|
||||||
with (
|
with (
|
||||||
patch(
|
patch(
|
||||||
"python_pkg.wake_alarm._audio.shutil.which",
|
"wake_alarm._audio.shutil.which",
|
||||||
return_value="/usr/bin/pactl",
|
return_value="/usr/bin/pactl",
|
||||||
),
|
),
|
||||||
patch(
|
patch(
|
||||||
"python_pkg.wake_alarm._audio.subprocess.run",
|
"wake_alarm._audio.subprocess.run",
|
||||||
side_effect=subprocess.TimeoutExpired("pactl", 5),
|
side_effect=subprocess.TimeoutExpired("pactl", 5),
|
||||||
),
|
),
|
||||||
):
|
):
|
||||||
@ -89,11 +89,11 @@ class TestAlarmSinkPresent:
|
|||||||
|
|
||||||
def test_true_when_sink_listed(self) -> None:
|
def test_true_when_sink_listed(self) -> None:
|
||||||
"""Returns True when the alarm sink name appears in pactl output."""
|
"""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")
|
proc = MagicMock(stdout=ALARM_AUDIO_SINK.encode() + b"\tPipeWire\n")
|
||||||
with patch(
|
with patch(
|
||||||
"python_pkg.wake_alarm._audio.subprocess.run",
|
"wake_alarm._audio.subprocess.run",
|
||||||
return_value=proc,
|
return_value=proc,
|
||||||
):
|
):
|
||||||
assert _alarm_sink_present("/usr/bin/pactl") is True
|
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."""
|
"""Returns False when the alarm sink is not in pactl output."""
|
||||||
proc = MagicMock(stdout=b"auto_null\tPipeWire\n")
|
proc = MagicMock(stdout=b"auto_null\tPipeWire\n")
|
||||||
with patch(
|
with patch(
|
||||||
"python_pkg.wake_alarm._audio.subprocess.run",
|
"wake_alarm._audio.subprocess.run",
|
||||||
return_value=proc,
|
return_value=proc,
|
||||||
):
|
):
|
||||||
assert _alarm_sink_present("/usr/bin/pactl") is False
|
assert _alarm_sink_present("/usr/bin/pactl") is False
|
||||||
@ -110,7 +110,7 @@ class TestAlarmSinkPresent:
|
|||||||
def test_false_on_subprocess_error(self) -> None:
|
def test_false_on_subprocess_error(self) -> None:
|
||||||
"""OSError while listing sinks → False, no raise."""
|
"""OSError while listing sinks → False, no raise."""
|
||||||
with patch(
|
with patch(
|
||||||
"python_pkg.wake_alarm._audio.subprocess.run",
|
"wake_alarm._audio.subprocess.run",
|
||||||
side_effect=OSError("boom"),
|
side_effect=OSError("boom"),
|
||||||
):
|
):
|
||||||
assert _alarm_sink_present("/usr/bin/pactl") is False
|
assert _alarm_sink_present("/usr/bin/pactl") is False
|
||||||
@ -123,7 +123,7 @@ class TestCurrentDefaultSink:
|
|||||||
"""Returns the trimmed default sink name."""
|
"""Returns the trimmed default sink name."""
|
||||||
proc = MagicMock(stdout=b"jbl_sink\n")
|
proc = MagicMock(stdout=b"jbl_sink\n")
|
||||||
with patch(
|
with patch(
|
||||||
"python_pkg.wake_alarm._audio.subprocess.run",
|
"wake_alarm._audio.subprocess.run",
|
||||||
return_value=proc,
|
return_value=proc,
|
||||||
):
|
):
|
||||||
assert _current_default_sink("/usr/bin/pactl") == "jbl_sink"
|
assert _current_default_sink("/usr/bin/pactl") == "jbl_sink"
|
||||||
@ -132,7 +132,7 @@ class TestCurrentDefaultSink:
|
|||||||
"""Empty output → None."""
|
"""Empty output → None."""
|
||||||
proc = MagicMock(stdout=b"\n")
|
proc = MagicMock(stdout=b"\n")
|
||||||
with patch(
|
with patch(
|
||||||
"python_pkg.wake_alarm._audio.subprocess.run",
|
"wake_alarm._audio.subprocess.run",
|
||||||
return_value=proc,
|
return_value=proc,
|
||||||
):
|
):
|
||||||
assert _current_default_sink("/usr/bin/pactl") is None
|
assert _current_default_sink("/usr/bin/pactl") is None
|
||||||
@ -140,7 +140,7 @@ class TestCurrentDefaultSink:
|
|||||||
def test_returns_none_on_error(self) -> None:
|
def test_returns_none_on_error(self) -> None:
|
||||||
"""TimeoutExpired → None, no raise."""
|
"""TimeoutExpired → None, no raise."""
|
||||||
with patch(
|
with patch(
|
||||||
"python_pkg.wake_alarm._audio.subprocess.run",
|
"wake_alarm._audio.subprocess.run",
|
||||||
side_effect=subprocess.TimeoutExpired("pactl", 3),
|
side_effect=subprocess.TimeoutExpired("pactl", 3),
|
||||||
):
|
):
|
||||||
assert _current_default_sink("/usr/bin/pactl") is None
|
assert _current_default_sink("/usr/bin/pactl") is None
|
||||||
@ -152,8 +152,8 @@ class TestActivateAlarmAudio:
|
|||||||
def test_returns_none_when_pactl_missing(self) -> None:
|
def test_returns_none_when_pactl_missing(self) -> None:
|
||||||
"""No pactl on PATH → returns None without touching audio."""
|
"""No pactl on PATH → returns None without touching audio."""
|
||||||
with (
|
with (
|
||||||
patch("python_pkg.wake_alarm._audio.shutil.which", return_value=None),
|
patch("wake_alarm._audio.shutil.which", return_value=None),
|
||||||
patch("python_pkg.wake_alarm._audio.subprocess.run") as mock_run,
|
patch("wake_alarm._audio.subprocess.run") as mock_run,
|
||||||
):
|
):
|
||||||
assert _activate_alarm_audio() is None
|
assert _activate_alarm_audio() is None
|
||||||
mock_run.assert_not_called()
|
mock_run.assert_not_called()
|
||||||
@ -162,23 +162,23 @@ class TestActivateAlarmAudio:
|
|||||||
"""Sink present → routes audio there and returns prior default sink."""
|
"""Sink present → routes audio there and returns prior default sink."""
|
||||||
with (
|
with (
|
||||||
patch(
|
patch(
|
||||||
"python_pkg.wake_alarm._audio.shutil.which",
|
"wake_alarm._audio.shutil.which",
|
||||||
return_value="/usr/bin/pactl",
|
return_value="/usr/bin/pactl",
|
||||||
),
|
),
|
||||||
patch(
|
patch(
|
||||||
"python_pkg.wake_alarm._audio._alarm_sink_present",
|
"wake_alarm._audio._alarm_sink_present",
|
||||||
return_value=True,
|
return_value=True,
|
||||||
),
|
),
|
||||||
patch(
|
patch(
|
||||||
"python_pkg.wake_alarm._audio._current_default_sink",
|
"wake_alarm._audio._current_default_sink",
|
||||||
return_value="jbl_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()
|
result = _activate_alarm_audio()
|
||||||
assert result == "jbl_sink"
|
assert result == "jbl_sink"
|
||||||
cmds = [call.args[0] for call in mock_run.call_args_list]
|
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_CARD,
|
||||||
ALARM_AUDIO_PROFILE,
|
ALARM_AUDIO_PROFILE,
|
||||||
ALARM_AUDIO_SINK,
|
ALARM_AUDIO_SINK,
|
||||||
@ -196,15 +196,15 @@ class TestActivateAlarmAudio:
|
|||||||
"""Sink never shows up → returns None after polling (no raise)."""
|
"""Sink never shows up → returns None after polling (no raise)."""
|
||||||
with (
|
with (
|
||||||
patch(
|
patch(
|
||||||
"python_pkg.wake_alarm._audio.shutil.which",
|
"wake_alarm._audio.shutil.which",
|
||||||
return_value="/usr/bin/pactl",
|
return_value="/usr/bin/pactl",
|
||||||
),
|
),
|
||||||
patch(
|
patch(
|
||||||
"python_pkg.wake_alarm._audio._alarm_sink_present",
|
"wake_alarm._audio._alarm_sink_present",
|
||||||
return_value=False,
|
return_value=False,
|
||||||
),
|
),
|
||||||
patch("python_pkg.wake_alarm._audio.time.sleep") as mock_sleep,
|
patch("wake_alarm._audio.time.sleep") as mock_sleep,
|
||||||
patch("python_pkg.wake_alarm._audio.subprocess.run"),
|
patch("wake_alarm._audio.subprocess.run"),
|
||||||
):
|
):
|
||||||
assert _activate_alarm_audio() is None
|
assert _activate_alarm_audio() is None
|
||||||
mock_sleep.assert_called()
|
mock_sleep.assert_called()
|
||||||
@ -213,19 +213,19 @@ class TestActivateAlarmAudio:
|
|||||||
"""Sink absent then present → sleeps once, then routes audio."""
|
"""Sink absent then present → sleeps once, then routes audio."""
|
||||||
with (
|
with (
|
||||||
patch(
|
patch(
|
||||||
"python_pkg.wake_alarm._audio.shutil.which",
|
"wake_alarm._audio.shutil.which",
|
||||||
return_value="/usr/bin/pactl",
|
return_value="/usr/bin/pactl",
|
||||||
),
|
),
|
||||||
patch(
|
patch(
|
||||||
"python_pkg.wake_alarm._audio._alarm_sink_present",
|
"wake_alarm._audio._alarm_sink_present",
|
||||||
side_effect=[False, True],
|
side_effect=[False, True],
|
||||||
),
|
),
|
||||||
patch(
|
patch(
|
||||||
"python_pkg.wake_alarm._audio._current_default_sink",
|
"wake_alarm._audio._current_default_sink",
|
||||||
return_value="old",
|
return_value="old",
|
||||||
),
|
),
|
||||||
patch("python_pkg.wake_alarm._audio.time.sleep") as mock_sleep,
|
patch("wake_alarm._audio.time.sleep") as mock_sleep,
|
||||||
patch("python_pkg.wake_alarm._audio.subprocess.run"),
|
patch("wake_alarm._audio.subprocess.run"),
|
||||||
):
|
):
|
||||||
assert _activate_alarm_audio() == "old"
|
assert _activate_alarm_audio() == "old"
|
||||||
mock_sleep.assert_called_once()
|
mock_sleep.assert_called_once()
|
||||||
@ -236,15 +236,15 @@ class TestRestoreAlarmAudio:
|
|||||||
|
|
||||||
def test_none_is_noop(self) -> None:
|
def test_none_is_noop(self) -> None:
|
||||||
"""None default → does nothing, no pactl lookup."""
|
"""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)
|
_restore_alarm_audio(None)
|
||||||
mock_which.assert_not_called()
|
mock_which.assert_not_called()
|
||||||
|
|
||||||
def test_no_pactl_returns_silently(self) -> None:
|
def test_no_pactl_returns_silently(self) -> None:
|
||||||
"""Default present but pactl missing → no raise, no run."""
|
"""Default present but pactl missing → no raise, no run."""
|
||||||
with (
|
with (
|
||||||
patch("python_pkg.wake_alarm._audio.shutil.which", return_value=None),
|
patch("wake_alarm._audio.shutil.which", return_value=None),
|
||||||
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")
|
_restore_alarm_audio("jbl_sink")
|
||||||
mock_run.assert_not_called()
|
mock_run.assert_not_called()
|
||||||
@ -253,10 +253,10 @@ class TestRestoreAlarmAudio:
|
|||||||
"""Calls set-default-sink with the captured prior default."""
|
"""Calls set-default-sink with the captured prior default."""
|
||||||
with (
|
with (
|
||||||
patch(
|
patch(
|
||||||
"python_pkg.wake_alarm._audio.shutil.which",
|
"wake_alarm._audio.shutil.which",
|
||||||
return_value="/usr/bin/pactl",
|
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")
|
_restore_alarm_audio("jbl_sink")
|
||||||
cmds = [call.args[0] for call in mock_run.call_args_list]
|
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
|
import pytest
|
||||||
|
|
||||||
from python_pkg.wake_alarm import _smart_plug
|
from wake_alarm import _smart_plug
|
||||||
from python_pkg.wake_alarm._smart_plug import (
|
from wake_alarm._smart_plug import (
|
||||||
_connect,
|
_connect,
|
||||||
_load_config,
|
_load_config,
|
||||||
_run,
|
_run,
|
||||||
|
|||||||
@ -8,7 +8,7 @@ from unittest.mock import patch
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from python_pkg.wake_alarm._state import (
|
from wake_alarm._state import (
|
||||||
_today_str,
|
_today_str,
|
||||||
has_workout_skip_today,
|
has_workout_skip_today,
|
||||||
load_wake_state,
|
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:
|
def _patch_wake_state_file(wake_state_file: Path) -> None:
|
||||||
"""Redirect WAKE_STATE_FILE to tmp_path for all tests."""
|
"""Redirect WAKE_STATE_FILE to tmp_path for all tests."""
|
||||||
with patch(
|
with patch(
|
||||||
"python_pkg.wake_alarm._state.WAKE_STATE_FILE",
|
"wake_alarm._state.WAKE_STATE_FILE",
|
||||||
wake_state_file,
|
wake_state_file,
|
||||||
):
|
):
|
||||||
yield
|
yield
|
||||||
@ -54,7 +54,7 @@ class TestSaveWakeState:
|
|||||||
def test_saves_with_hmac(self, wake_state_file: Path) -> None:
|
def test_saves_with_hmac(self, wake_state_file: Path) -> None:
|
||||||
"""Save state with HMAC signature when key is available."""
|
"""Save state with HMAC signature when key is available."""
|
||||||
with patch(
|
with patch(
|
||||||
"python_pkg.wake_alarm._state.compute_entry_hmac",
|
"wake_alarm._state.compute_entry_hmac",
|
||||||
return_value="fakesig",
|
return_value="fakesig",
|
||||||
):
|
):
|
||||||
result = save_wake_state(
|
result = save_wake_state(
|
||||||
@ -72,7 +72,7 @@ class TestSaveWakeState:
|
|||||||
def test_saves_without_hmac(self, wake_state_file: Path) -> None:
|
def test_saves_without_hmac(self, wake_state_file: Path) -> None:
|
||||||
"""Save unsigned state when HMAC key is unavailable."""
|
"""Save unsigned state when HMAC key is unavailable."""
|
||||||
with patch(
|
with patch(
|
||||||
"python_pkg.wake_alarm._state.compute_entry_hmac",
|
"wake_alarm._state.compute_entry_hmac",
|
||||||
return_value=None,
|
return_value=None,
|
||||||
):
|
):
|
||||||
result = save_wake_state(
|
result = save_wake_state(
|
||||||
@ -89,11 +89,11 @@ class TestSaveWakeState:
|
|||||||
"""Return False when file cannot be written."""
|
"""Return False when file cannot be written."""
|
||||||
with (
|
with (
|
||||||
patch(
|
patch(
|
||||||
"python_pkg.wake_alarm._state.compute_entry_hmac",
|
"wake_alarm._state.compute_entry_hmac",
|
||||||
return_value="sig",
|
return_value="sig",
|
||||||
),
|
),
|
||||||
patch(
|
patch(
|
||||||
"python_pkg.wake_alarm._state.WAKE_STATE_FILE",
|
"wake_alarm._state.WAKE_STATE_FILE",
|
||||||
wake_state_file / "nonexistent_dir" / "file.json",
|
wake_state_file / "nonexistent_dir" / "file.json",
|
||||||
),
|
),
|
||||||
):
|
):
|
||||||
@ -147,7 +147,7 @@ class TestLoadWakeState:
|
|||||||
}
|
}
|
||||||
wake_state_file.write_text(json.dumps(state))
|
wake_state_file.write_text(json.dumps(state))
|
||||||
with patch(
|
with patch(
|
||||||
"python_pkg.wake_alarm._state.verify_entry_hmac",
|
"wake_alarm._state.verify_entry_hmac",
|
||||||
return_value=False,
|
return_value=False,
|
||||||
):
|
):
|
||||||
assert load_wake_state() is None
|
assert load_wake_state() is None
|
||||||
@ -165,7 +165,7 @@ class TestLoadWakeState:
|
|||||||
}
|
}
|
||||||
wake_state_file.write_text(json.dumps(state))
|
wake_state_file.write_text(json.dumps(state))
|
||||||
with patch(
|
with patch(
|
||||||
"python_pkg.wake_alarm._state.verify_entry_hmac",
|
"wake_alarm._state.verify_entry_hmac",
|
||||||
return_value=True,
|
return_value=True,
|
||||||
):
|
):
|
||||||
result = load_wake_state()
|
result = load_wake_state()
|
||||||
@ -194,7 +194,7 @@ class TestHasWorkoutSkipToday:
|
|||||||
}
|
}
|
||||||
wake_state_file.write_text(json.dumps(state))
|
wake_state_file.write_text(json.dumps(state))
|
||||||
with patch(
|
with patch(
|
||||||
"python_pkg.wake_alarm._state.verify_entry_hmac",
|
"wake_alarm._state.verify_entry_hmac",
|
||||||
return_value=True,
|
return_value=True,
|
||||||
):
|
):
|
||||||
assert has_workout_skip_today() is True
|
assert has_workout_skip_today() is True
|
||||||
@ -212,7 +212,7 @@ class TestHasWorkoutSkipToday:
|
|||||||
}
|
}
|
||||||
wake_state_file.write_text(json.dumps(state))
|
wake_state_file.write_text(json.dumps(state))
|
||||||
with patch(
|
with patch(
|
||||||
"python_pkg.wake_alarm._state.verify_entry_hmac",
|
"wake_alarm._state.verify_entry_hmac",
|
||||||
return_value=True,
|
return_value=True,
|
||||||
):
|
):
|
||||||
assert has_workout_skip_today() is False
|
assert has_workout_skip_today() is False
|
||||||
@ -238,7 +238,7 @@ class TestWasAlarmDismissedToday:
|
|||||||
}
|
}
|
||||||
wake_state_file.write_text(json.dumps(state))
|
wake_state_file.write_text(json.dumps(state))
|
||||||
with patch(
|
with patch(
|
||||||
"python_pkg.wake_alarm._state.verify_entry_hmac",
|
"wake_alarm._state.verify_entry_hmac",
|
||||||
return_value=True,
|
return_value=True,
|
||||||
):
|
):
|
||||||
assert was_alarm_dismissed_today() is True
|
assert was_alarm_dismissed_today() is True
|
||||||
@ -256,7 +256,7 @@ class TestWasAlarmDismissedToday:
|
|||||||
}
|
}
|
||||||
wake_state_file.write_text(json.dumps(state))
|
wake_state_file.write_text(json.dumps(state))
|
||||||
with patch(
|
with patch(
|
||||||
"python_pkg.wake_alarm._state.verify_entry_hmac",
|
"wake_alarm._state.verify_entry_hmac",
|
||||||
return_value=True,
|
return_value=True,
|
||||||
):
|
):
|
||||||
assert was_alarm_dismissed_today() is False
|
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:
|
def test_returns_false_when_file_missing(self, tmp_path: Path) -> None:
|
||||||
"""Return False when the workout log file does not exist."""
|
"""Return False when the workout log file does not exist."""
|
||||||
with patch(
|
with patch(
|
||||||
"python_pkg.wake_alarm._state.WORKOUT_LOG_FILE",
|
"wake_alarm._state.WORKOUT_LOG_FILE",
|
||||||
tmp_path / "workout_log.json",
|
tmp_path / "workout_log.json",
|
||||||
):
|
):
|
||||||
assert was_workout_logged_today() is False
|
assert was_workout_logged_today() is False
|
||||||
@ -278,7 +278,7 @@ class TestWasWorkoutLoggedToday:
|
|||||||
log_file = tmp_path / "workout_log.json"
|
log_file = tmp_path / "workout_log.json"
|
||||||
log_file.write_text("not json {{{")
|
log_file.write_text("not json {{{")
|
||||||
with patch(
|
with patch(
|
||||||
"python_pkg.wake_alarm._state.WORKOUT_LOG_FILE",
|
"wake_alarm._state.WORKOUT_LOG_FILE",
|
||||||
log_file,
|
log_file,
|
||||||
):
|
):
|
||||||
assert was_workout_logged_today() is False
|
assert was_workout_logged_today() is False
|
||||||
@ -288,7 +288,7 @@ class TestWasWorkoutLoggedToday:
|
|||||||
log_file = tmp_path / "workout_log.json"
|
log_file = tmp_path / "workout_log.json"
|
||||||
log_file.write_text(json.dumps([1, 2, 3]))
|
log_file.write_text(json.dumps([1, 2, 3]))
|
||||||
with patch(
|
with patch(
|
||||||
"python_pkg.wake_alarm._state.WORKOUT_LOG_FILE",
|
"wake_alarm._state.WORKOUT_LOG_FILE",
|
||||||
log_file,
|
log_file,
|
||||||
):
|
):
|
||||||
assert was_workout_logged_today() is False
|
assert was_workout_logged_today() is False
|
||||||
@ -298,7 +298,7 @@ class TestWasWorkoutLoggedToday:
|
|||||||
log_file = tmp_path / "workout_log.json"
|
log_file = tmp_path / "workout_log.json"
|
||||||
log_file.write_text(json.dumps({"1999-01-01": {"type": "old"}}))
|
log_file.write_text(json.dumps({"1999-01-01": {"type": "old"}}))
|
||||||
with patch(
|
with patch(
|
||||||
"python_pkg.wake_alarm._state.WORKOUT_LOG_FILE",
|
"wake_alarm._state.WORKOUT_LOG_FILE",
|
||||||
log_file,
|
log_file,
|
||||||
):
|
):
|
||||||
assert was_workout_logged_today() is False
|
assert was_workout_logged_today() is False
|
||||||
@ -308,7 +308,7 @@ class TestWasWorkoutLoggedToday:
|
|||||||
log_file = tmp_path / "workout_log.json"
|
log_file = tmp_path / "workout_log.json"
|
||||||
log_file.write_text(json.dumps({_today_str(): {"type": "phone_verified"}}))
|
log_file.write_text(json.dumps({_today_str(): {"type": "phone_verified"}}))
|
||||||
with patch(
|
with patch(
|
||||||
"python_pkg.wake_alarm._state.WORKOUT_LOG_FILE",
|
"wake_alarm._state.WORKOUT_LOG_FILE",
|
||||||
log_file,
|
log_file,
|
||||||
):
|
):
|
||||||
assert was_workout_logged_today() is True
|
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