diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..c712d25 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,2 @@ +[run] +omit = tests/* diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..d85d9ab --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,27 @@ +name: CI + +on: + push: + branches: [main, development] + pull_request: + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.10", "3.13"] + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-python@v7 + with: + python-version: ${{ matrix.python-version }} + cache: pip + cache-dependency-path: requirements*.txt + - run: pip install -r requirements.txt -r requirements-dev.txt + - run: ruff check . + - run: python -m pytest --cov=. --cov-report=term-missing diff --git a/.gitignore b/.gitignore index f2cb5de..76c368b 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,6 @@ config.json *.db *.db-wal *.db-shm +.coverage +htmlcov/ +coverage.xml diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..38a6629 --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,25 @@ +workflow: + rules: + - if: $CI_PIPELINE_SOURCE == "merge_request_event" + - if: $CI_COMMIT_BRANCH && $CI_OPEN_MERGE_REQUESTS + when: never + - if: $CI_COMMIT_BRANCH + +test: + image: python:3.12 + variables: + PIP_CACHE_DIR: "$CI_PROJECT_DIR/.cache/pip" + cache: + key: + files: [requirements.txt, requirements-dev.txt] + paths: [.cache/pip] + script: + - pip install -r requirements.txt -r requirements-dev.txt + - ruff check . + - python -m pytest --cov=. --cov-report=term-missing --cov-report=xml + coverage: '/^TOTAL.+?(\d+(?:\.\d+)?%)$/' + artifacts: + reports: + coverage_report: + coverage_format: cobertura + path: coverage.xml diff --git a/CHANGELOG.md b/CHANGELOG.md index 7887197..94cf03e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,18 @@ All notable changes to WalkingDad will be documented in this file. ## [Unreleased] +### Fixed + +- **Pause and speed buttons no longer error out when Bluetooth isn't running.** If the Bluetooth connection had already gone away, pressing Pause or a speed button showed a server error page. Pause now still pauses the session, and the speed buttons do nothing. +- **A corrupted crash-recovery file no longer stops the app from starting.** If `session_state.json` contained unreadable (non-UTF-8) bytes, startup crashed. Startup now ignores the file, the same as an unparseable one. + +### Internal + +- **Tests now cover nearly all of the Python code** (ROADMAP 2.5): routes, Bluetooth sequences against a fake treadmill, status-packet math, persistence, and `run.py`'s launcher. The suite runs in about a second. +- **Continuous integration** (ROADMAP 2.7): `ruff` lint and the test suite run on pull/merge requests and on pushes to `main`/`development` via GitHub Actions (Python 3.10 and 3.13) and GitLab CI (Python 3.12, with coverage shown in merge requests). `ruff` is pinned in `requirements-dev.txt`, and lint fixes in `app.py` (imports sorted, unused `global` declarations removed) have no behavior change. +- **Importing `app.py` with `WALKINGDAD_NO_STARTUP=1` no longer installs the Ctrl+C/SIGTERM handlers or the `atexit` hook**, so pressing Ctrl+C during a test run interrupts pytest instead of killing the process two seconds later. Running the app normally is unchanged. +- **`run.py`'s launcher logic moved into a `main()` function** so it can be tested. `python run.py` behaves the same. + --- ## [1.8.0] (2026-10-05) diff --git a/README.md b/README.md index b0acaa8..f1fd76a 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,7 @@ The app opens your browser automatically at `http://127.0.0.1:5001`. A console w **Windows shortcut:** Double-click `start_app.bat` instead of running the commands manually. -**Running tests:** `pip install -r requirements-dev.txt`, then `python -m pytest`. No treadmill needed. Tests that import `app.py` run with `WALKINGDAD_NO_STARTUP=1` (set in `tests/conftest.py`), which skips its import-time startup: opening the real database, migrating `session_history.json`, and the Bluetooth scan. +**Running tests:** `pip install -r requirements-dev.txt`, then `python -m pytest` (add `--cov=.` for a coverage report) and `ruff check .`. No treadmill needed. Tests that import `app.py` run with `WALKINGDAD_NO_STARTUP=1` (set in `tests/conftest.py`), which skips its import-time startup: opening the real database, migrating `session_history.json`, the Bluetooth scan, and installing the Ctrl+C/exit handlers. CI runs the same lint and tests on pull/merge requests and on pushes to `main`/`development`, via GitHub Actions (`.github/workflows/ci.yml`) and GitLab CI (`.gitlab-ci.yml`). ## Usage diff --git a/ROADMAP.md b/ROADMAP.md index b757d71..5fdf7c0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -166,15 +166,25 @@ All user-tunable settings are now loaded from an optional `config.json` file, wi --- -### 2.5 Unit Tests -- **Status:** In Progress -- **Priority:** Medium -- **Problem:** Most of `app.py` has no automated tests, making refactoring risky. Covered so far: `storage.py`, the JSON migration, `units.py`, `samples.py` (2.9), and in `app.py` the stale-pause check (1.5) plus `process_status_packet()`'s active-time accrual and auto-pause detection, and `run.py`'s port-in-use check (1.6). -- **Solution:** Extend the suite to `app.py`'s logic: - - `format_seconds_to_hms()` - time formatting edge cases - - `kcal_estimate()` - calorie calculation - - `process_status_packet()` - distance/steps accumulation and device counter resets, speed history management (mock BLE data) -- **Implementation:** Tests import `app.py` directly; `tests/conftest.py` sets `WALKINGDAD_NO_STARTUP=1` so the import skips opening `walkingdad.db`, the JSON migration, the orphan sweep, the BLE thread, and the SSE broadcaster. +### ✅ 2.5 Unit Tests +**Status:** ✅ Complete +**Priority:** Medium +**Files Modified:** `app.py`, `run.py`, `requirements-dev.txt`, `pytest.ini`, `ruff.toml`, `.coveragerc`, `.gitignore`, `README.md`, `tests/` + +A `pytest` suite covering every module without a treadmill. How to run it: the README's **Running tests** line. + +| Area | Coverage | +|---|---| +| **Pure helpers** | Time formatting, calorie estimate, status-field extraction, settings casts and clamps, Apple Health shortcut URLs, stats payload and SSE framing | +| **Status packets** | Distance/step accumulation across device counter resets, calories, speed history filtering and cap, resume speed chosen on auto-pause, liveness stamping (plus the existing active-time and auto-pause tests) | +| **Routes** | Every Flask route through the test client: start/pause/resume/end, speed controls and clamping, restore/discard, CSV export, settings save, `/stats`, `/stats_stream`, `/shutdown` | +| **BLE and async** | A fake controller drives the wake/start/stop sequences, light wake, lock timeouts, the stats monitor, the idle watchdog, disconnect handling, auto-reconnect backoff, scanning, connecting, the BLE thread lifecycle, and signal/HTTP shutdown | +| **Persistence** | Session-state file save/load/clear (including corrupt files), `config.json` writes, and every database helper against a temp SQLite DB, including "log and continue" behavior when storage fails | +| **`run.py`** | Port check, browser launch, the HTTP shutdown call, and `main()`'s Waitress subprocess and Ctrl+C handling | + +**Implementation:** `tests/conftest.py` sets `WALKINGDAD_NO_STARTUP=1` before importing `app.py`, so the import skips opening `walkingdad.db`, the JSON migration, the orphan sweep, the BLE thread, the SSE broadcaster, and installing the signal/`atexit` handlers. Its `app_state` fixture resets every module global, points the state file, `config.json`, and the database at `tmp_path`, and records BLE coroutines instead of scheduling them, so tests can run a captured sequence against a fake controller. + +**Not covered:** the inline template JavaScript (left to 2.12's browser smoke test); code that only runs at import (`app.py`'s startup block, `config.py`'s no-`config.json` path); the background-thread entry points (SSE broadcaster start, `/shutdown`'s delayed exit); `_ble_thread`'s handlers for errors escaping asyncio's own `run_forever()` and task cleanup; and `run.py`'s Windows branch and `__main__` guard. --- @@ -187,12 +197,20 @@ All user-tunable settings are now loaded from an optional `config.json` file, wi --- -### 2.7 Continuous Integration -- **Status:** Planned -- **Priority:** Medium -- **Problem:** There's no GitHub Actions workflow; nothing runs automatically on push/PR, even though a `pytest` suite now exists (2.5). -- **Solution:** A GitHub Actions workflow that installs dependencies and runs the test suite plus a linter, on every push and PR. -- **Implementation:** `.github/workflows/ci.yml` running on `ubuntu-latest`, `pip install -r requirements.txt -r requirements-dev.txt`, then `pytest` and a linter (e.g. `ruff`). +### ✅ 2.7 Continuous Integration +**Status:** ✅ Complete +**Priority:** Medium +**Files Modified:** `.github/workflows/ci.yml`, `.gitlab-ci.yml`, `requirements-dev.txt`, `ruff.toml` + +Pull/merge requests and pushes to `main`/`development` run the lint and test suite on both GitHub Actions and GitLab CI. + +| Feature | Description | +|---|---| +| **GitHub Actions** | `.github/workflows/ci.yml` on `ubuntu-latest`, Python 3.10 (the supported floor) and 3.13. Pushes run only on `main`/`development`, so a PR branch isn't tested twice | +| **GitLab CI** | `.gitlab-ci.yml` on the `python:3.12` image. Runs a merge-request pipeline when an MR is open, otherwise a branch pipeline, and shows the coverage percentage and a Cobertura report in the MR | +| **Steps** | `pip install -r requirements.txt -r requirements-dev.txt`, `ruff check .`, `python -m pytest --cov=.`. CI reports coverage but never fails on it | +| **Lint** | `ruff` pinned in `requirements-dev.txt`, since its default rule set changes between releases. `ruff.toml` ignores only rules that flag deliberate project style (root logger, catch-all excepts around BLE I/O, naive local datetimes, a `ValueError` raised to share an `except` clause) | +| **Linux only** | BLE is fully mocked, so no Bluetooth hardware or OS-specific stack is needed. `tests/test_run.py` assumes POSIX (macOS/Linux) | --- @@ -238,23 +256,23 @@ Session data lives in a local SQLite database (`walkingdad.db`, configurable via - **Priority:** Medium - **Problem:** Pinning versions (2.6) fixes what's installed but doesn't catch a known CVE in whatever gets pinned, or a new one disclosed later against an already-pinned version. - **Solution:** Enable Dependabot security alerts on the repo, and/or run `pip-audit` against `requirements.txt` in CI (2.7). -- **Implementation:** A `.github/dependabot.yml` for version-update PRs plus security alerts; a `pip-audit` step added to the CI workflow from 2.7. +- **Implementation:** A `.github/dependabot.yml` for version-update PRs plus security alerts; a `pip-audit` step added to both CI configs from 2.7. --- ### 2.12 Integration Smoke Test - **Status:** Planned - **Priority:** Medium -- **Problem:** 2.5's unit tests cover pure functions, but nothing exercises the actual Start → Pause → Resume → End route flow end to end, so a template or routing regression could still slip through. +- **Problem:** 2.5's tests drive each route through Flask's test client, but nothing loads the rendered pages in a real browser and clicks through Start → Pause → Resume → End, so a template JavaScript or front-end wiring regression could still slip through. - **Solution:** One browser-driven smoke test that boots the app against a mocked BLE device/controller and clicks through the full session lifecycle, asserting each screen renders and each transition lands where expected. -- **Implementation:** Playwright (or Selenium) driving a test instance of the Flask app with `controller`/`BleakScanner` mocked out; run as part of the CI workflow from 2.7. +- **Implementation:** Playwright (or Selenium) driving a test instance of the Flask app with `controller`/`BleakScanner` mocked out; run as part of the CI configs from 2.7. --- ### 2.13 CONTRIBUTING.md - **Status:** Planned - **Priority:** Low -- **Problem:** There's no documented contribution process or versioning policy, which matters more once 2.7 (CI) and 2.5 (Unit Tests) make outside PRs realistically reviewable. +- **Problem:** There's no documented contribution process or versioning policy, which matters more now that 2.7 (CI) and 2.5 (Unit Tests) make outside PRs realistically reviewable. - **Solution:** A `CONTRIBUTING.md` covering local setup, how to run tests/lint, commit/PR expectations, and the project's versioning policy (semver against `CHANGELOG.md`). - **Implementation:** Plain markdown doc, no tooling. @@ -432,12 +450,26 @@ Active, Paused, and Start screens redesigned to read like the WalkingPad's own o ### 3.17 Tablet-Width Responsive Breakpoint - **Status:** Planned - **Priority:** Low -- **Problem:** `base.html` has exactly one `@media (max-width: 480px)` rule, tuned for phone-width screens. Desktop is the primary use case, but the app's own pitch covers "any browser on your network," and 2.7/3.7 (Touch/Swipe Gesture Controls) already anticipate a phone/tablet as a secondary device, so there's nothing tuned for the tablet width range in between. +- **Problem:** `base.html` has exactly one `@media (max-width: 480px)` rule, tuned for phone-width screens. Desktop is the primary use case, but the app's own pitch covers "any browser on your network," and 3.7 (Touch/Swipe Gesture Controls) already anticipates a phone/tablet as a secondary device, so there's nothing tuned for the tablet width range in between. - **Solution:** Add an intermediate breakpoint (e.g. `max-width: 900px`) tuned for tablet-class screens (iPad-size), rather than jumping straight from desktop layout to the 480px phone rules. - **Implementation:** Audit `console-hero`/`console-strip`/history table layout at common tablet widths (768-1024px) and add a second `@media` block alongside the existing 480px one. --- +### 3.18 Transition Hint Shows on Every Button Press +- **Status:** Planned +- **Priority:** Medium +- **Problem:** Clicking a speed preset or stepper on the active screen briefly shows "Getting the belt moving…" with a spinner, and the buttons jump down while it's visible. Every button has a variant of this, and the text is wrong for most of them: + - Pause and End flash "Getting the belt moving…" + - Start flashes "Stopping belt…" + - Resume flashes "Pausing belt…" + + Each page's `#transitioning-hint` text describes the transition *into* that page (active: after Start/Resume; paused: after Pause; start: after End), and the `belt_transitioning` flag in the SSE stats payload is meant to drive it. But `disableButtonsOnSubmit()` in `templates/base.html` also calls `setTransitioning(hintId, …, true)` on every form submit, so the hint appears for the request round-trip. The page then reloads with it hidden again, because speed changes never set `_belt_transitioning`. The hint toggles `display: none`, so showing it reflows the layout. +- **Solution:** On submit, only disable and dim the buttons, which is all the double-tap guard needs. Leave the hint to the server's `belt_transitioning` flag, so it only ever shows during a real start/pause/resume/end belt sequence, with text that matches. Optionally reserve the hint's space (toggle `visibility` instead of `display`) so the legitimate post-Start/Resume hint doesn't shift the layout either. +- **Implementation:** Drop the hint from `disableButtonsOnSubmit()` (`templates/base.html`; it can take just `buttons`, or call `setTransitioning(null, buttons, true)`, which `getElementById(null)` already tolerates). Update its three call sites in `active_session.html`, `paused_session.html` and `start_session.html`, plus the comments there that describe the behavior. No backend change. Verify by hand in the browser: presets and steppers show no hint or shift, and the correct hint still appears after Start, Pause, Resume and End while the belt sequence runs. Template JS has no automated tests (see 2.12). + +--- + ## Stats & Motivation ### 4.1 Estimated Time to Distance Goal diff --git a/app.py b/app.py index 3316946..96e38b4 100644 --- a/app.py +++ b/app.py @@ -14,15 +14,31 @@ from datetime import datetime, timedelta from bleak import BleakScanner -from flask import Flask, render_template, redirect, url_for, jsonify, make_response, request, Response +from flask import ( + Flask, + Response, + jsonify, + make_response, + redirect, + render_template, + request, + url_for, +) from ph4_walkingpad.pad import Controller, WalkingPad import config import storage from config import ( - BLE_DEVICE_NAME, KCAL_PER_MILE, MAX_SPEED_KMH, MIN_SPEED_KMH, - SPEED_STEP, SLOW_WALK_SPEED_KMH, RESUME_GRACE_PERIOD_SECONDS, - HISTORY_DISPLAY_LIMIT, APPLE_HEALTH_SHORTCUT_NAME, APPLE_HEALTH_EXPORT_ENABLED, + APPLE_HEALTH_EXPORT_ENABLED, + APPLE_HEALTH_SHORTCUT_NAME, + BLE_DEVICE_NAME, + HISTORY_DISPLAY_LIMIT, + KCAL_PER_MILE, + MAX_SPEED_KMH, + MIN_SPEED_KMH, + RESUME_GRACE_PERIOD_SECONDS, + SLOW_WALK_SPEED_KMH, + SPEED_STEP, STALE_PAUSE_TIMEOUT_MINUTES, ) from samples import SampleBuffer, summary_from_samples @@ -421,7 +437,7 @@ def _save_session_state(): with open(tmp_path, "w") as f: json.dump(snapshot, f, indent=2) os.replace(tmp_path, SESSION_STATE_FILE) - except IOError as exc: + except OSError as exc: logging.error(f"Failed to write session state: {exc}") @@ -436,7 +452,7 @@ def _load_session_state() -> dict | None: if not isinstance(state, dict) or not state.get("session_active"): return None return state - except (json.JSONDecodeError, IOError) as exc: + except (OSError, ValueError) as exc: logging.warning(f"Failed to read session state, ignoring: {exc}") return None @@ -447,7 +463,7 @@ def _clear_session_state(): try: if os.path.exists(SESSION_STATE_FILE): os.remove(SESSION_STATE_FILE) - except IOError as exc: + except OSError as exc: logging.error(f"Failed to clear session state: {exc}") @@ -519,11 +535,11 @@ def _build_log_shortcut_url(session_record: dict) -> str: # ── Context processor so templates always know flags ──────────────────── @app.context_processor def inject_flags(): - return dict( - connected=connected, connecting=connecting, connection_failed=connection_failed, - apple_health_shortcut_name=APPLE_HEALTH_SHORTCUT_NAME, - setup_shortcut_url=_build_setup_shortcut_url(), - ) + return { + "connected": connected, "connecting": connecting, "connection_failed": connection_failed, + "apple_health_shortcut_name": APPLE_HEALTH_SHORTCUT_NAME, + "setup_shortcut_url": _build_setup_shortcut_url(), + } # ── BLE helpers ───────────────────────────────────────────────────────── @@ -666,7 +682,7 @@ def process_status_packet(dev_dist: float, dev_steps: int, dev_speed: float): passive BLE notification callback (_handle_status_update), either path landing here means the connection is alive. """ - global belt_running, resume_speed_kmh, _resume_grace_deadline + global belt_running, resume_speed_kmh global current_speed_kmh, current_distance_km, current_steps, current_calories global _last_dev_dist, _last_dev_steps, _last_status_update_monotonic global current_session_active_seconds, _last_moving_packet_monotonic @@ -702,20 +718,20 @@ def process_status_packet(dev_dist: float, dev_steps: int, dev_speed: float): # Fires on any zero reading once the grace window has passed, not only on a # moving-to-zero transition: a belt that stopped (or never started) inside # the window would otherwise leave the session "walking" indefinitely. - if time.time() > _resume_grace_deadline and not _belt_transitioning: - if belt_running and new_reported_speed_kmh == 0: - logging.info("Belt has stopped unexpectedly. Auto-pausing session.") - - # Use the OLDEST speed from history to ignore the deceleration phase. - if speed_history: - resume_speed_kmh = speed_history[0] # Use the first (oldest) item - elif current_speed_kmh > 0: - # Walked only at or below MIN_SPEED_KMH, so nothing was recorded in history - resume_speed_kmh = MIN_SPEED_KMH - # else the belt never moved this segment: keep the speed it was asked for + if (time.time() > _resume_grace_deadline and not _belt_transitioning + and belt_running and new_reported_speed_kmh == 0): + logging.info("Belt has stopped unexpectedly. Auto-pausing session.") - belt_running = False - just_auto_paused = True + # Use the OLDEST speed from history to ignore the deceleration phase. + if speed_history: + resume_speed_kmh = speed_history[0] # Use the first (oldest) item + elif current_speed_kmh > 0: + # Walked only at or below MIN_SPEED_KMH, so nothing was recorded in history + resume_speed_kmh = MIN_SPEED_KMH + # else the belt never moved this segment: keep the speed it was asked for + + belt_running = False + just_auto_paused = True # Cumulative stats accumulation if dev_dist < _last_dev_dist: @@ -1233,7 +1249,6 @@ def _handle_disconnect(client): depending on platform/backend. """ global connected, belt_running, connecting, connection_failed - global _stats_monitor_task, _idle_watchdog_task, _belt_sequence_task, _speed_change_task with _start_ble_thread_lock: # Idempotent: this can legitimately fire 2-3 times for the same @@ -1726,7 +1741,7 @@ def discard_session(): def start_session(): """Begin a new session: reset counters, start belt, launch stats monitor.""" global session_active, belt_running, current_distance_km, current_steps, current_calories, resume_speed_kmh - global current_session_active_seconds, _stats_monitor_task, _session_start_time, current_speed_kmh + global current_session_active_seconds, _session_start_time, current_speed_kmh global _resume_grace_deadline, _pending_restore, _last_moving_packet_monotonic if not connected: @@ -1844,7 +1859,10 @@ async def _pause_belt_sequence(): _belt_sequence_task = None _belt_transitioning = False - asyncio.run_coroutine_threadsafe(_pause_belt_sequence(), ble_loop) + try: + asyncio.run_coroutine_threadsafe(_pause_belt_sequence(), ble_loop) + except Exception as exc: + logging.error(f"Failed to queue pause sequence: {exc}") return redirect(url_for("root")) @@ -1852,8 +1870,7 @@ async def _pause_belt_sequence(): @app.route("/resume", methods=["POST"], endpoint="resume") @app.route("/resume_session", methods=["POST"], endpoint="resume_session") def resume_session(): - global belt_running, _resume_grace_deadline, session_active, _stats_monitor_task - global _last_moving_packet_monotonic + global belt_running, _resume_grace_deadline, _last_moving_packet_monotonic with _session_state_lock: if not session_active: @@ -1921,13 +1938,7 @@ async def _resume_belt_sequence(): @app.route("/decrease_speed", methods=["POST"]) def decrease_speed(): """Decrease the belt speed by one step.""" - if not belt_running: - return redirect(url_for("root")) - - new_speed_kmh = max(MIN_SPEED_KMH, current_speed_kmh - SPEED_STEP) - dev_speed = int(new_speed_kmh * 10) - asyncio.run_coroutine_threadsafe(_locked_change_speed(dev_speed), ble_loop) - return redirect(url_for("root")) + return _set_preset_speed(max(MIN_SPEED_KMH, current_speed_kmh - SPEED_STEP)) def _set_preset_speed(speed_kmh: float): @@ -1936,7 +1947,10 @@ def _set_preset_speed(speed_kmh: float): return redirect(url_for("root")) dev_speed = int(speed_kmh * 10) - asyncio.run_coroutine_threadsafe(_locked_change_speed(dev_speed), ble_loop) + try: + asyncio.run_coroutine_threadsafe(_locked_change_speed(dev_speed), ble_loop) + except Exception as exc: + logging.error(f"Failed to queue speed change: {exc}") return redirect(url_for("root")) @@ -1955,13 +1969,7 @@ def slow_speed(): @app.route("/increase_speed", methods=["POST"]) def increase_speed(): """Increase the belt speed by one step.""" - if not belt_running: - return redirect(url_for("root")) - - new_speed_kmh = min(MAX_SPEED_KMH, current_speed_kmh + SPEED_STEP) - dev_speed = int(new_speed_kmh * 10) - asyncio.run_coroutine_threadsafe(_locked_change_speed(dev_speed), ble_loop) - return redirect(url_for("root")) + return _set_preset_speed(min(MAX_SPEED_KMH, current_speed_kmh + SPEED_STEP)) @app.route("/max_speed", methods=["POST"]) @@ -1977,19 +1985,19 @@ def _build_stats_payload() -> dict: Single source of truth for the wire payload shape, shared by the polling /stats endpoint and the SSE broadcaster. """ - return dict( - is_connected=connected, - connection_failed=connection_failed, - session_active=session_active, - is_running=belt_running, - belt_transitioning=_belt_transitioning, - speed=round(current_speed_kmh * KM_TO_MI, 1), - distance=round(current_distance_km * KM_TO_MI, 2), - steps=current_steps, - calories=round(current_calories), - time_active=format_seconds_to_hms(current_session_active_seconds), - stopping=_server_stopping, - ) + return { + "is_connected": connected, + "connection_failed": connection_failed, + "session_active": session_active, + "is_running": belt_running, + "belt_transitioning": _belt_transitioning, + "speed": round(current_speed_kmh * KM_TO_MI, 1), + "distance": round(current_distance_km * KM_TO_MI, 2), + "steps": current_steps, + "calories": round(current_calories), + "time_active": format_seconds_to_hms(current_session_active_seconds), + "stopping": _server_stopping, + } @app.route("/stats", endpoint="get_stats") @@ -2121,14 +2129,9 @@ def _deferred_exit(): return resp -# ── Signal handlers for graceful shutdown on Ctrl+C / SIGTERM ────────── -signal.signal(signal.SIGTERM, _handle_signal_shutdown) -signal.signal(signal.SIGINT, _handle_signal_shutdown) - # ── Atexit handler as safety net ──────────────────────────────────────── def _atexit_cleanup(): """Safety net: attempt to stop the belt and disconnect BLE on process exit.""" - global _shutting_down, ble_loop if _shutting_down: return # Already handled gracefully logging.info("atexit: performing emergency cleanup...") @@ -2138,10 +2141,12 @@ def _atexit_cleanup(): except Exception as exc: logging.error(f"atexit cleanup error: {exc}") -atexit.register(_atexit_cleanup) -# ── Kick off BLE thread ────────────────────────────────────────────────── -# The server is no longer started here. This just pre-starts the BLE thread. +# ── Startup: exit handlers (Ctrl+C / SIGTERM / atexit), BLE thread, SSE broadcaster ── +# The web server itself is started by run.py. Skipped under WALKINGDAD_NO_STARTUP=1 (tests). if _STARTUP_ENABLED: + signal.signal(signal.SIGTERM, _handle_signal_shutdown) + signal.signal(signal.SIGINT, _handle_signal_shutdown) + atexit.register(_atexit_cleanup) _start_ble_thread() _start_sse_broadcaster() diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..c7b23ec --- /dev/null +++ b/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +pythonpath = . +testpaths = tests diff --git a/requirements-dev.txt b/requirements-dev.txt index e079f8a..3cf67e4 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1 +1,4 @@ pytest +pytest-cov +# Pinned: ruff's default rule set changes between releases. +ruff==0.16.10 diff --git a/ruff.toml b/ruff.toml new file mode 100644 index 0000000..48c2908 --- /dev/null +++ b/ruff.toml @@ -0,0 +1,4 @@ +[lint] +# Deliberate style here: root logger, catch-all excepts around BLE I/O, naive local +# datetimes for session dates, and a ValueError raised to share an except clause. +ignore = ["LOG015", "BLE001", "DTZ005", "TRY004"] diff --git a/run.py b/run.py index e295a6e..9308eee 100644 --- a/run.py +++ b/run.py @@ -86,7 +86,7 @@ def http_shutdown(): time.sleep(6) -if __name__ == "__main__": +def main(): check_port() print("Starting production server with Waitress...") @@ -129,3 +129,7 @@ def http_shutdown(): server_process.kill() server_process.wait() print("Server stopped.") + + +if __name__ == "__main__": + main() diff --git a/tests/conftest.py b/tests/conftest.py index f1f37ea..d093fbe 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,3 +1,81 @@ import os os.environ["WALKINGDAD_NO_STARTUP"] = "1" + +import pytest + +import app +import config +import storage + +_RESET = { + "connected": True, "connecting": False, "connection_failed": False, + "ble_loop": None, "controller": None, "_device_ble_address": None, "_ble_command_lock": None, + "_last_status_update_monotonic": 0.0, + "_stats_monitor_task": None, "_idle_watchdog_task": None, "_belt_sequence_task": None, + "_speed_change_task": None, "_auto_reconnect_task": None, "_belt_transitioning": False, + "session_active": False, "belt_running": False, "_session_start_time": None, "_session_id": None, + "_session_start_monotonic": 0.0, "_pending_restore": None, "_paused_since": None, + "_resume_grace_deadline": 0, "_last_moving_packet_monotonic": None, "resume_speed_kmh": 2.0, + "current_speed_kmh": 0.0, "current_distance_km": 0.0, "current_steps": 0, "current_calories": 0.0, + "current_session_active_seconds": 0, "_last_dev_dist": 0, "_last_dev_steps": 0, + "_shutting_down": False, "_server_stopping": False, +} +# /settings POST rewrites these on both modules; registering them restores them after each test. +_SETTINGS_CONSTS = [c for _, c, _, _ in app._SETTINGS_SCHEMA] + ["APPLE_HEALTH_EXPORT_ENABLED"] + + +@pytest.fixture +def app_state(monkeypatch, tmp_path): + """Idle, connected app with every global reset, files and DB under tmp_path, and BLE scheduling recorded.""" + for name, value in _RESET.items(): + monkeypatch.setattr(app, name, value) + for name in _SETTINGS_CONSTS: + monkeypatch.setattr(config, name, getattr(config, name)) + if hasattr(app, name): + monkeypatch.setattr(app, name, getattr(app, name)) + app.speed_history.clear() + app._samples.reset() + monkeypatch.setattr(app, "_sse_subscribers", []) + monkeypatch.setattr(app, "SESSION_STATE_FILE", str(tmp_path / "session_state.json")) + monkeypatch.setattr(app, "_CONFIG_FILE", str(tmp_path / "config.json")) + monkeypatch.setattr(storage, "_db_path", None) + storage.init_db(str(tmp_path / "test.db")) + + scheduled = [] + + def record(coro, loop): + scheduled.append(coro) + + monkeypatch.setattr(app.asyncio, "run_coroutine_threadsafe", record) + yield scheduled + for coro in scheduled: + coro.close() + + +@pytest.fixture +def walking(monkeypatch): + """A walking session past its grace window, with a controllable monotonic clock.""" + clock = [1000.0] + monkeypatch.setattr(app.time, "monotonic", lambda: clock[0]) + monkeypatch.setattr(app, "_save_session_state", lambda: None) + monkeypatch.setattr(app, "_session_id", None) + monkeypatch.setattr(app, "session_active", True) + monkeypatch.setattr(app, "belt_running", True) + monkeypatch.setattr(app, "_belt_transitioning", False) + monkeypatch.setattr(app, "_resume_grace_deadline", 0) + monkeypatch.setattr(app, "current_session_active_seconds", 0) + monkeypatch.setattr(app, "current_speed_kmh", 0.0) + monkeypatch.setattr(app, "current_distance_km", 0.0) + monkeypatch.setattr(app, "current_steps", 0) + monkeypatch.setattr(app, "_last_moving_packet_monotonic", None) + monkeypatch.setattr(app, "_last_dev_dist", 0) + monkeypatch.setattr(app, "_last_dev_steps", 0) + monkeypatch.setattr(app, "resume_speed_kmh", 3.0) + app.speed_history.clear() + + def packet(speed_kmh, after=1.0, dist=0, steps=0): + clock[0] += after + app.process_status_packet(dist, steps, int(speed_kmh * 10)) + + return packet diff --git a/tests/test_active_time.py b/tests/test_active_time.py index 2f00a84..6e08b95 100644 --- a/tests/test_active_time.py +++ b/tests/test_active_time.py @@ -3,32 +3,6 @@ import app -@pytest.fixture -def walking(monkeypatch): - """A walking session past its grace window, with a controllable monotonic clock.""" - clock = [1000.0] - monkeypatch.setattr(app.time, "monotonic", lambda: clock[0]) - monkeypatch.setattr(app, "_save_session_state", lambda: None) - monkeypatch.setattr(app, "_session_id", None) - monkeypatch.setattr(app, "session_active", True) - monkeypatch.setattr(app, "belt_running", True) - monkeypatch.setattr(app, "_belt_transitioning", False) - monkeypatch.setattr(app, "_resume_grace_deadline", 0) - monkeypatch.setattr(app, "current_session_active_seconds", 0) - monkeypatch.setattr(app, "current_speed_kmh", 0.0) - monkeypatch.setattr(app, "_last_moving_packet_monotonic", None) - monkeypatch.setattr(app, "_last_dev_dist", 0) - monkeypatch.setattr(app, "_last_dev_steps", 0) - monkeypatch.setattr(app, "resume_speed_kmh", 3.0) - app.speed_history.clear() - - def packet(speed_kmh, after=1.0): - clock[0] += after - app.process_status_packet(0, 0, int(speed_kmh * 10)) - - return packet - - def test_counts_only_intervals_opened_by_a_moving_packet(walking, monkeypatch): monkeypatch.setattr(app, "_resume_grace_deadline", float("inf")) # ramp-up: no auto-pause walking(0) diff --git a/tests/test_app_helpers.py b/tests/test_app_helpers.py new file mode 100644 index 0000000..145d08e --- /dev/null +++ b/tests/test_app_helpers.py @@ -0,0 +1,647 @@ +import json +import queue +import re +import urllib.parse +from datetime import datetime, timedelta + +import pytest + +import app +import storage +from units import KM_TO_MI + +_SCHEMA = {field: cast for field, _, cast, _ in app._SETTINGS_SCHEMA} + + +@pytest.mark.parametrize(("secs", "expected"), [ + (0, "0:00:00"), + (59, "0:00:59"), + (60, "0:01:00"), + (3599, "0:59:59"), + (3600, "1:00:00"), + (36000 + 61, "10:01:01"), + (125 * 3600, "125:00:00"), + (61.99, "0:01:01"), +]) +def test_format_seconds_to_hms(secs, expected): + assert app.format_seconds_to_hms(secs) == expected + + +def test_kcal_estimate(monkeypatch): + monkeypatch.setattr(app, "KCAL_PER_MILE", 80) + assert app.kcal_estimate(2.5) == 200 + assert app.kcal_estimate(0) == 0 + + +def test_extract_status_fields_dict_and_object(): + assert app._extract_status_fields({"dist": 5, "steps": 7, "speed": 30}) == (5, 7, 30) + assert app._extract_status_fields({}) == (0, 0, 0) + + class Status: + dist = 9 + speed = 25 + + assert app._extract_status_fields(Status()) == (9, 0, 25) + assert app._extract_status_fields(object()) == (0, 0, 0) + + +def test_clamp_waitress_threads(): + lo, hi = app._MIN_WAITRESS_THREADS, app._MAX_WAITRESS_THREADS + assert app._clamp_waitress_threads(str(lo - 1), 8) == lo + assert app._clamp_waitress_threads("-5", 8) == lo + assert app._clamp_waitress_threads(str(lo), 8) == lo + assert app._clamp_waitress_threads(str(hi), 8) == hi + assert app._clamp_waitress_threads(str(hi + 1), 8) == hi + assert app._clamp_waitress_threads(" 16 ", 8) == 16 + for bad in ("", "abc", "4.5"): + with pytest.raises(ValueError, match="invalid literal"): + app._clamp_waitress_threads(bad, 8) + + +def test_clamp_resume_grace_period(): + lo = app._MIN_RESUME_GRACE_PERIOD_SECONDS + assert app._clamp_resume_grace_period(str(lo - 1), 10) == lo + assert app._clamp_resume_grace_period("0", 10) == lo + assert app._clamp_resume_grace_period(str(lo), 10) == lo + assert app._clamp_resume_grace_period("100000", 10) == 100000 + for bad in ("", "x", "3.5"): + with pytest.raises(ValueError, match="invalid literal"): + app._clamp_resume_grace_period(bad, 10) + + +def test_settings_schema_casts(): + assert _SCHEMA["ble_device_name"](" KS-X ", "old") == "KS-X" + assert _SCHEMA["ble_device_name"](" ", "old") == "" + assert _SCHEMA["host"](" 0.0.0.0 ", "127.0.0.1") == "0.0.0.0" + assert _SCHEMA["host"](" ", "127.0.0.1") == "127.0.0.1" + assert _SCHEMA["apple_health_shortcut_name"]("", "Log Walk") == "Log Walk" + assert _SCHEMA["stale_pause_timeout_minutes"]("-3", 30) == 0 + assert _SCHEMA["stale_pause_timeout_minutes"]("45", 30) == 45 + assert _SCHEMA["max_speed_kmh"]("6.5", 6.0) == 6.5 + assert _SCHEMA["kcal_per_mile"]("90", 80) == 90 + assert _SCHEMA["resume_grace_period_seconds"] is app._clamp_resume_grace_period + assert _SCHEMA["waitress_threads"] is app._clamp_waitress_threads + with pytest.raises(ValueError, match="invalid literal"): + _SCHEMA["port"]("http", 5000) + + +def test_build_setup_shortcut_url(): + assert app._build_setup_shortcut_url() == app._APPLE_HEALTH_SHORTCUT_ICLOUD_LINK + assert app._build_setup_shortcut_url().startswith("https://www.icloud.com/shortcuts/") + + +def test_build_log_shortcut_url(monkeypatch): + monkeypatch.setattr(app, "APPLE_HEALTH_SHORTCUT_NAME", "Log Walk & Run") + record = { + "date": "2026-01-02", "start_time": "08:30:00", "duration_seconds": 600, + "distance_km": 1.5, "distance_mi": 0.932, "calories": 70, "steps": 999, + } + url = app._build_log_shortcut_url(record) + assert url.startswith("shortcuts://run-shortcut?name=Log%20Walk%20%26%20Run&input=text&text=") + text = url.split("&text=", 1)[1] + assert " " not in text + assert '"' not in text + assert "{" not in text + assert "08:30:00" in text + payload = json.loads(urllib.parse.unquote(text)) + assert payload == {k: v for k, v in record.items() if k != "steps"} + + +def test_build_log_shortcut_url_keeps_colon_and_slash(monkeypatch): + monkeypatch.setattr(app, "APPLE_HEALTH_SHORTCUT_NAME", "a:b/c") + record = dict.fromkeys(("date", "start_time", "duration_seconds", "distance_km", "distance_mi", "calories"), 0) + assert "name=a:b/c&" in app._build_log_shortcut_url(record) + + +def test_inject_flags(app_state, monkeypatch): + monkeypatch.setattr(app, "connected", False) + monkeypatch.setattr(app, "connecting", True) + monkeypatch.setattr(app, "connection_failed", True) + monkeypatch.setattr(app, "APPLE_HEALTH_SHORTCUT_NAME", "My Shortcut") + assert app.inject_flags() == { + "connected": False, "connecting": True, "connection_failed": True, + "apple_health_shortcut_name": "My Shortcut", + "setup_shortcut_url": app._build_setup_shortcut_url(), + } + + +def test_build_stats_payload(app_state, monkeypatch): + monkeypatch.setattr(app, "session_active", True) + monkeypatch.setattr(app, "belt_running", True) + monkeypatch.setattr(app, "_belt_transitioning", True) + monkeypatch.setattr(app, "_server_stopping", True) + monkeypatch.setattr(app, "current_speed_kmh", 4.0) + monkeypatch.setattr(app, "current_distance_km", 1.23456) + monkeypatch.setattr(app, "current_steps", 1500) + monkeypatch.setattr(app, "current_calories", 61.6) + monkeypatch.setattr(app, "current_session_active_seconds", 3725.9) + assert app._build_stats_payload() == { + "is_connected": True, + "connection_failed": False, + "session_active": True, + "is_running": True, + "belt_transitioning": True, + "speed": round(4.0 * KM_TO_MI, 1), + "distance": round(1.23456 * KM_TO_MI, 2), + "steps": 1500, + "calories": 62, + "time_active": "1:02:05", + "stopping": True, + } + + +def test_build_stats_payload_idle(app_state): + payload = app._build_stats_payload() + assert payload["speed"] == 0 + assert payload["distance"] == 0 + assert payload["calories"] == 0 + assert payload["time_active"] == "0:00:00" + assert payload["is_running"] is False + + +def test_sse_frame(): + frame = app._sse_frame({"a": 1, "b": "x"}) + assert frame.startswith("data: ") + assert frame.endswith("\n\n") + assert frame.count("\n") == 2 + assert json.loads(frame[len("data: "):]) == {"a": 1, "b": "x"} + + +def _start_session(monkeypatch, minutes_ago=10): + monkeypatch.setattr(app, "session_active", True) + monkeypatch.setattr(app, "belt_running", True) + monkeypatch.setattr(app, "_session_start_time", datetime.now() - timedelta(minutes=minutes_ago)) + + +def test_build_session_state_snapshot(app_state, monkeypatch): + start = datetime.fromisoformat("2026-01-02T08:30:00") + for name, value in { + "session_active": True, "_session_id": "sid", "_session_start_time": start, + "current_distance_km": 1.5, "current_steps": 2000, "current_calories": 75.0, + "current_session_active_seconds": 900, "resume_speed_kmh": 3.5, + "_last_dev_dist": 150, "_last_dev_steps": 2000, + }.items(): + monkeypatch.setattr(app, name, value) + assert app._build_session_state_snapshot() == { + "session_active": True, "session_id": "sid", "session_start_time": "2026-01-02T08:30:00", + "current_distance_km": 1.5, "current_steps": 2000, "current_calories": 75.0, + "current_session_active_seconds": 900, "resume_speed_kmh": 3.5, + "last_dev_dist": 150, "last_dev_steps": 2000, + } + monkeypatch.setattr(app, "_session_start_time", None) + assert app._build_session_state_snapshot()["session_start_time"] is None + + +def test_save_session_state_noop_when_inactive(app_state, tmp_path): + app._save_session_state() + assert list(tmp_path.glob("session_state.json*")) == [] + + +def test_save_session_state_writes_atomically_and_flushes(app_state, monkeypatch, tmp_path): + _start_session(monkeypatch) + app._begin_db_session() + app._record_sample() + app._save_session_state() + with open(app.SESSION_STATE_FILE) as f: + assert json.load(f) == app._build_session_state_snapshot() + assert not (tmp_path / "session_state.json.tmp").exists() + assert len(storage.get_samples(app._session_id)) == 1 + + +def test_save_session_state_survives_write_error(app_state, monkeypatch, tmp_path, caplog): + _start_session(monkeypatch) + monkeypatch.setattr(app, "SESSION_STATE_FILE", str(tmp_path / "missing" / "s.json")) + app._save_session_state() + assert "Failed to write session state" in caplog.text + + +def test_load_session_state(app_state): + path = app.SESSION_STATE_FILE + assert app._load_session_state() is None + + def write(text): + with open(path, "w") as f: + f.write(text) + + write("{not json") + assert app._load_session_state() is None + write("[1, 2]") + assert app._load_session_state() is None + write('{"session_active": false, "current_steps": 5}') + assert app._load_session_state() is None + write("{}") + assert app._load_session_state() is None + write('{"session_active": true, "current_steps": 5}') + assert app._load_session_state() == {"session_active": True, "current_steps": 5} + + +def test_load_session_state_non_utf8_file(app_state): + with open(app.SESSION_STATE_FILE, "wb") as f: + f.write(b"\xff\xfe\x00garbage") + assert app._load_session_state() is None + + +def test_clear_session_state(app_state, tmp_path): + app._clear_session_state() + (tmp_path / "session_state.json").write_text("{}") + app._clear_session_state() + assert not (tmp_path / "session_state.json").exists() + + +def test_write_config_creates_new_file(app_state): + app._write_config({"PORT": 5001}) + with open(app._CONFIG_FILE) as f: + assert json.load(f) == {"PORT": 5001} + + +def test_write_config_merges_into_existing(app_state): + with open(app._CONFIG_FILE, "w") as f: + json.dump({"HOST": "h", "PORT": 1}, f) + app._write_config({"PORT": 2, "SPEED_STEP": 0.5}) + with open(app._CONFIG_FILE) as f: + assert json.load(f) == {"HOST": "h", "PORT": 2, "SPEED_STEP": 0.5} + + +def test_begin_db_session_creates_row(app_state, monkeypatch): + monkeypatch.setattr(app.time, "monotonic", lambda: 10_000.0) + _start_session(monkeypatch, minutes_ago=5) + app._samples.add(1, 0, 0, 0, belt_running=True) + app._begin_db_session() + row = storage.get_session(app._session_id) + assert row["status"] == "active" + assert row["device_model"] == app.BLE_DEVICE_NAME + assert datetime.fromisoformat(row["start_time"]) == app._session_start_time.astimezone().replace(microsecond=0) + assert app._session_start_monotonic == pytest.approx(10_000 - 300, abs=1) + assert app._samples.drain() == [] + + +def test_begin_db_session_reuses_existing_id(app_state, monkeypatch): + _start_session(monkeypatch) + existing = storage.create_session("2026-01-01T08:00:00+00:00", "x", None) + app._begin_db_session(existing) + assert app._session_id == existing + assert len(storage.list_active_sessions()) == 1 + + +def test_begin_db_session_unknown_existing_id_creates_new(app_state, monkeypatch): + _start_session(monkeypatch) + app._begin_db_session("gone") + assert app._session_id not in (None, "gone") + assert storage.get_session(app._session_id) is not None + + +def test_begin_db_session_storage_failure_clears_id(app_state, monkeypatch): + _start_session(monkeypatch) + monkeypatch.setattr(app, "_session_id", "stale") + + def boom(*a, **k): + raise RuntimeError + + monkeypatch.setattr(storage, "create_session", boom) + app._begin_db_session() + assert app._session_id is None + + +def test_record_sample_and_flush(app_state, monkeypatch): + clock = [5000.0] + monkeypatch.setattr(app.time, "monotonic", lambda: clock[0]) + _start_session(monkeypatch, minutes_ago=0) + app._begin_db_session() + monkeypatch.setattr(app, "current_speed_kmh", 3.6) + monkeypatch.setattr(app, "current_distance_km", 0.25) + monkeypatch.setattr(app, "current_steps", 300) + clock[0] += 2 + app._record_sample() + monkeypatch.setattr(app, "belt_running", False) + clock[0] += 5 + app._record_sample() + app._flush_samples() + rows = storage.get_samples(app._session_id) + assert [(r["speed_mps"], r["distance_m"], r["steps"], r["belt_running"]) for r in rows] == [ + (pytest.approx(1.0), 250, 300, 1), (0.0, 250, 300, 0), + ] + assert rows[1]["t_ms"] - rows[0]["t_ms"] == 5000 + app._flush_samples() + assert len(storage.get_samples(app._session_id)) == 2 + + +def test_record_sample_noop_without_session(app_state, monkeypatch): + app._record_sample() + assert app._samples.drain() == [] + _start_session(monkeypatch) + app._record_sample() + assert app._samples.drain() == [] + + +def test_flush_samples_without_session_id_drops_rows(app_state, monkeypatch): + monkeypatch.setattr(storage, "append_samples", lambda *a: pytest.fail("should not write")) + app._samples.add(1, 0, 0, 0, belt_running=True) + app._flush_samples() + assert app._samples.drain() == [] + + +def _pauses(session_id): + conn = storage._connect(storage._db_path) + try: + return [tuple(r) for r in conn.execute( + "SELECT start_time, end_time, reason FROM pauses WHERE session_id = ?", (session_id,), + )] + finally: + conn.close() + + +def test_record_pause_and_resume(app_state, monkeypatch): + sid = storage.create_session("2026-01-01T08:00:00+00:00", None, None) + monkeypatch.setattr(app, "_session_id", sid) + app._record_pause("manual", at="2026-01-01T08:05:00+00:00") + assert _pauses(sid) == [("2026-01-01T08:05:00+00:00", None, "manual")] + monkeypatch.setattr(app, "_now_iso", lambda: "2026-01-01T08:06:00+00:00") + app._record_resume() + assert _pauses(sid) == [("2026-01-01T08:05:00+00:00", "2026-01-01T08:06:00+00:00", "manual")] + app._record_pause("auto") + assert _pauses(sid)[-1] == ("2026-01-01T08:06:00+00:00", None, "auto") + + +def test_record_pause_resume_noop_without_session_id(app_state, monkeypatch): + monkeypatch.setattr(storage, "add_pause", lambda *a: pytest.fail("should not write")) + monkeypatch.setattr(storage, "end_pause", lambda *a: pytest.fail("should not write")) + app._record_pause("manual") + app._record_resume() + + +def test_last_sample_time(app_state, monkeypatch): + assert app._last_sample_time() is None + start = datetime(2026, 1, 2, 8, 0).astimezone() + monkeypatch.setattr(app, "_session_start_time", start) + sid = storage.create_session(start.isoformat(), None, None) + monkeypatch.setattr(app, "_session_id", sid) + assert app._last_sample_time() is None + storage.append_samples(sid, [(1000, 1.0, 1, 1, 1, None), (65_500, 1.0, 60, 90, 1, None)]) + assert app._last_sample_time() == (start + timedelta(seconds=65)).isoformat() + + +def test_save_session(app_state, monkeypatch): + _start_session(monkeypatch, minutes_ago=20) + app._begin_db_session() + sid = app._session_id + monkeypatch.setattr(app, "current_distance_km", 1.2) + monkeypatch.setattr(app, "current_steps", 1600) + monkeypatch.setattr(app, "current_calories", 60.0) + monkeypatch.setattr(app, "current_session_active_seconds", 800) + app._save_session() + assert app._session_id is None + row = storage.get_session(sid) + assert row["status"] == "completed" + assert row["distance_m"] == pytest.approx(1200) + assert row["steps"] == 1600 + assert row["calories_kcal"] == 60.0 + assert row["moving_s"] == 800 + assert row["avg_speed_mps"] == pytest.approx(1.5) + assert row["elapsed_s"] == pytest.approx(1200, abs=2) + assert row["has_samples"] == 1 + assert datetime.fromisoformat(row["end_time"]) <= datetime.now().astimezone() + + +def test_save_session_zero_moving_time_and_missing_row(app_state, monkeypatch): + _start_session(monkeypatch) + monkeypatch.setattr(app, "current_distance_km", 0.01) + app._save_session() + (row,) = storage.list_sessions() + assert row["avg_speed_mps"] == pytest.approx(10.0) + + +def test_save_session_noop_when_inactive(app_state, monkeypatch): + monkeypatch.setattr(app, "_session_id", "sid") + app._save_session() + assert app._session_id == "sid" + assert storage.list_sessions() == [] + + +def test_save_session_resets_id_on_storage_error(app_state, monkeypatch): + _start_session(monkeypatch) + app._begin_db_session() + + def boom(*a): + raise RuntimeError + + monkeypatch.setattr(storage, "complete_session", boom) + app._save_session() + assert app._session_id is None + + +def _completed(start, distance_m=1000.0): + sid = storage.create_session(start, None, None) + storage.complete_session(sid, { + "end_time": start, "elapsed_s": 600, "moving_s": 600, "distance_m": distance_m, + "steps": 1000, "calories_kcal": 50, "avg_speed_mps": 1.0, + }) + return sid + + +def test_load_session_history(app_state): + _completed("2026-01-01T08:00:00+00:00") + newest = _completed("2026-01-03T08:00:00+00:00", 2000) + storage.create_session("2026-01-04T08:00:00+00:00", None, None) + history = app._load_session_history() + assert [h["date"] for h in history] == ["2026-01-03", "2026-01-01"] + assert history[0]["id"] == newest + assert history[0]["distance_km"] == 2.0 + assert len(app._load_session_history(limit=1)) == 1 + + +def test_load_session_history_storage_error(app_state, monkeypatch): + def boom(**k): + raise RuntimeError + + monkeypatch.setattr(storage, "list_sessions", boom) + assert app._load_session_history() == [] + + +def test_clear_session_history_keeps_active(app_state): + _completed("2026-01-01T08:00:00+00:00") + active = storage.create_session("2026-01-02T08:00:00+00:00", None, None) + app._clear_session_history() + assert storage.list_sessions() == [] + assert storage.get_session(active) is not None + + +def test_dismiss_health_export_marks_most_recent(app_state): + older = _completed("2026-01-01T08:00:00+00:00") + newer = _completed("2026-01-02T08:00:00+00:00") + app._dismiss_health_export() + assert storage.get_session(newer)["health_logged"] == 1 + assert storage.get_session(older)["health_logged"] == 0 + + +def test_dismiss_health_export_empty_history(app_state): + app._dismiss_health_export() + assert storage.list_sessions() == [] + + +def test_sweep_orphaned_sessions(app_state): + start = "2026-01-01T08:00:00+00:00" + with_samples = storage.create_session(start, None, None) + storage.append_samples(with_samples, [(1000, 1.0, 1.0, 2, 1, None), (2000, 1.0, 500.0, 600, 1, None)]) + empty = storage.create_session(start, None, None) + keep = storage.create_session(start, None, None) + app._sweep_orphaned_sessions(keep) + row = storage.get_session(with_samples) + assert row["status"] == "completed" + assert row["distance_m"] == 500.0 + assert row["steps"] == 600 + assert row["end_time"] == "2026-01-01T08:00:02+00:00" + assert row["calories_kcal"] == pytest.approx(app.KCAL_PER_MILE * 0.5 * KM_TO_MI) + assert storage.get_session(empty) is None + assert storage.get_session(keep)["status"] == "active" + + +def test_sweep_orphaned_sessions_none_keep_id(app_state): + sid = storage.create_session("2026-01-01T08:00:00+00:00", None, None) + app._sweep_orphaned_sessions(None) + assert storage.get_session(sid) is None + + +def test_is_shutting_down(app_state, monkeypatch): + assert app._is_shutting_down() is False + monkeypatch.setattr(app, "_shutting_down", True) + assert app._is_shutting_down() is True + + +def test_now_iso_format(): + now = app._now_iso() + assert re.fullmatch(r"\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d[+-]\d\d:\d\d", now) + assert abs((datetime.fromisoformat(now) - datetime.now().astimezone()).total_seconds()) < 2 + + +class _Loop: + def __init__(self, closed): + self.closed = closed + + def is_closed(self): + return self.closed + + +def test_atexit_cleanup_noop_paths(app_state, monkeypatch): + monkeypatch.setattr(app, "_shutting_down", True) + monkeypatch.setattr(app, "ble_loop", _Loop(closed=False)) + app._atexit_cleanup() + monkeypatch.setattr(app, "_shutting_down", False) + monkeypatch.setattr(app, "ble_loop", None) + app._atexit_cleanup() + monkeypatch.setattr(app, "ble_loop", _Loop(closed=True)) + app._atexit_cleanup() + assert app_state == [] + + +def test_atexit_cleanup_swallows_errors(app_state, monkeypatch): + monkeypatch.setattr(app, "ble_loop", _Loop(closed=False)) + app._atexit_cleanup() # the recorder returns None, so .result() raises AttributeError + assert len(app_state) == 1 + assert app_state[0].cr_code is app._graceful_shutdown.__code__ + + +class _Stop(Exception): + pass + + +def _run_one_tick(monkeypatch): + calls = [] + + def sleep(_): + calls.append(1) + if len(calls) == 2: + raise _Stop + + monkeypatch.setattr(app.time, "sleep", sleep) + with pytest.raises(_Stop): + app._sse_broadcast_loop() + + +def test_sse_broadcast_replaces_stale_item(app_state, monkeypatch): + stale, fresh = queue.Queue(maxsize=1), queue.Queue(maxsize=1) + stale.put("stale") + app._sse_subscribers.extend([stale, fresh]) + monkeypatch.setattr(app, "current_steps", 42) + _run_one_tick(monkeypatch) + expected = app._sse_frame(app._build_stats_payload()) + for q in (stale, fresh): + assert q.get_nowait() == expected + assert q.empty() + assert json.loads(expected[len("data: "):])["steps"] == 42 + + +def test_sse_broadcast_survives_stale_pause_error(app_state, monkeypatch): + def boom(): + raise RuntimeError + + monkeypatch.setattr(app, "_end_session_if_stale_pause", boom) + q = queue.Queue() + app._sse_subscribers.append(q) + _run_one_tick(monkeypatch) + assert q.get_nowait() == app._sse_frame(app._build_stats_payload()) + + +def test_sse_broadcast_survives_failed_tick(app_state, monkeypatch, caplog): + real_payload, sleeps = app._build_stats_payload, [] + + def payload(): + if len(sleeps) == 1: + raise RuntimeError("bad tick") + return real_payload() + + def sleep(_): + sleeps.append(1) + if len(sleeps) == 3: + raise _Stop + + monkeypatch.setattr(app, "_build_stats_payload", payload) + monkeypatch.setattr(app.time, "sleep", sleep) + q = queue.Queue() + app._sse_subscribers.append(q) + with pytest.raises(_Stop): + app._sse_broadcast_loop() + assert "SSE broadcast tick failed (continuing): bad tick" in caplog.text + assert q.get_nowait() == app._sse_frame(real_payload()) + assert q.empty() + + +def _boom(*a, **k): + raise RuntimeError("db down") + + +def _oserror(*a): + raise OSError("read-only") + + +@pytest.mark.parametrize(("call", "target", "attr", "expected"), [ + (app._flush_samples, storage, "append_samples", None), + (lambda: app._record_pause("manual"), storage, "add_pause", None), + (app._last_sample_time, storage, "get_samples", None), + (lambda: app._sweep_orphaned_sessions(None), storage, "list_active_sessions", None), + (app._record_resume, storage, "end_pause", None), + (app._load_session_history, storage, "list_sessions", []), + (app._clear_session_history, storage, "clear_history", None), + (app._dismiss_health_export, storage, "list_sessions", None), + (app._clear_session_state, app.os, "remove", None), + (app._begin_db_session, storage, "create_session", None), +]) +def test_storage_failures_are_logged_not_raised(app_state, monkeypatch, caplog, call, target, attr, expected): + _start_session(monkeypatch) + monkeypatch.setattr(app, "_session_id", "sid") + app._samples.add(1, 0, 0, 0, belt_running=True) + with open(app.SESSION_STATE_FILE, "w") as f: + f.write("{}") + monkeypatch.setattr(target, attr, _boom if target is storage else _oserror) + assert call() == expected + assert any(r.levelname == "ERROR" for r in caplog.records) + + +def test_save_session_without_db_row(app_state, monkeypatch, caplog): + _start_session(monkeypatch) + monkeypatch.setattr(storage, "create_session", _boom) + app._begin_db_session() + app._save_session() + assert app._session_id is None + assert "Session not saved: no database row" in caplog.text + assert storage.list_sessions() == [] + assert storage.list_active_sessions() == [] diff --git a/tests/test_ble_async.py b/tests/test_ble_async.py new file mode 100644 index 0000000..c694ad5 --- /dev/null +++ b/tests/test_ble_async.py @@ -0,0 +1,1053 @@ +import asyncio +import concurrent.futures +import logging +import time +from datetime import datetime +from types import SimpleNamespace + +import pytest + +import app +import storage + +STANDBY = ("switch_mode", app.WalkingPad.MODE_STANDBY) +MANUAL = ("switch_mode", app.WalkingPad.MODE_MANUAL) +FULL_WAKE = [STANDBY, MANUAL, ("start_belt",)] +_real_sleep = asyncio.sleep + + +class FakeClient: + def __init__(self, connected=True, fail=False): + self.is_connected = connected + self.fail = fail + self.disconnects = 0 + self.callback = None + + async def disconnect(self): + self.disconnects += 1 + if self.fail: + raise RuntimeError("disconnect failed") + + def set_disconn_callback(self, cb): + self.callback = cb + + +class FakeController: + """Records calls in order; `fail` names methods that raise; `hook(name)` runs on every call.""" + + def __init__(self, fail=(), hook=None): + self.calls = [] + self.fail = set(fail) + self.hook = hook + self.client = FakeClient() + + async def _call(self, name, *args): + self.calls.append((name, *args)) + if self.hook: + self.hook(name) + if name in self.fail: + raise RuntimeError(f"{name} failed") + + async def run(self, address): + await self._call("run", address) + + async def switch_mode(self, mode): + await self._call("switch_mode", mode) + + async def start_belt(self): + await self._call("start_belt") + + async def stop_belt(self): + await self._call("stop_belt") + + async def change_speed(self, speed): + await self._call("change_speed", speed) + + async def ask_stats(self): + await self._call("ask_stats") + + +class FakeLoop: + def __init__(self, running=True): + self.running = running + self.scheduled = [] + + def is_running(self): + return self.running + + def is_closed(self): + return False + + def stop(self): + self.running = False + + def call_soon_threadsafe(self, fn, *args): + self.scheduled.append(fn) + fn(*args) + + +def reply(speed_kmh): + """A hook that answers ask_stats() with a status packet at speed_kmh.""" + def hook(name): + if name == "ask_stats": + app.process_status_packet(0, 0, int(speed_kmh * 10)) + return hook + + +def run(make_coro): + async def main(): + app._ble_command_lock = asyncio.Lock() + return await make_coro() + return asyncio.run(main()) + + +@pytest.fixture +def sleeps(app_state, monkeypatch): + """Instant asyncio.sleep (still yields); returns the requested delays.""" + delays = [] + + async def fake(delay, result=None): + delays.append(delay) + await _real_sleep(0) + return result + + monkeypatch.setattr(app.asyncio, "sleep", fake) + return delays + + +def on_sleep(monkeypatch, action): + """Instant asyncio.sleep that first calls action(n) with the 1-based call count.""" + count = [0] + + async def fake(delay, result=None): + count[0] += 1 + action(count[0]) + await _real_sleep(0) + return result + + monkeypatch.setattr(app.asyncio, "sleep", fake) + + +def disconnect_on_sleep(n): + """on_sleep action: drop `connected` on the nth sleep, ending the idle watchdog.""" + def action(count): + if count == n: + app.connected = False + return action + + +@pytest.fixture +def pad(sleeps, monkeypatch): + fake = FakeController() + monkeypatch.setattr(app, "controller", fake) + return fake + + +@pytest.fixture +def disconnects(monkeypatch): + calls = [] + monkeypatch.setattr(app, "_handle_disconnect", calls.append) + return calls + + +# ── small helpers ──────────────────────────────────────────────────────── +@pytest.mark.parametrize(("client", "expected"), [ + (FakeClient(), 1), + (FakeClient(connected=False), 0), + (FakeClient(fail=True), 1), +]) +def test_safe_disconnect_client(app_state, client, expected): + run(lambda: app._safe_disconnect_client(client, "test")) + assert client.disconnects == expected + + +def test_safe_disconnect_client_none(app_state): + run(lambda: app._safe_disconnect_client(None, "test")) + + +def test_run_locked_returns_factory_result(app_state): + async def make(): + assert app._ble_command_lock.locked() + return "ok" + + assert run(lambda: app._run_locked(make)) == "ok" + + +def test_run_locked_acquire_timeout_skips_factory(app_state): + made = [] + + async def body(): + await app._ble_command_lock.acquire() + with pytest.raises(asyncio.TimeoutError): + await app._run_locked(lambda: made.append(1), timeout=0.01) + + run(body) + assert made == [] + + +def test_run_locked_releases_after_factory_raises(app_state): + async def boom(): + raise ValueError("boom") + + async def body(): + with pytest.raises(ValueError, match="boom"): + await app._run_locked(boom) + assert not app._ble_command_lock.locked() + + run(body) + + +def test_cancel_task(app_state): + async def body(): + await app._cancel_task(None) + done = asyncio.create_task(_real_sleep(0)) + await done + await app._cancel_task(done) + assert not done.cancelled() + running = asyncio.create_task(asyncio.Event().wait()) + await _real_sleep(0) + await app._cancel_task(running) + assert running.cancelled() + + run(body) + + +def test_check_staleness_and_disconnect(app_state, monkeypatch, disconnects): + monkeypatch.setattr(app.time, "monotonic", lambda: 1000.0) + app._last_status_update_monotonic = 1000.0 - app._STALE_STATUS_TIMEOUT_SECONDS + assert app._check_staleness_and_disconnect("test") is False + assert disconnects == [] + app._last_status_update_monotonic -= 1 + assert app._check_staleness_and_disconnect("test") is True + assert disconnects == [None] + + +# ── wake sequences ─────────────────────────────────────────────────────── +def test_full_wake_sequence_order(pad): + run(app._full_wake_sequence) + assert pad.calls == FULL_WAKE + + +def test_try_light_wake_confirmed(pad): + pad.hook = reply(3.0) + assert run(app._try_light_wake) is True + assert pad.calls == [("start_belt",), ("ask_stats",)] + + +@pytest.mark.parametrize(("speed_before", "hook"), [ + (3.0, None), # stale nonzero speed from before the probe must not count + (0.0, reply(0.0)), # fresh packet, but belt not moving +]) +def test_try_light_wake_unconfirmed(pad, speed_before, hook): + app.current_speed_kmh = speed_before + pad.hook = hook + assert run(app._try_light_wake) is False + assert pad.calls.count(("ask_stats",)) == app._LIGHT_WAKE_PROBE_ATTEMPTS + + +def test_try_light_wake_swallows_start_belt_error(pad): + pad.fail = {"start_belt"} + pad.hook = reply(3.0) + assert run(app._try_light_wake) is True + + +def test_wake_and_start_belt_full(pad): + run(lambda: app._wake_and_start_belt(3.5)) + assert pad.calls == [*FULL_WAKE, ("change_speed", int(3.5 * 10))] + + +def test_wake_and_start_belt_no_speed(pad): + run(app._wake_and_start_belt) + assert pad.calls == FULL_WAKE + + +def test_wake_and_start_belt_light_wake(pad): + pad.hook = reply(2.5) + run(lambda: app._wake_and_start_belt(2.5, try_light_wake=True)) + assert pad.calls == [("start_belt",), ("ask_stats",), ("change_speed", 25)] + + +def test_wake_and_start_belt_light_wake_falls_back(pad): + run(lambda: app._wake_and_start_belt(2.5, try_light_wake=True)) + probe = [("start_belt",)] + [("ask_stats",)] * app._LIGHT_WAKE_PROBE_ATTEMPTS + assert pad.calls == [*probe, *FULL_WAKE, ("change_speed", 25)] + + +def test_locked_change_speed(pad, disconnects): + run(lambda: app._locked_change_speed(30)) + assert pad.calls == [("change_speed", 30)] + assert disconnects == [] + + +def test_locked_change_speed_failure_disconnects(pad, disconnects): + pad.fail = {"change_speed"} + run(lambda: app._locked_change_speed(30)) + assert disconnects == [None] + + +# ── _handle_disconnect ─────────────────────────────────────────────────── +def test_handle_disconnect_without_loop(app_state): + app.belt_running = True + app._handle_disconnect(None) + assert (app.connected, app.connecting, app.connection_failed, app.belt_running) == (False, False, True, False) + assert app_state == [] + + +def test_handle_disconnect_schedules_one_reconnect(app_state): + app.ble_loop = FakeLoop() + app._handle_disconnect(None) + app._handle_disconnect(None) + assert (app.connected, app.connecting, app.connection_failed) == (False, True, False) + assert [c.__name__ for c in app_state] == ["_auto_reconnect"] + + +def test_handle_disconnect_no_reconnect_while_shutting_down(app_state): + app.ble_loop = FakeLoop() + app._shutting_down = True + app._handle_disconnect(None) + assert (app.connecting, app.connection_failed) == (False, True) + assert app_state == [] + + +def test_handle_disconnect_ignores_stale_client(app_state): + app.controller = FakeController() + app._handle_disconnect(FakeClient()) + assert app.connected is True + app._handle_disconnect(app.controller.client) + assert app.connected is False + + +def test_handle_disconnect_records_auto_pause(app_state, monkeypatch): + pauses = [] + monkeypatch.setattr(app, "_record_pause", pauses.append) + app.session_active = True + app._handle_disconnect(None) + assert pauses == ["auto"] + + +def test_handle_disconnect_cancels_tasks(app_state): + async def body(): + tasks = [asyncio.create_task(asyncio.Event().wait()) for _ in range(4)] + app._stats_monitor_task, app._idle_watchdog_task, app._belt_sequence_task, app._speed_change_task = tasks + await _real_sleep(0) + app._handle_disconnect(None) + await asyncio.gather(*tasks, return_exceptions=True) + assert all(t.cancelled() for t in tasks) + + run(body) + + +# ── reconnect / scan / connect ─────────────────────────────────────────── +def _fake_connect(monkeypatch, results, on_call=None): + attempts = [] + + async def connect(): + attempts.append(1) + if on_call: + on_call() + result = results[len(attempts) - 1] + if isinstance(result, Exception): + raise result + return result + + monkeypatch.setattr(app, "_connect_to_pad", connect) + return attempts + + +def test_auto_reconnect_succeeds_after_failures(sleeps, monkeypatch): + app.connected, app.connecting = False, True + attempts = _fake_connect(monkeypatch, [False, RuntimeError("scan"), True]) + run(app._auto_reconnect) + assert len(attempts) == 3 + assert (app.connected, app.connecting, app.connection_failed) == (True, False, False) + + +def test_auto_reconnect_gives_up(sleeps, monkeypatch): + app.connected, app.connecting = False, True + attempts = _fake_connect(monkeypatch, [False] * app._MAX_RECONNECT_ATTEMPTS) + run(app._auto_reconnect) + assert len(attempts) == app._MAX_RECONNECT_ATTEMPTS + assert (app.connected, app.connecting, app.connection_failed) == (False, False, True) + assert sleeps == [ + min(app._RECONNECT_BASE_DELAY_SECONDS * 2 ** (n - 1), app._RECONNECT_MAX_DELAY_SECONDS) + for n in range(1, app._MAX_RECONNECT_ATTEMPTS) + ] + + +def test_auto_reconnect_exits_when_shutting_down(sleeps, monkeypatch): + app._shutting_down = True + attempts = _fake_connect(monkeypatch, []) + run(app._auto_reconnect) + assert attempts == [] + + +def test_auto_reconnect_stops_after_shutdown_begins(sleeps, monkeypatch): + attempts = _fake_connect(monkeypatch, [False], on_call=lambda: setattr(app, "_shutting_down", True)) + run(app._auto_reconnect) + assert len(attempts) == 1 + assert app.connection_failed is False + + +def _fake_scanner(monkeypatch, devices, fail=False): + class Scanner: + discovered_devices = devices + + async def __aenter__(self): + if fail: + raise RuntimeError("adapter off") + return self + + async def __aexit__(self, *exc): + return False + + monkeypatch.setattr(app, "BleakScanner", Scanner) + + +_NAMED = SimpleNamespace(address="AA", name=f"{app.BLE_DEVICE_NAME} X21") +_KNOWN = SimpleNamespace(address="BB", name=None) +_OTHER = SimpleNamespace(address="CC", name="Headphones") + + +@pytest.mark.parametrize(("devices", "known", "fail", "expected"), [ + ([_NAMED, _KNOWN], "BB", False, _KNOWN), + ([_OTHER, _NAMED], "ZZ", False, _NAMED), + ([_OTHER, _NAMED], None, False, _NAMED), + ([_OTHER, _KNOWN], "ZZ", False, None), + ([_NAMED], None, True, None), +]) +def test_scan_for_device(sleeps, monkeypatch, devices, known, fail, expected): + _fake_scanner(monkeypatch, devices, fail) + app._device_ble_address = known + assert run(lambda: app._scan_for_device(timeout=1)) is expected + + +def _new_controller(monkeypatch): + """_connect_to_pad() finds _NAMED and builds the returned FakeController.""" + new = FakeController() + + async def scan(timeout): + return _NAMED + + async def idle(): + pass + + monkeypatch.setattr(app, "_scan_for_device", scan) + monkeypatch.setattr(app, "Controller", lambda: new) + monkeypatch.setattr(app, "_idle_connection_watchdog", idle) + return new + + +def test_connect_to_pad(sleeps, monkeypatch): + old = FakeController() + new = _new_controller(monkeypatch) + app.controller = old + + assert run(app._connect_to_pad) is True + assert old.client.disconnects == 1 + assert app.controller is new + assert app._device_ble_address == _NAMED.address + assert new.calls == [("run", _NAMED.address), MANUAL] + assert new.client.callback is app._handle_disconnect + assert app._idle_watchdog_task is not None + new.on_cur_status_received(None, {"dist": 0, "steps": 0, "speed": 25}) + assert app.current_speed_kmh == 2.5 + + +def test_connect_to_pad_device_not_found(sleeps, monkeypatch): + scans = [] + + async def scan(timeout): + scans.append(timeout) + + monkeypatch.setattr(app, "_scan_for_device", scan) + app._device_ble_address = "AA" + assert run(app._connect_to_pad) is False + assert len(scans) == 3 + assert app._device_ble_address is None + + +# ── background loops ───────────────────────────────────────────────────── +def test_stats_monitor_polls_until_belt_stops(pad, disconnects): + app.belt_running = True + + def hook(name): + if len(pad.calls) == 3: + app.belt_running = False + + pad.hook = hook + run(app._stats_monitor) + assert pad.calls == [("ask_stats",)] * 3 + assert disconnects == [] + + +def test_stats_monitor_breaks_on_staleness(pad, disconnects, monkeypatch): + clock = [1000.0] + monkeypatch.setattr(app.time, "monotonic", lambda: clock[0]) + app.belt_running = True + + def hook(name): + clock[0] += app._STALE_STATUS_TIMEOUT_SECONDS + 1 + + pad.hook = hook + run(app._stats_monitor) + assert pad.calls == [("ask_stats",)] + assert disconnects == [None] + assert app.belt_running is True + + +def test_idle_watchdog_exits_when_not_connected(pad): + app.connected = False + run(app._idle_connection_watchdog) + assert pad.calls == [] + + +def test_idle_watchdog_detects_dead_client(pad, disconnects): + pad.client.is_connected = False + run(app._idle_connection_watchdog) + assert disconnects == [None] + assert pad.calls == [] + + +def _begin_session(): + app._session_start_time = datetime.now() + app._begin_db_session() + app.session_active = True + return app._session_id + + +def test_graceful_shutdown(pad): + session_id = _begin_session() + app.belt_running = True + run(app._graceful_shutdown) + assert pad.calls == [("stop_belt",), STANDBY] + assert pad.client.disconnects == 1 + assert storage.get_session(session_id)["status"] == "completed" + assert (app.connected, app.session_active, app.belt_running) == (False, False, False) + + +def test_graceful_shutdown_resets_flags_on_error(pad): + pad.fail = {"stop_belt"} + _begin_session() + app.belt_running = True + run(app._graceful_shutdown) + assert pad.calls == [("stop_belt",)] + assert (app.connected, app.session_active, app.belt_running) == (False, False, False) + + +def test_graceful_shutdown_idle(pad, caplog): + run(app._graceful_shutdown) + assert pad.calls == [STANDBY] + assert pad.client.disconnects == 1 + assert storage.list_sessions() == [] + assert storage.list_active_sessions() == [] + assert "Graceful shutdown error" not in caplog.text + + +def test_graceful_shutdown_without_controller_still_saves(sleeps, caplog): + session_id = _begin_session() + app.belt_running = True + run(app._graceful_shutdown) + assert storage.get_session(session_id)["status"] == "completed" + assert (app.connected, app.session_active, app.belt_running) == (False, False, False) + assert "Graceful shutdown error" not in caplog.text + + +def test_graceful_shutdown_controller_without_client(pad, caplog): + pad.client = None + with caplog.at_level(logging.INFO): + run(app._graceful_shutdown) + assert pad.calls == [STANDBY] + assert "Disconnecting BLE client" not in caplog.text + assert "Graceful shutdown error" not in caplog.text + + +def test_end_session_while_paused_sends_no_stop(app_state, pad, disconnects): + session_id = _begin_session() + app.ble_loop = FakeLoop() + app.app.test_client().post("/end_session") + [sequence] = app_state + app_state.clear() + run(lambda: sequence) + assert pad.calls == [] + assert disconnects == [] + assert app._belt_transitioning is False + assert storage.get_session(session_id)["status"] == "completed" + + +# ── belt sequences scheduled by the routes ─────────────────────────────── +_ROUTES = { + "/start": ({}, FULL_WAKE, "start_belt", True), + "/pause": ({"session_active": True, "belt_running": True}, [("stop_belt",)], "stop_belt", False), + "/resume": ( + {"session_active": True, "resume_speed_kmh": 3.0}, + [("start_belt",), ("ask_stats",), ("change_speed", 30)], "change_speed", True, + ), + "/end_session": ( + {"session_active": True, "belt_running": True, "_session_start_time": datetime.now(), "ble_loop": FakeLoop()}, + [("stop_belt",)], "stop_belt", False, + ), +} + + +@pytest.mark.parametrize("fail", [False, True]) +@pytest.mark.parametrize("route", list(_ROUTES)) +def test_route_belt_sequence(app_state, pad, disconnects, monkeypatch, route, fail): + state, expected, failing, restamps = _ROUTES[route] + for name, value in state.items(): + setattr(app, name, value) + + async def monitor(): + pass + + monkeypatch.setattr(app, "_stats_monitor", monitor) + app.app.test_client().post(route) + [sequence] = app_state + app_state.clear() + + transitioning = [] + + def hook(name): + transitioning.append(app._belt_transitioning) + reply(3.0)(name) + + pad.hook = hook + if fail: + pad.fail = {failing} + app._resume_grace_deadline = 0 + before = time.time() + run(lambda: sequence) + + assert transitioning and all(transitioning) + assert app._belt_transitioning is False + assert app._belt_sequence_task is None + if restamps: + assert app._resume_grace_deadline >= before + app.RESUME_GRACE_PERIOD_SECONDS + if fail: + assert disconnects == [None] + assert app.belt_running is False + else: + assert pad.calls == expected + assert disconnects == [] + if restamps: + assert app._stats_monitor_task is not None + + +@pytest.mark.parametrize("route", ["/start", "/pause", "/resume"]) +def test_route_belt_sequence_cancelled_waiting_for_lock(app_state, pad, disconnects, route): + state = _ROUTES[route][0] + for name, value in state.items(): + setattr(app, name, value) + app.app.test_client().post(route) + [sequence] = app_state + app_state.clear() + belt_running = app.belt_running + + async def body(): + await app._ble_command_lock.acquire() # e.g. held by an in-flight stats poll + task = asyncio.create_task(sequence) + await _real_sleep(0) + assert app._belt_transitioning is True + task.cancel() + await task + assert not task.cancelled() # the sequence swallows its own cancellation + + run(body) + assert app._belt_transitioning is False + assert app._belt_sequence_task is None + assert app._stats_monitor_task is None + assert pad.calls == [] + assert disconnects == [] + assert app.belt_running is belt_running + + +# ── more connect paths ─────────────────────────────────────────────────── +def _boom(*args): + raise RuntimeError("boom") + + +@pytest.mark.parametrize(("api", "fails"), [ + ("set_disconn_callback", False), + ("set_disconnected_callback", False), + ("set_disconn_callback", True), + ("set_disconnected_callback", True), + (None, False), +]) +def test_connect_to_pad_disconnect_callback_api(sleeps, monkeypatch, api, fails): + new = _new_controller(monkeypatch) + registered = [] + new.client = SimpleNamespace(is_connected=True) + if api: + setattr(new.client, api, _boom if fails else registered.append) + assert run(app._connect_to_pad) is True + assert registered == ([app._handle_disconnect] if api and not fails else []) + assert new.calls == [("run", _NAMED.address), MANUAL] + + +def test_connect_to_pad_without_client_skips_callback(sleeps, monkeypatch, caplog): + new = _new_controller(monkeypatch) + new.client = None + with caplog.at_level(logging.DEBUG): + assert run(app._connect_to_pad) is True + assert new.calls == [("run", _NAMED.address), MANUAL] + assert "Disconnect callback not available" not in caplog.text + + +@pytest.mark.parametrize("fails", [False, True]) +def test_connect_to_pad_enables_notifications(sleeps, monkeypatch, fails): + new = _new_controller(monkeypatch) + + async def enable_notifications(): + await new._call("enable_notifications") + if fails: + raise RuntimeError("notify unsupported") + + new.enable_notifications = enable_notifications + assert run(app._connect_to_pad) is True + assert new.calls[-1] == ("enable_notifications",) + + +def test_connect_to_pad_status_handler_swallows_errors(sleeps, monkeypatch, caplog): + new = _new_controller(monkeypatch) + run(app._connect_to_pad) + monkeypatch.setattr(app, "process_status_packet", _boom) + new.on_cur_status_received(None, {"dist": 0, "steps": 0, "speed": 25}) + assert "_handle_status_update error: boom" in caplog.text + + +def test_try_light_wake_keeps_probing_after_ask_stats_errors(pad): + pad.fail = {"ask_stats"} + assert run(app._try_light_wake) is False + assert pad.calls.count(("ask_stats",)) == app._LIGHT_WAKE_PROBE_ATTEMPTS + + +def test_auto_reconnect_stops_when_shutdown_begins_during_backoff(app_state, monkeypatch): + on_sleep(monkeypatch, lambda n: setattr(app, "_shutting_down", True)) + app.connected, app.connecting = False, True + attempts = _fake_connect(monkeypatch, [False, True]) + run(app._auto_reconnect) + assert len(attempts) == 1 + assert (app.connected, app.connection_failed) == (False, False) + + +def test_handle_disconnect_reconnect_scheduling_fails(app_state, monkeypatch): + def fail(coro, loop): + coro.close() + raise RuntimeError("loop closed") + + monkeypatch.setattr(app.asyncio, "run_coroutine_threadsafe", fail) + app.ble_loop = FakeLoop() + app._handle_disconnect(None) + assert (app.connected, app.connecting, app.connection_failed) == (False, False, True) + + +def test_handle_disconnect_cancels_tasks_via_live_loop(app_state): + loop = FakeLoop() + + async def body(): + tasks = [asyncio.create_task(asyncio.Event().wait()) for _ in range(2)] + app._stats_monitor_task, app._belt_sequence_task = tasks + await _real_sleep(0) + app.ble_loop = loop + app._handle_disconnect(None) + await asyncio.gather(*tasks, return_exceptions=True) + assert loop.scheduled == [t.cancel for t in tasks] + assert all(t.cancelled() for t in tasks) + + run(body) + + +# ── more background-loop branches ──────────────────────────────────────── +def test_idle_watchdog_defers_to_stats_monitor_while_belt_running(pad, disconnects, monkeypatch): + on_sleep(monkeypatch, disconnect_on_sleep(3)) + app.belt_running = True + pad.client.is_connected = False # would trigger a disconnect if checked + run(app._idle_connection_watchdog) + assert pad.calls == [] + assert disconnects == [] + + +def test_idle_watchdog_pings_when_is_connected_raises(pad, disconnects, monkeypatch): + class BrokenClient: + @property + def is_connected(self): + raise RuntimeError("backend gone") + + on_sleep(monkeypatch, disconnect_on_sleep(2)) + pad.client = BrokenClient() + app._last_status_update_monotonic = time.monotonic() + run(app._idle_connection_watchdog) + assert pad.calls == [("ask_stats",)] + assert disconnects == [] + + +def test_idle_watchdog_breaks_when_stale(pad, disconnects, monkeypatch): + monkeypatch.setattr(app.time, "monotonic", lambda: 1000.0) + app._last_status_update_monotonic = 1000.0 - app._IDLE_STALE_STATUS_TIMEOUT_SECONDS - 1 + run(app._idle_connection_watchdog) # connected stays True: only the break can end it + assert disconnects == [None] + assert pad.calls == [] + + +def test_idle_watchdog_retries_after_ping_error(pad, disconnects, monkeypatch): + on_sleep(monkeypatch, disconnect_on_sleep(3)) + pad.fail = {"ask_stats"} + app._last_status_update_monotonic = time.monotonic() + run(app._idle_connection_watchdog) + assert pad.calls == [("ask_stats",)] * 2 + assert disconnects == [] + + +def test_stats_monitor_processes_poll_reply(pad, disconnects, monkeypatch): + packets = [] + monkeypatch.setattr(app, "process_status_packet", lambda *a: packets.append(a)) + app.belt_running = True + + async def ask_stats(): + app.belt_running = False + return {"dist": 5, "steps": 7, "speed": 25} + + pad.ask_stats = ask_stats + run(app._stats_monitor) + assert packets == [(5, 7, 25)] + + +@pytest.mark.parametrize(("exc", "logged"), [ + (asyncio.TimeoutError(), "Status poll timed out"), + (RuntimeError("gatt"), "ask_stats error: gatt"), +]) +def test_stats_monitor_survives_poll_errors(pad, disconnects, monkeypatch, caplog, exc, logged): + app.belt_running = True + polls = [] + + async def ask_stats(): + polls.append(1) + if len(polls) == 3: + app.belt_running = False + raise exc + + pad.ask_stats = ask_stats + run(app._stats_monitor) + assert len(polls) == 3 + assert caplog.text.count(logged) == 3 + assert disconnects == [] + + +def test_stats_monitor_saves_state_periodically(pad, disconnects, monkeypatch): + saves = [] + monkeypatch.setattr(app, "_save_session_state", lambda: saves.append(len(pad.calls))) + app.belt_running = True + ticks = 2 * app._SESSION_STATE_SAVE_INTERVAL_SECONDS + 1 + + def hook(name): + if len(pad.calls) == ticks: + app.belt_running = False + + pad.hook = hook + run(app._stats_monitor) + n = app._SESSION_STATE_SAVE_INTERVAL_SECONDS + assert saves == [n, 2 * n] + + +def test_stats_monitor_cancelled_during_sleep_exits_cleanly(pad, monkeypatch): + app.belt_running = True + + async def body(): + parked = asyncio.Event() + + async def park(delay, result=None): + parked.set() + await asyncio.get_running_loop().create_future() + + monkeypatch.setattr(app.asyncio, "sleep", park) + task = asyncio.create_task(app._stats_monitor()) + await parked.wait() + task.cancel() + await task + assert not task.cancelled() + + run(body) + assert pad.calls == [("ask_stats",)] + + +def test_stats_monitor_logs_unexpected_error(pad, monkeypatch, caplog): + monkeypatch.setattr(app, "_check_staleness_and_disconnect", _boom) + app.belt_running = True + run(app._stats_monitor) + assert "Stats monitor error: boom" in caplog.text + assert pad.calls == [("ask_stats",)] + + +# ── BLE thread lifecycle ───────────────────────────────────────────────── +@pytest.fixture +def ble_thread(app_state, monkeypatch): + """Runs _ble_thread() inline with _connect_to_pad replaced by `connect`.""" + def start(connect): + monkeypatch.setattr(app, "_connect_to_pad", connect) + app.connected, app.connecting = False, True + app._ble_thread() + yield start + asyncio.set_event_loop(None) + + +async def _refuse(): + return False + + +async def _crash(): + raise RuntimeError("adapter off") + + +@pytest.mark.parametrize("connect", [_refuse, _crash]) +def test_ble_thread_connect_failure(ble_thread, connect): + ble_thread(connect) + assert (app.connected, app.connecting, app.connection_failed) == (False, False, True) + assert app.ble_loop.is_closed() + + +def test_ble_thread_event_loop_creation_fails(ble_thread, monkeypatch): + monkeypatch.setattr(app.asyncio, "new_event_loop", _boom) + ble_thread(_crash) + assert (app.connecting, app.connection_failed) == (False, True) + assert app.ble_loop is None + + +def _connect_then(during_run): + """A successful connect that leaves a pending task and runs during_run(loop) once run_forever starts.""" + leftovers = [] + + async def connect(): + loop = asyncio.get_running_loop() + leftovers.append(loop.create_task(asyncio.Event().wait())) + # Nested so it lands after run_until_complete() returns, inside run_forever(). + loop.call_soon(loop.call_soon, during_run, loop) + return True + + return connect, leftovers + + +def test_ble_thread_runs_until_stopped(ble_thread): + seen = [] + + def during_run(loop): + seen.append((app.connected, app.connecting, app.ble_loop is loop, app._ble_command_lock)) + loop.stop() + + connect, leftovers = _connect_then(during_run) + ble_thread(connect) + [(connected, connecting, current, lock)] = seen + assert (connected, connecting, current) == (True, False, True) + assert isinstance(lock, asyncio.Lock) and not lock.locked() + assert app.connected is False + assert app.ble_loop.is_closed() + assert leftovers[0].cancelled() + + +def test_ble_thread_superseded_cleanup_keeps_newer_state(ble_thread): + newer = FakeLoop() + + def during_run(loop): + app.ble_loop = newer + loop.stop() + + connect, _ = _connect_then(during_run) + ble_thread(connect) + assert app.connected is True + assert app.ble_loop is newer + + +@pytest.fixture +def threads(app_state, monkeypatch): + started = [] + + class Thread: + def __init__(self, target, daemon): + self.target, self.daemon = target, daemon + + def start(self): + started.append(self) + + monkeypatch.setattr(app.threading, "Thread", Thread) + return started + + +@pytest.mark.parametrize(("connected", "connecting"), [(True, False), (False, True)]) +def test_start_ble_thread_guard(threads, connected, connecting): + app.connected, app.connecting, app.connection_failed = connected, connecting, True + app._start_ble_thread() + assert threads == [] + assert (app.connecting, app.connection_failed) == (connecting, True) + + +@pytest.mark.parametrize("running", [True, False]) +def test_start_ble_thread(threads, running): + old = FakeLoop(running=running) + app.ble_loop = old + app.connected, app.connection_failed = False, True + app._start_ble_thread() + assert (app.connecting, app.connection_failed) == (True, False) + assert old.scheduled == ([old.stop] if running else []) + [thread] = threads + assert (thread.target, thread.daemon) == (app._ble_thread, True) + + +# ── signal shutdown ────────────────────────────────────────────────────── +class _Exit(Exception): + pass + + +@pytest.fixture +def signal_exit(app_state, monkeypatch): + """Patches sleep/_exit (which raises _Exit) and makes scheduled coroutines resolve to `result`.""" + calls = [] + outcome = {"result": None} + + def schedule(coro, loop): + app_state.append(coro) + future = concurrent.futures.Future() + if isinstance(outcome["result"], Exception): + future.set_exception(outcome["result"]) + else: + future.set_result(outcome["result"]) + return future + + def exit_(code): + calls.append(("exit", code)) + raise _Exit + + monkeypatch.setattr(app.asyncio, "run_coroutine_threadsafe", schedule) + monkeypatch.setattr(app.time, "sleep", lambda s: calls.append(("sleep", s))) + monkeypatch.setattr(app.os, "_exit", exit_) + return calls, outcome + + +def test_signal_shutdown_ignores_duplicate(signal_exit): + calls, _ = signal_exit + app._shutting_down = True + app._handle_signal_shutdown(15, None) + assert calls == [] + assert app._server_stopping is False + + +def test_signal_shutdown_without_loop(signal_exit, app_state): + calls, _ = signal_exit + with pytest.raises(_Exit): + app._handle_signal_shutdown(15, None) + assert calls == [("sleep", 2), ("exit", 0)] + assert (app._shutting_down, app._server_stopping) == (True, True) + assert app_state == [] + + +@pytest.mark.parametrize("fails", [False, True]) +def test_signal_shutdown_with_loop(signal_exit, app_state, fails): + calls, outcome = signal_exit + loop = FakeLoop() + if fails: + outcome["result"] = RuntimeError("stuck") + loop.call_soon_threadsafe = _boom + app.ble_loop = loop + with pytest.raises(_Exit): + app._handle_signal_shutdown(15, None) + assert [c.__name__ for c in app_state] == ["_graceful_shutdown"] + assert loop.running is fails + assert calls == [("sleep", 2), ("exit", 0)] + assert app._server_stopping is True diff --git a/tests/test_migration.py b/tests/test_migration.py index 5f76fe3..d4b1ab5 100644 --- a/tests/test_migration.py +++ b/tests/test_migration.py @@ -118,3 +118,44 @@ def test_corrupt_file_left_untouched(db): def test_missing_file_is_noop(db): storage.migrate_json(str(db / "nope.json")) assert storage.list_sessions() == [] + + +def test_non_list_top_level_left_untouched(db, caplog): + json_path = _write(db / "session_history.json", {"sessions": [NORMAL]}) + + storage.migrate_json(json_path) + + assert "top level is not a list" in caplog.text + assert json.loads((db / "session_history.json").read_text()) == {"sessions": [NORMAL]} + assert not list(db.glob("session_history.json.*")) + assert storage.list_sessions() == [] + + +@pytest.mark.parametrize("end_time", [None, "25:99:00"]) +def test_missing_or_bad_end_time_falls_back_to_moving_time(db, end_time): + record = {k: v for k, v in NORMAL.items() if k != "end_time"} + if end_time is not None: + record["end_time"] = end_time + + storage.migrate_json(_write(db / "session_history.json", [record])) + + s = storage.list_sessions()[0] + assert s["end_time"] is None + assert s["elapsed_s"] == s["moving_s"] == 317 + + +def test_rename_failure_still_marks_migrated(db, monkeypatch, caplog): + json_path = _write(db / "session_history.json", [NORMAL]) + + def refuse(src, dst): + raise OSError("read-only") + + monkeypatch.setattr(storage.os, "replace", refuse) + storage.migrate_json(json_path) + monkeypatch.undo() + + assert "could not rename it: read-only" in caplog.text + assert (db / "session_history.json").exists() + assert len(storage.list_sessions()) == 1 + storage.migrate_json(json_path) + assert len(storage.list_sessions()) == 1 diff --git a/tests/test_port_check.py b/tests/test_port_check.py index c93ba67..0518254 100644 --- a/tests/test_port_check.py +++ b/tests/test_port_check.py @@ -1,3 +1,5 @@ +import errno +import os import socket import pytest @@ -89,3 +91,18 @@ def test_rejects_invalid_port(monkeypatch, port): _config(monkeypatch, "127.0.0.1", port) with pytest.raises(SystemExit, match="1 to 65535"): run.check_port() + + +@pytest.mark.parametrize(("err", "hint"), [ + (errno.EACCES, "doesn't allow this port"), + (errno.EADDRNOTAVAIL, 'Check "host" and "port"'), +]) +def test_other_bind_errors_get_their_own_hint(monkeypatch, err, hint): + _config(monkeypatch, "127.0.0.1", _free_port()) + + def refuse(*a, **k): + raise OSError(err, os.strerror(err)) + + monkeypatch.setattr(run.socket, "create_server", refuse) + with pytest.raises(SystemExit, match=hint): + run.check_port() diff --git a/tests/test_routes.py b/tests/test_routes.py new file mode 100644 index 0000000..88549d7 --- /dev/null +++ b/tests/test_routes.py @@ -0,0 +1,554 @@ +import json +import os +import sqlite3 +from datetime import datetime, timedelta +from types import SimpleNamespace + +import pytest + +import app +import config +import storage + + +@pytest.fixture +def client(app_state): + return app.app.test_client() + + +@pytest.fixture +def no_loop(monkeypatch, app_state): + def fail(coro, loop): + coro.close() + raise RuntimeError("no BLE loop") + + monkeypatch.setattr(app.asyncio, "run_coroutine_threadsafe", fail) + + +@pytest.fixture +def running(monkeypatch, app_state): + monkeypatch.setattr(app, "session_active", True) + monkeypatch.setattr(app, "belt_running", True) + + +@pytest.fixture +def paused(monkeypatch, app_state): + monkeypatch.setattr(app, "session_active", True) + monkeypatch.setattr(app, "belt_running", False) + + +def names(scheduled): + return [c.__name__ for c in scheduled] + + +def redirects_root(resp): + return resp.status_code == 302 and resp.headers["Location"].endswith("/") + + +def raw_pauses(session_id): + conn = sqlite3.connect(storage._db_path) + try: + return conn.execute("SELECT reason, end_time FROM pauses WHERE session_id = ?", (session_id,)).fetchall() + finally: + conn.close() + + +def completed_session(start, steps=100): + sid = storage.create_session(start.astimezone().isoformat(timespec="seconds"), "pad", None) + storage.complete_session(sid, { + "end_time": (start + timedelta(minutes=10)).astimezone().isoformat(timespec="seconds"), + "elapsed_s": 600, "moving_s": 600, "distance_m": 1000, "steps": steps, + "calories_kcal": 50, "avg_speed_mps": 1.0, + }) + return sid + + +def pending_state(session_id=None, steps=1234): + return { + "session_active": True, + "session_id": session_id, + "session_start_time": (datetime.now() - timedelta(minutes=5)).isoformat(), + "current_distance_km": 1.5, + "current_steps": steps, + "current_calories": 80.0, + "current_session_active_seconds": 300, + "resume_speed_kmh": 3.5, + "last_dev_dist": 150, + "last_dev_steps": steps, + } + + +# ── GET / ── + +def test_root_disconnected(client, monkeypatch): + monkeypatch.setattr(app, "connected", False) + resp = client.get("/") + assert resp.status_code == 200 + assert b"DISCONNECTED" in resp.data + + +def test_root_idle(client): + assert b"Start New Session" in client.get("/").data + + +def test_root_active(client, running): + assert b"Active Session" in client.get("/").data + + +def test_root_paused(client, paused): + assert b"Session Paused" in client.get("/").data + + +def test_root_pending_restore(client, monkeypatch): + monkeypatch.setattr(app, "_pending_restore", pending_state()) + data = client.get("/").data + assert b"was interrupted" in data + assert b"1,234 steps" in data + + +def test_start_not_connected(client, app_state, monkeypatch): + monkeypatch.setattr(app, "connected", False) + assert redirects_root(client.post("/start")) + assert not app.session_active + assert not storage.list_active_sessions() + assert app_state == [] + + +def test_start(client, app_state, monkeypatch): + monkeypatch.setattr(app, "current_steps", 99) + monkeypatch.setattr(app, "current_distance_km", 3.0) + monkeypatch.setattr(app, "_pending_restore", pending_state()) + app.speed_history.append(4.0) + assert redirects_root(client.post("/start")) + assert app.session_active and app.belt_running + assert (app.current_steps, app.current_distance_km, list(app.speed_history)) == (0, 0.0, []) + assert app._pending_restore is None + assert [s["id"] for s in storage.list_active_sessions()] == [app._session_id] + assert os.path.exists(app.SESSION_STATE_FILE) + assert names(app_state) == ["_start_belt_sequence"] + + +def test_start_already_active(client, app_state, running): + assert redirects_root(client.post("/start")) + assert app_state == [] + assert not storage.list_active_sessions() + + +def test_start_schedule_failure(client, no_loop): + assert redirects_root(client.post("/start")) + assert app.session_active + assert not app.belt_running + + +# ── /pause, /pause_session ── + +@pytest.mark.parametrize("path", ["/pause", "/pause_session"]) +def test_pause(client, app_state, path): + client.post("/start") + app.speed_history.extend([2.5, 4.2]) + assert redirects_root(client.post(path)) + assert not app.belt_running + assert app.resume_speed_kmh == 4.2 + assert raw_pauses(app._session_id) == [("manual", None)] + with open(app.SESSION_STATE_FILE) as f: + assert json.load(f)["resume_speed_kmh"] == 4.2 + assert names(app_state) == ["_start_belt_sequence", "_pause_belt_sequence"] + + +def test_pause_not_running(client, app_state, paused): + assert redirects_root(client.post("/pause")) + assert app_state == [] + + +def test_pause_schedule_failure(client, running, no_loop): + assert redirects_root(client.post("/pause")) + assert not app.belt_running + + +# ── /resume, /resume_session ── + +@pytest.mark.parametrize("path", ["/resume", "/resume_session"]) +def test_resume(client, app_state, path): + client.post("/start") + client.post("/pause") + assert redirects_root(client.post(path)) + assert app.belt_running + assert raw_pauses(app._session_id)[0][1] is not None + assert names(app_state)[-1] == "_resume_belt_sequence" + + +def test_resume_no_session(client, app_state): + assert redirects_root(client.post("/resume")) + assert not app.belt_running + assert app_state == [] + + +def test_resume_already_running(client, app_state, running): + assert redirects_root(client.post("/resume")) + assert app_state == [] + + +def test_resume_schedule_failure(client, paused, no_loop): + assert redirects_root(client.post("/resume")) + assert not app.belt_running + + +# ── speed ── + +@pytest.fixture +def speeds(monkeypatch): + monkeypatch.setattr(app, "MIN_SPEED_KMH", 1.0) + monkeypatch.setattr(app, "MAX_SPEED_KMH", 6.0) + monkeypatch.setattr(app, "SPEED_STEP", 0.5) + monkeypatch.setattr(app, "SLOW_WALK_SPEED_KMH", 2.0) + + +@pytest.mark.parametrize(("path", "current", "expected"), [ + ("/increase_speed", 3.0, 35), + ("/increase_speed", 6.0, 60), + ("/decrease_speed", 3.0, 25), + ("/decrease_speed", 1.0, 10), + ("/min_speed", 3.0, 10), + ("/slow_speed", 3.0, 20), + ("/max_speed", 3.0, 60), +]) +def test_speed(client, app_state, running, speeds, monkeypatch, path, current, expected): + monkeypatch.setattr(app, "current_speed_kmh", current) + assert redirects_root(client.post(path)) + assert names(app_state) == ["_locked_change_speed"] + assert app_state[0].cr_frame.f_locals["dev_speed"] == expected + + +@pytest.mark.parametrize("path", ["/increase_speed", "/decrease_speed", "/min_speed", "/slow_speed", "/max_speed"]) +def test_speed_not_running(client, app_state, paused, path): + assert redirects_root(client.post(path)) + assert app_state == [] + + +def test_speed_schedule_failure(client, running, no_loop): + assert redirects_root(client.post("/max_speed")) + + +# ── /end_session ── + +def test_end_session(client, app_state, monkeypatch): + client.post("/start") + monkeypatch.setattr(app, "current_steps", 500) + monkeypatch.setattr(app, "current_distance_km", 0.4) + monkeypatch.setattr(app, "current_session_active_seconds", 300) + assert redirects_root(client.post("/end_session")) + history = storage.list_sessions() + assert [(s["status"], s["steps"]) for s in history] == [("completed", 500)] + assert not app.session_active and not app.belt_running + assert (app.current_steps, app.current_distance_km, app.current_session_active_seconds) == (0, 0.0, 0) + assert app._session_id is None + assert not os.path.exists(app.SESSION_STATE_FILE) + + +def test_end_session_stale_while_running_is_noop(app_state, running): + app._end_session(stale=True) + assert app.session_active and app.belt_running + assert app_state == [] + assert storage.list_sessions() == [] + + +# ── /restore_session, /discard_session ── + +def test_restore_session(client, monkeypatch): + sid = storage.create_session(datetime.now().astimezone().isoformat(timespec="seconds"), "pad", None) + monkeypatch.setattr(app, "_pending_restore", pending_state(sid)) + assert redirects_root(client.post("/restore_session")) + assert app.session_active and not app.belt_running + assert app._pending_restore is None + assert app._session_id == sid + assert (app.current_steps, app.current_distance_km, app.resume_speed_kmh) == (1234, 1.5, 3.5) + assert (app._last_dev_dist, app._last_dev_steps) == (150, 1234) + assert raw_pauses(sid) == [("shutdown", None)] + assert os.path.exists(app.SESSION_STATE_FILE) + + +def test_restore_session_not_connected(client, monkeypatch): + monkeypatch.setattr(app, "connected", False) + monkeypatch.setattr(app, "_pending_restore", pending_state()) + assert redirects_root(client.post("/restore_session")) + assert not app.session_active + assert app._pending_restore is not None + + +def test_restore_session_already_active(client, running, monkeypatch): + monkeypatch.setattr(app, "_pending_restore", pending_state()) + assert redirects_root(client.post("/restore_session")) + assert app._pending_restore is not None + assert app.current_steps == 0 + + +def test_restore_session_nothing_pending(client): + assert redirects_root(client.post("/restore_session")) + assert not app.session_active + + +def test_discard_session(client, monkeypatch): + sid = storage.create_session(datetime.now().astimezone().isoformat(timespec="seconds"), "pad", None) + monkeypatch.setattr(app, "_pending_restore", pending_state(sid)) + with open(app.SESSION_STATE_FILE, "w") as f: + json.dump(pending_state(sid), f) + assert redirects_root(client.post("/discard_session")) + assert app._pending_restore is None + assert storage.get_session(sid) is None + assert not os.path.exists(app.SESSION_STATE_FILE) + + +def test_discard_session_delete_fails(client, monkeypatch): + def boom(_): + raise RuntimeError + + monkeypatch.setattr(storage, "delete_session", boom) + monkeypatch.setattr(app, "_pending_restore", pending_state("sid")) + with open(app.SESSION_STATE_FILE, "w") as f: + f.write("{}") + assert redirects_root(client.post("/discard_session")) + assert app._pending_restore is None + assert not os.path.exists(app.SESSION_STATE_FILE) + + +def test_discard_session_with_nothing_pending(client, monkeypatch): + deleted = [] + monkeypatch.setattr(storage, "delete_session", deleted.append) + assert redirects_root(client.post("/discard_session")) + assert deleted == [] + + +def test_discard_session_while_active(client, running, monkeypatch): + monkeypatch.setattr(app, "_pending_restore", pending_state()) + with open(app.SESSION_STATE_FILE, "w") as f: + f.write("{}") + assert redirects_root(client.post("/discard_session")) + assert app._pending_restore is not None + assert os.path.exists(app.SESSION_STATE_FILE) + + +# ── history / export ── + +def test_export_csv(client, monkeypatch): + client.post("/start") + monkeypatch.setattr(app, "current_steps", 777) + client.post("/end_session") + resp = client.get("/export_csv") + assert resp.status_code == 200 + assert resp.headers["Content-Type"].startswith("text/csv") + assert resp.headers["Content-Disposition"] == "attachment; filename=walkingdad_history.csv" + header, row = resp.data.decode().splitlines() + assert header.startswith("date,start_time,end_time,duration_seconds") + assert row.split(",")[6] == "777" + + +def test_clear_history(client): + completed_session(datetime.now()) + resp = client.post("/clear_history") + assert resp.get_json() == {"status": "cleared"} + assert storage.list_sessions() == [] + + +def test_dismiss_health_export(client): + sid = completed_session(datetime.now()) + resp = client.post("/dismiss_health_export") + assert resp.get_json() == {"status": "dismissed"} + assert storage.get_session(sid)["health_logged"] + + +# ── /settings ── + +def test_settings_get(client): + resp = client.get("/settings") + assert resp.status_code == 200 + assert b"Settings" in resp.data + + +def test_settings_post(client, monkeypatch): + monkeypatch.setattr(app, "APPLE_HEALTH_EXPORT_ENABLED", True) + new_max = config.MAX_SPEED_KMH - 0.5 + resp = client.post("/settings", data={ + "max_speed_kmh": str(new_max), + "kcal_per_mile": "not-a-number", + "port": str(config.PORT + 1), + }) + assert resp.status_code == 302 and resp.headers["Location"].endswith("/?saved=1") + with open(app._CONFIG_FILE) as f: + written = json.load(f) + assert written["max_speed_kmh"] == new_max + assert written["apple_health_export_enabled"] is False + assert config.MAX_SPEED_KMH == app.MAX_SPEED_KMH == new_max + assert written["kcal_per_mile"] == config.KCAL_PER_MILE == app.KCAL_PER_MILE + assert config.PORT == written["port"] + assert config.APPLE_HEALTH_EXPORT_ENABLED is False + assert app.APPLE_HEALTH_EXPORT_ENABLED is False + + +@pytest.mark.parametrize(("days_ago", "dismissed"), [(0, False), (2, True)]) +def test_settings_enable_health_export_predismiss(client, monkeypatch, days_ago, dismissed): + monkeypatch.setattr(app, "APPLE_HEALTH_EXPORT_ENABLED", False) + sid = completed_session(datetime.now() - timedelta(days=days_ago)) + client.post("/settings", data={"apple_health_export_enabled": "on"}) + assert app.APPLE_HEALTH_EXPORT_ENABLED is True + assert bool(storage.get_session(sid)["health_logged"]) is dismissed + + +def test_settings_already_enabled_no_predismiss(client, monkeypatch): + monkeypatch.setattr(app, "APPLE_HEALTH_EXPORT_ENABLED", True) + sid = completed_session(datetime.now() - timedelta(days=2)) + client.post("/settings", data={"apple_health_export_enabled": "on"}) + assert not storage.get_session(sid)["health_logged"] + + +# ── /reconnect ── + +@pytest.mark.parametrize(("connected", "connecting", "called"), [ + (False, False, True), (True, False, False), (False, True, False), +]) +def test_reconnect(client, monkeypatch, connected, connecting, called): + calls = [] + monkeypatch.setattr(app, "_start_ble_thread", lambda: calls.append(1)) + monkeypatch.setattr(app, "connected", connected) + monkeypatch.setattr(app, "connecting", connecting) + assert redirects_root(client.get("/reconnect")) + assert bool(calls) is called + + +# ── /stats, /stats_stream ── + +def test_stats(client, running, monkeypatch): + monkeypatch.setattr(app, "current_steps", 42) + resp = client.get("/stats") + assert resp.headers["Cache-Control"] == "no-store" + body = resp.get_json() + assert body["is_connected"] and body["session_active"] and body["is_running"] + assert body["steps"] == 42 + + +def test_stats_stream(client): + resp = client.get("/stats_stream", buffered=False) + assert resp.mimetype == "text/event-stream" + first = next(iter(resp.response)) + first = first.decode() if isinstance(first, bytes) else first + assert first.startswith("data: ") + assert json.loads(first[len("data: "):])["is_connected"] is True + assert len(app._sse_subscribers) == 1 + resp.close() + assert app._sse_subscribers == [] + + +def test_stats_stream_close_after_subscriber_already_removed(client): + resp = client.get("/stats_stream", buffered=False) + next(iter(resp.response)) + app._sse_subscribers.clear() + resp.close() + assert app._sse_subscribers == [] + + +def test_stats_stream_keepalive_and_frames(client, monkeypatch): + monkeypatch.setattr(app, "_SSE_KEEPALIVE_TIMEOUT_SECONDS", 0.01) + resp = client.get("/stats_stream", buffered=False) + chunks = (c.decode() if isinstance(c, bytes) else c for c in resp.response) + next(chunks) + assert next(chunks) == ": keepalive\n\n" + app._sse_subscribers[0].put("data: {}\n\n") + assert next(chunks) == "data: {}\n\n" + resp.close() + + +# ── /shutdown ── + +def test_shutdown(client, monkeypatch): + threads, exits = [], [] + + class FakeThread: + def __init__(self, target, daemon): + threads.append(target) + + def start(self): + pass + + monkeypatch.setattr(app.threading, "Thread", FakeThread) + monkeypatch.setattr(app.os, "_exit", exits.append) + resp = client.post("/shutdown") + assert resp.get_json() == {"status": "shutting_down"} + assert app._shutting_down and app._server_stopping + assert len(threads) == 1 + assert client.post("/shutdown").get_json() == {"status": "shutting_down"} + assert len(threads) == 1 + assert exits == [] + + +class _BleLoop: + def __init__(self): + self.calls = [] + + def is_closed(self): + return False + + def is_running(self): + return True + + def stop(self): + pass + + def call_soon_threadsafe(self, fn): + self.calls.append(fn) + + +@pytest.mark.parametrize("fails", [False, True]) +def test_shutdown_stops_ble_loop(client, monkeypatch, fails): + loop, scheduled, timeouts = _BleLoop(), [], [] + + class Future: + def result(self, timeout): + timeouts.append(timeout) + if fails: + raise TimeoutError + + def schedule(coro, ble_loop): + assert ble_loop is loop + scheduled.append(coro.__name__) + coro.close() + return Future() + + class FakeThread: + def __init__(self, target, daemon): + pass + + def start(self): + pass + + monkeypatch.setattr(app.asyncio, "run_coroutine_threadsafe", schedule) + monkeypatch.setattr(app.threading, "Thread", FakeThread) + monkeypatch.setattr(app, "ble_loop", loop) + assert client.post("/shutdown").get_json() == {"status": "shutting_down"} + assert scheduled == ["_graceful_shutdown"] + assert timeouts == [10] + assert loop.calls == [loop.stop] + + +class _IdleLoop(_BleLoop): + def is_running(self): + return False + + +class _StuckLoop(_BleLoop): + def call_soon_threadsafe(self, fn): + self.calls.append(fn) + raise RuntimeError("loop closed") + + +@pytest.mark.parametrize(("loop_cls", "stop_requested"), [(_IdleLoop, False), (_StuckLoop, True)]) +def test_shutdown_loop_stop_edge_cases(client, monkeypatch, loop_cls, stop_requested): + loop = loop_cls() + + def schedule(coro, ble_loop): + coro.close() + return SimpleNamespace(result=lambda timeout: None) + + monkeypatch.setattr(app.asyncio, "run_coroutine_threadsafe", schedule) + monkeypatch.setattr(app.threading, "Thread", lambda target, daemon: SimpleNamespace(start=lambda: None)) + monkeypatch.setattr(app, "ble_loop", loop) + assert client.post("/shutdown").get_json() == {"status": "shutting_down"} + assert loop.calls == ([loop.stop] if stop_requested else []) diff --git a/tests/test_run.py b/tests/test_run.py new file mode 100644 index 0000000..7ccd355 --- /dev/null +++ b/tests/test_run.py @@ -0,0 +1,91 @@ +import os +import subprocess + +import pytest + +import run + + +def test_open_browser(monkeypatch): + opened = [] + monkeypatch.setattr(run.webbrowser, "open_new", opened.append) + run.open_browser() + assert opened == [f"http://127.0.0.1:{run.PORT}"] + + +@pytest.mark.parametrize("fails", [False, True]) +def test_http_shutdown(monkeypatch, fails): + requests, sleeps = [], [] + + def urlopen(req, timeout): + requests.append((req.get_method(), req.full_url, timeout)) + if fails: + raise ConnectionResetError + + monkeypatch.setattr(run.urllib.request, "urlopen", urlopen) + monkeypatch.setattr(run.time, "sleep", sleeps.append) + run.http_shutdown() + assert requests == [("POST", run.SHUTDOWN_URL, 15)] + assert sleeps == [6] + + +class _Process: + def __init__(self, waits): + self.waits = list(waits) + self.events = [] + + def wait(self, timeout=None): + self.events.append(("wait", timeout)) + exc = self.waits.pop(0) if self.waits else None + if exc: + raise exc + + def terminate(self): + self.events.append("terminate") + + def kill(self): + self.events.append("kill") + + +@pytest.fixture +def launch(monkeypatch): + calls = [] + + def go(*waits): + proc = _Process(waits) + + def popen(argv, **kwargs): + calls.append(("popen", argv, kwargs)) + return proc + + monkeypatch.setattr(run, "check_port", lambda: calls.append("check_port")) + monkeypatch.setattr(run.subprocess, "Popen", popen) + monkeypatch.setattr(run.time, "sleep", lambda _: None) + monkeypatch.setattr(run, "open_browser", lambda: calls.append("open_browser")) + monkeypatch.setattr(run, "http_shutdown", lambda: proc.events.append("http_shutdown")) + run.main() + return calls, proc.events + + return go + + +def test_main_normal_exit(launch): + calls, events = launch() + assert calls[0] == "check_port" + _, argv, kwargs = calls[1] + assert argv == [ + "waitress-serve", f"--host={run.HOST}", f"--port={run.PORT}", f"--threads={run.WAITRESS_THREADS}", "app:app", + ] + assert kwargs == {"preexec_fn": os.setsid} + assert calls[2] == "open_browser" + assert events == [("wait", None)] + + +def test_main_ctrl_c_shuts_down_then_terminates(launch): + _, events = launch(KeyboardInterrupt) + assert events == [("wait", None), "http_shutdown", "terminate", ("wait", 10)] + + +def test_main_ctrl_c_kills_when_terminate_times_out(launch): + _, events = launch(KeyboardInterrupt, subprocess.TimeoutExpired("waitress-serve", 10)) + assert events == [("wait", None), "http_shutdown", "terminate", ("wait", 10), "kill", ("wait", None)] diff --git a/tests/test_status_packet.py b/tests/test_status_packet.py new file mode 100644 index 0000000..717e9fb --- /dev/null +++ b/tests/test_status_packet.py @@ -0,0 +1,102 @@ +import time + +import pytest + +import app + + +def test_distance_and_steps_accumulate(walking): + walking(4, dist=10, steps=20) + walking(4, dist=25, steps=50) + assert app.current_distance_km == pytest.approx(0.25) + assert app.current_steps == 50 + + +def test_device_counter_reset_continues_from_new_baseline(walking): + walking(4, dist=100, steps=500) + walking(4, dist=3, steps=7) + walking(4, dist=5, steps=10) + assert app.current_distance_km == pytest.approx(1.05) + assert app.current_steps == 510 + + +def test_calories_follow_distance(walking): + walking(4, dist=160) + assert app.current_calories == pytest.approx(app.kcal_estimate(1.6 * app.KM_TO_MI)) + + +def test_current_speed_updates(walking): + walking(4.2) + assert app.current_speed_kmh == pytest.approx(4.2) + walking(3.5) + assert app.current_speed_kmh == pytest.approx(3.5) + + +def test_speed_history_only_records_above_min_speed(walking, monkeypatch): + monkeypatch.setattr(app, "MIN_SPEED_KMH", 1.0) + walking(1.0) + walking(1.5) + assert list(app.speed_history) == [1.5] + + +def test_speed_history_not_recorded_while_paused(walking, monkeypatch): + monkeypatch.setattr(app, "belt_running", False) + walking(app.MIN_SPEED_KMH + 1) + assert not app.speed_history + + +def test_speed_history_capped(walking, monkeypatch): + monkeypatch.setattr(app, "MIN_SPEED_KMH", 1.0) + cap = app.speed_history.maxlen + for i in range(cap + 3): + walking(2 + i / 10) + assert len(app.speed_history) == cap + assert app.speed_history[0] == pytest.approx(2.3) + + +def test_auto_pause_resumes_at_oldest_history_speed(walking, monkeypatch): + monkeypatch.setattr(app, "MIN_SPEED_KMH", 1.0) + monkeypatch.setattr(app, "_record_pause", lambda *a, **k: None) + walking(4.5) + walking(3.0) # deceleration + walking(0) + assert not app.belt_running + assert app.resume_speed_kmh == pytest.approx(4.5) + + +def test_auto_pause_falls_back_to_min_speed_when_history_empty(walking, monkeypatch): + monkeypatch.setattr(app, "MIN_SPEED_KMH", 2.0) + monkeypatch.setattr(app, "_record_pause", lambda *a, **k: None) + walking(1.5) + walking(0) + assert app.resume_speed_kmh == 2.0 + + +def test_auto_pause_persists(walking, monkeypatch): + calls = [] + monkeypatch.setattr(app, "_record_pause", lambda reason, *a, **k: calls.append(reason)) + monkeypatch.setattr(app, "_save_session_state", lambda: calls.append("saved")) + walking(4) + assert calls == [] + walking(0) + assert calls == ["auto", "saved"] + + +def test_grace_deadline_suppresses_auto_pause(walking, monkeypatch): + monkeypatch.setattr(app, "_resume_grace_deadline", time.time() + 60) + walking(4) + walking(0) + assert app.belt_running + + +def test_status_update_stamped_on_success(walking, monkeypatch): + monkeypatch.setattr(app, "_last_status_update_monotonic", 0.0) + walking(4) + assert app._last_status_update_monotonic == app.time.monotonic() + + +def test_status_update_not_stamped_when_packet_raises(walking, monkeypatch): + monkeypatch.setattr(app, "_last_status_update_monotonic", 0.0) + with pytest.raises(TypeError): + walking(4, dist=None) + assert app._last_status_update_monotonic == 0.0 diff --git a/tests/test_storage.py b/tests/test_storage.py index 856227a..d61a0ea 100644 --- a/tests/test_storage.py +++ b/tests/test_storage.py @@ -293,7 +293,7 @@ def worker(): for t in range(batch_start * 1000, (batch_start + 5) * 1000, 1000) ]) storage.complete_session(session_id, {"end_time": "2026-01-01T08:05:00"}) - except Exception as exc: # noqa: BLE001 - collected across threads, re-raised below + except Exception as exc: errors.append(exc) threads = [threading.Thread(target=worker) for _ in range(5)]