mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 18:07:22 +01:00
Serve the lock decision, its history and its health at 127.0.0.1:8770
The only status surface was a GTK tray icon, commented out of ~/.config/i3 on 2026-08-21. So when the weekly count went wrong, nothing on the machine said so for two weeks -- the same silence that let a deleted timer job stop enforcement for thirteen days in August. Three read-only endpoints, and a React 19 UI over them: /api/status the decision, the week, and whether today has a workout /api/decisions the recorded trail, newest first /api/health timers armed, disarm marker, log and trail freshness The Why view is the one that matters: it renders the run of "weekly_minimum_met 5/5" ticks that hid this week's bug, without anyone having to reach for journalctl. /api/status also carries gaming.workout_today, which is the fact steam-backlog-enforcer will cut the daily budget on. Deliberately just the fact -- the enforcer owns the hour values, so the two repos cannot come to disagree about what a workout day is. It is derived from count_day_credits, the same rule the locker enforces with. _compliance_state_word moves from status_view (tkinter) into _status_projection, and the systemd arming check moves from scripts/check_enforcement_armed.py into screen_locker/_armed_state.py, so the server can answer "is enforcement armed" without a second copy of the logic that would be free to disagree with the gate. web/ mirrors steam-backlog-enforcer/web but tightens it: that config has no "strict" in any tsconfig, extends only tseslint recommended (no type-aware rules at all), and gates neither lint nor typecheck in pre-commit. Here all three run, on strictTypeChecked + stylisticTypeChecked, with noUncheckedIndexedAccess and exactOptionalPropertyTypes. 44 tests, 100% of statements, branches, functions and lines. typescript stays pinned at 6.0.3: 7.0.2 is out, but every published typescript-eslint -- stable, canary and rc-v8 -- declares typescript ">=4.8.4 <6.1.0". The fleet allowlist already carries that entry. The 250-line cap now covers ts/tsx. It was python/shell/dart only, which would have made web/ exempt from the gate the rest of the repo lives under -- the same omission that exempted the whole Flutter app until 2026-08-15. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fcwu78wk17FWMHbbfbyicc
This commit is contained in:
parent
dd8a39ade5
commit
8f911585c1
7
.gitignore
vendored
7
.gitignore
vendored
@ -30,3 +30,10 @@ screen_locker/early_bird_pending.json
|
||||
|
||||
# ci_mirror.sh cached requirements.txt venv (rebuilt on demand)
|
||||
.ci-mirror-venv/
|
||||
|
||||
# Web status UI (web/) build and tooling output.
|
||||
web/node_modules/
|
||||
web/dist/
|
||||
web/coverage/
|
||||
web/node_modules
|
||||
web/dist
|
||||
|
||||
@ -195,8 +195,11 @@ repos:
|
||||
language: system
|
||||
# Dart was missing here until 2026-08-15, so the cap had never looked at
|
||||
# a single file in workout_app/ — the whole Flutter app was exempt from
|
||||
# the gate it was being refactored to satisfy.
|
||||
types_or: [python, shell, dart]
|
||||
# the gate it was being refactored to satisfy. ts/tsx joined on
|
||||
# 2026-08-29 with web/, for exactly the same reason: a language absent
|
||||
# from this list is a directory the cap silently does not apply to.
|
||||
types_or: [python, shell, dart, ts, tsx]
|
||||
exclude: '^web/(node_modules|dist|coverage)/'
|
||||
- id: no-silent-failures
|
||||
name: Block silent failures (swallowed exceptions and silent sys.exit(0) must say why)
|
||||
entry: python3 scripts/check_silent_failures.py
|
||||
@ -236,6 +239,32 @@ repos:
|
||||
require_serial: true
|
||||
stages: [pre-push]
|
||||
|
||||
# web/ gates. steam-backlog-enforcer, which this app was copied from, has
|
||||
# only a coverage hook -- its lint and typecheck are unenforced, so both
|
||||
# can rot without anything failing. All three run here.
|
||||
- id: web-lint
|
||||
name: eslint (status web UI)
|
||||
entry: npm --prefix web run lint
|
||||
language: system
|
||||
files: '^web/(src/.*\.(ts|tsx)|eslint\.config\.js|package\.json)$'
|
||||
pass_filenames: false
|
||||
require_serial: true
|
||||
- id: web-typecheck
|
||||
name: tsc --build (status web UI)
|
||||
entry: npm --prefix web run build
|
||||
language: system
|
||||
files: '^web/(src/.*\.(ts|tsx)|tsconfig.*\.json|vite\.config\.ts)$'
|
||||
pass_filenames: false
|
||||
require_serial: true
|
||||
- id: web-coverage
|
||||
name: vitest --coverage 100% (status web UI)
|
||||
entry: npm --prefix web run coverage
|
||||
language: system
|
||||
files: '^web/src/'
|
||||
pass_filenames: false
|
||||
require_serial: true
|
||||
stages: [pre-push]
|
||||
|
||||
# Four markdown namespaces (README / CLAUDE* / DOCS* / TODO*), plus the
|
||||
# rule that a TODO carries "REMOVE ME AFTER FINISH" and nothing else may.
|
||||
# Shared gate in ~/utils owns the namespaces and the exemption list, so
|
||||
|
||||
@ -13,6 +13,7 @@ dependencies = [
|
||||
|
||||
[project.scripts]
|
||||
screen-locker-status = "screen_locker.status_view:main"
|
||||
screen-locker-web = "screen_locker._web_server:main"
|
||||
|
||||
# Optional extras. The `mcp` group is ONLY needed by the read-only MCP server
|
||||
# (screen_locker._mcp), which Claude Code spawns via a dedicated venv (see
|
||||
|
||||
19
screen-locker-web.service
Normal file
19
screen-locker-web.service
Normal file
@ -0,0 +1,19 @@
|
||||
[Unit]
|
||||
Description=Screen Locker status web UI (localhost only)
|
||||
# No After=graphical-session.target: this serves HTTP and reads state files, it
|
||||
# never touches the display. Ordering it after the graphical session would also
|
||||
# stop it starting on a headless boot, which is exactly when you most want to
|
||||
# be able to ask whether enforcement is armed.
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=/usr/bin/python3 -m screen_locker._web_server
|
||||
Restart=always
|
||||
RestartSec=5s
|
||||
# steam-backlog-enforcer asks this server whether today has a workout before
|
||||
# deciding the gaming budget, and fails CLOSED to the smaller budget when the
|
||||
# answer does not arrive. So a crash costs gaming time rather than silently
|
||||
# handing it back -- but it should still come straight back up.
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
150
screen_locker/_armed_state.py
Normal file
150
screen_locker/_armed_state.py
Normal file
@ -0,0 +1,150 @@
|
||||
"""Whether systemd would actually run the locker, read from systemd itself.
|
||||
|
||||
The 2026-08 outage was not a logic bug. Every predicate worked; the timer that
|
||||
*invokes* them had been disabled by an ordering cycle, and nothing on the
|
||||
machine considered that worth mentioning. ``systemctl status`` showed no failed
|
||||
unit, because a job deleted to break a cycle is not a failure -- it is an
|
||||
absence, and absences do not page anyone.
|
||||
|
||||
This module answers one question -- *would enforcement actually happen?* -- from
|
||||
systemd's own view rather than from the unit files on disk, because the files
|
||||
were correct-looking the entire time the timer sat disabled.
|
||||
|
||||
It lives in the package rather than in ``scripts/`` because two surfaces need
|
||||
the same answer: ``scripts/check_enforcement_armed.py`` (the gate that rides
|
||||
along with ``workout-sync.service``) and the Health view of the local web UI.
|
||||
Two copies of this logic would be free to disagree about whether the locker is
|
||||
alive, which is the failure it exists to detect.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
# Every unit that must be armed for a workout-less day to end in a lock.
|
||||
# workout-locker.service itself is WantedBy=graphical-session.target and so is
|
||||
# only ever a login one-shot; the timers are what make enforcement recur.
|
||||
REQUIRED_TIMERS = (
|
||||
"early-bird-workout-check.timer",
|
||||
"workout-locker.timer",
|
||||
)
|
||||
|
||||
_TIMEOUT_SECONDS = 10
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TimerState:
|
||||
"""What systemd currently believes about one timer."""
|
||||
|
||||
name: str
|
||||
enabled: bool
|
||||
enabled_raw: str
|
||||
scheduled: bool
|
||||
|
||||
@property
|
||||
def armed(self) -> bool:
|
||||
"""True only when the timer is both enabled and actually scheduled.
|
||||
|
||||
Both halves matter. ``is-enabled`` alone passes for a timer whose job
|
||||
systemd deleted to break an ordering cycle; ``list-timers`` alone passes
|
||||
for a transient ``start``ed timer that will not survive a reboot.
|
||||
|
||||
Returns:
|
||||
True when this timer would really fire.
|
||||
"""
|
||||
return self.enabled and self.scheduled
|
||||
|
||||
def describe(self) -> str:
|
||||
"""Return a one-line human summary of this timer's state.
|
||||
|
||||
Returns:
|
||||
An ``OK``/``DISARMED`` line naming every reason it is not armed.
|
||||
"""
|
||||
if self.armed:
|
||||
return f"OK {self.name}: enabled and scheduled"
|
||||
problems = []
|
||||
if not self.enabled:
|
||||
problems.append(f"not enabled (is-enabled={self.enabled_raw or 'unknown'})")
|
||||
if not self.scheduled:
|
||||
problems.append("no next trigger in list-timers")
|
||||
return f"DISARMED {self.name}: {', '.join(problems)}"
|
||||
|
||||
|
||||
def systemctl_available() -> bool:
|
||||
"""Return whether ``systemctl`` can be run at all.
|
||||
|
||||
Reachable in containers and CI. Callers must treat False as "did not
|
||||
check", never as "checked and fine".
|
||||
|
||||
Returns:
|
||||
True when ``systemctl`` is on PATH.
|
||||
"""
|
||||
return shutil.which("systemctl") is not None
|
||||
|
||||
|
||||
def _run(args: list[str]) -> tuple[int, str]:
|
||||
"""Run ``args``, returning ``(returncode, stdout)``. Never raises.
|
||||
|
||||
Args:
|
||||
args: The argument vector, passed without a shell.
|
||||
|
||||
Returns:
|
||||
The process return code and its stdout; ``(1, "")`` if it could not run.
|
||||
"""
|
||||
try:
|
||||
# Explicit argument list, never shell=True.
|
||||
proc = subprocess.run(
|
||||
args,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=_TIMEOUT_SECONDS,
|
||||
check=False,
|
||||
)
|
||||
except OSError, subprocess.SubprocessError:
|
||||
# A check that cannot run must not look like a check that passed.
|
||||
_logger.exception("Could not run %s", " ".join(args))
|
||||
return 1, ""
|
||||
return proc.returncode, proc.stdout
|
||||
|
||||
|
||||
def _scheduled_timers() -> set[str]:
|
||||
"""Return the timer names systemd currently has a next trigger for.
|
||||
|
||||
Returns:
|
||||
The subset of :data:`REQUIRED_TIMERS` with a scheduled next run.
|
||||
"""
|
||||
_, out = _run(["systemctl", "--user", "list-timers", "--all", "--no-pager"])
|
||||
scheduled: set[str] = set()
|
||||
for line in out.splitlines():
|
||||
for name in REQUIRED_TIMERS:
|
||||
# A timer with no next trigger prints "n/a" in the NEXT column.
|
||||
if name in line and " n/a " not in f" {line} ":
|
||||
scheduled.add(name)
|
||||
return scheduled
|
||||
|
||||
|
||||
def collect_states() -> list[TimerState]:
|
||||
"""Return the current arming state of every required timer.
|
||||
|
||||
Returns:
|
||||
One :class:`TimerState` per entry in :data:`REQUIRED_TIMERS`.
|
||||
"""
|
||||
scheduled = _scheduled_timers()
|
||||
states = []
|
||||
for name in REQUIRED_TIMERS:
|
||||
code, out = _run(["systemctl", "--user", "is-enabled", name])
|
||||
raw = out.strip()
|
||||
states.append(
|
||||
TimerState(
|
||||
name=name,
|
||||
enabled=code == 0 and raw == "enabled",
|
||||
enabled_raw=raw,
|
||||
scheduled=name in scheduled,
|
||||
)
|
||||
)
|
||||
return states
|
||||
@ -52,7 +52,7 @@ from screen_locker._constants import (
|
||||
)
|
||||
from screen_locker._sick_tracker import load_history
|
||||
from screen_locker._status_data import format_summary_line, gather_status
|
||||
from screen_locker.status_view import _compliance_state_word
|
||||
from screen_locker._status_projection import compliance_state_word
|
||||
|
||||
# Log to STDERR only — STDOUT carries the MCP JSON-RPC protocol frames, so a
|
||||
# single stray stdout write would corrupt the stream and kill the session.
|
||||
@ -105,12 +105,12 @@ def get_summary() -> dict[str, Any]:
|
||||
|
||||
``summary_line`` is the same string the status bar shows
|
||||
(``format_summary_line``); ``compliance_state`` is one of ``ok`` / ``warn``
|
||||
/ ``lock`` (``_compliance_state_word``).
|
||||
/ ``lock`` (``compliance_state_word``).
|
||||
"""
|
||||
snapshot = gather_status()
|
||||
return {
|
||||
"summary_line": format_summary_line(snapshot),
|
||||
"compliance_state": _compliance_state_word(snapshot),
|
||||
"compliance_state": compliance_state_word(snapshot),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -108,3 +108,17 @@ def format_summary_line(snapshot: StatusSnapshot) -> str:
|
||||
f"{mark} {week.counted_count}/{week.minimum} workouts · "
|
||||
f"{tonight_str} tonight · sick {sick.used_7d}/{sick.budget_7d}"
|
||||
)
|
||||
|
||||
|
||||
def compliance_state_word(snapshot: StatusSnapshot) -> str:
|
||||
"""One-word compliance state for the tray icon: ``ok`` / ``warn`` / ``lock``.
|
||||
|
||||
``lock`` — the lock would fire right now (no skip condition applies).
|
||||
``warn`` — not locked, but this week's minimum isn't met yet.
|
||||
``ok`` — not locked and the weekly minimum is already met.
|
||||
"""
|
||||
if snapshot.lock_explanation.fired:
|
||||
return "lock"
|
||||
if snapshot.week.remaining > 0:
|
||||
return "warn"
|
||||
return "ok"
|
||||
|
||||
209
screen_locker/_web_payload.py
Normal file
209
screen_locker/_web_payload.py
Normal file
@ -0,0 +1,209 @@
|
||||
"""JSON payloads for the local status web UI.
|
||||
|
||||
One read-only view, shared by the HTTP server and anything else that wants the
|
||||
same answer. Deliberately built on :func:`~screen_locker._status_data.gather_status`
|
||||
rather than on a second walk of the log: ``_mcp`` already proves that snapshot
|
||||
round-trips through ``json.dumps`` unchanged, and a second projection is exactly
|
||||
how the status view came to explain a decision the locker never made.
|
||||
|
||||
Nothing here reads a secret. The sync token and the HMAC key never appear in a
|
||||
payload, and no endpoint takes input that reaches the filesystem.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict
|
||||
from datetime import UTC, datetime
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from screen_locker._armed_state import collect_states, systemctl_available
|
||||
from screen_locker._decision_log import DECISION_LOG_FILE, read_decisions
|
||||
from screen_locker._log_io import load_workout_log
|
||||
from screen_locker._status_data import gather_status
|
||||
from screen_locker._status_projection import (
|
||||
compliance_state_word,
|
||||
format_summary_line,
|
||||
)
|
||||
from screen_locker._weekly_check import count_day_credits
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
# The workout log lives beside the package, same default as
|
||||
# ``_status_data._DEFAULT_LOG_FILE``; declared here to avoid importing a
|
||||
# private name across modules, exactly as ``_mcp`` does.
|
||||
LOG_FILE = Path(__file__).resolve().parent / "log.json"
|
||||
|
||||
# The marker that disables enforcement machine-wide. Kept in sync with
|
||||
# scripts/disarm_guard.sh, which is the thing that writes it.
|
||||
DISARM_MARKER = Path.home() / ".local" / "share" / "screen_locker" / "DISARMED"
|
||||
|
||||
# How many decisions the Why view gets by default. The trail holds 3000; the
|
||||
# browser only ever needs the recent tail to answer "why did nothing happen".
|
||||
DEFAULT_DECISION_LIMIT = 200
|
||||
|
||||
|
||||
def workout_credits_today(*, now: datetime | None = None) -> int:
|
||||
"""Return how many workout credits today has earned so far.
|
||||
|
||||
Uses :func:`~screen_locker._weekly_check.count_day_credits`, the same rule
|
||||
the locker enforces with, so "did I work out today" cannot drift from "does
|
||||
the locker think I worked out today".
|
||||
|
||||
Args:
|
||||
now: Override for the current local datetime (for testing).
|
||||
|
||||
Returns:
|
||||
The number of distinct credits earned today, zero if none.
|
||||
"""
|
||||
moment = now if now is not None else datetime.now(tz=UTC).astimezone()
|
||||
today = moment.date().isoformat()
|
||||
entries = load_workout_log(LOG_FILE).get(today, [])
|
||||
return count_day_credits(today, [e for e in entries if isinstance(e, dict)])
|
||||
|
||||
|
||||
def build_status_payload(*, now: datetime | None = None) -> dict[str, Any]:
|
||||
"""Build the full status payload.
|
||||
|
||||
Args:
|
||||
now: Override for the current local datetime (for testing).
|
||||
|
||||
Returns:
|
||||
The status snapshot plus the derived one-liners and the gaming-budget
|
||||
input the enforcer reads.
|
||||
"""
|
||||
snapshot = gather_status()
|
||||
credits_today = workout_credits_today(now=now)
|
||||
return {
|
||||
"snapshot": asdict(snapshot),
|
||||
"summary_line": format_summary_line(snapshot),
|
||||
"compliance_state": compliance_state_word(snapshot),
|
||||
# The gaming budget is decided by steam-backlog-enforcer, which owns the
|
||||
# hour values. This reports only the fact the decision turns on, so the
|
||||
# two repos cannot disagree about what a "workout day" is.
|
||||
"gaming": {
|
||||
"workout_today": credits_today > 0,
|
||||
"credits_today": credits_today,
|
||||
"reason": (
|
||||
f"{credits_today} counted workout(s) logged today"
|
||||
if credits_today
|
||||
else "no counted workout logged today"
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def build_decisions_payload(
|
||||
*,
|
||||
limit: int = DEFAULT_DECISION_LIMIT,
|
||||
) -> dict[str, Any]:
|
||||
"""Build the decision-history payload, newest first.
|
||||
|
||||
Args:
|
||||
limit: Maximum number of decisions to return.
|
||||
|
||||
Returns:
|
||||
The recent decisions and the total the trail holds.
|
||||
"""
|
||||
decisions = read_decisions()
|
||||
tail = decisions[-limit:] if limit > 0 else decisions
|
||||
return {
|
||||
"total": len(decisions),
|
||||
"returned": len(tail),
|
||||
"decisions": list(reversed(tail)),
|
||||
}
|
||||
|
||||
|
||||
def _log_age_seconds(*, now: datetime | None = None) -> float | None:
|
||||
"""Return how long ago the workout log was last written.
|
||||
|
||||
Args:
|
||||
now: Override for the current local datetime (for testing).
|
||||
|
||||
Returns:
|
||||
Age in seconds, or ``None`` when the log does not exist.
|
||||
"""
|
||||
try:
|
||||
mtime = LOG_FILE.stat().st_mtime
|
||||
except OSError:
|
||||
_logger.warning(
|
||||
"Cannot stat the workout log at %s — reporting its age as unknown "
|
||||
"rather than as fresh",
|
||||
LOG_FILE,
|
||||
)
|
||||
return None
|
||||
moment = now if now is not None else datetime.now(tz=UTC).astimezone()
|
||||
return moment.timestamp() - mtime
|
||||
|
||||
|
||||
def _last_decision_age_seconds(*, now: datetime | None = None) -> float | None:
|
||||
"""Return how long ago a decision was last recorded.
|
||||
|
||||
A locker that stopped running goes quiet rather than failing, so the age of
|
||||
the newest decision is the most direct evidence that it is still alive.
|
||||
|
||||
Args:
|
||||
now: Override for the current local datetime (for testing).
|
||||
|
||||
Returns:
|
||||
Age in seconds, or ``None`` when nothing has been recorded.
|
||||
"""
|
||||
decisions = read_decisions()
|
||||
if not decisions:
|
||||
return None
|
||||
stamp = decisions[-1].get("timestamp")
|
||||
if not isinstance(stamp, str):
|
||||
return None
|
||||
try:
|
||||
recorded = datetime.fromisoformat(stamp)
|
||||
except ValueError:
|
||||
_logger.warning(
|
||||
"Newest decision has an unparsable timestamp %r — reporting its "
|
||||
"age as unknown rather than as recent",
|
||||
stamp,
|
||||
)
|
||||
return None
|
||||
moment = now if now is not None else datetime.now(tz=UTC).astimezone()
|
||||
return moment.timestamp() - recorded.timestamp()
|
||||
|
||||
|
||||
def build_health_payload(*, now: datetime | None = None) -> dict[str, Any]:
|
||||
"""Build the health payload: is the locker actually able to fire.
|
||||
|
||||
Every field here exists because its absence once went unnoticed. A timer
|
||||
silently deleted to break an ordering cycle, a disarm marker left in place,
|
||||
a log that stopped being written -- none of those raise an error anywhere,
|
||||
so each is reported as a value rather than left to be inferred from silence.
|
||||
|
||||
Args:
|
||||
now: Override for the current local datetime (for testing).
|
||||
|
||||
Returns:
|
||||
Timer arming state, the disarm marker, and freshness of log and trail.
|
||||
"""
|
||||
checked = systemctl_available()
|
||||
timers = [
|
||||
{
|
||||
"name": state.name,
|
||||
"enabled": state.enabled,
|
||||
"scheduled": state.scheduled,
|
||||
"armed": state.armed,
|
||||
"describe": state.describe(),
|
||||
}
|
||||
for state in (collect_states() if checked else ())
|
||||
]
|
||||
disarmed = DISARM_MARKER.exists()
|
||||
return {
|
||||
# "could not check" is reported as its own state: it must never be
|
||||
# rendered as a green tick.
|
||||
"timers_checked": checked,
|
||||
"timers": timers,
|
||||
"armed": checked and not disarmed and all(t["armed"] for t in timers),
|
||||
"disarmed": disarmed,
|
||||
"disarm_marker": str(DISARM_MARKER),
|
||||
"log_file": str(LOG_FILE),
|
||||
"log_age_seconds": _log_age_seconds(now=now),
|
||||
"decision_log_file": str(DECISION_LOG_FILE),
|
||||
"last_decision_age_seconds": _last_decision_age_seconds(now=now),
|
||||
}
|
||||
222
screen_locker/_web_server.py
Normal file
222
screen_locker/_web_server.py
Normal file
@ -0,0 +1,222 @@
|
||||
"""Read-only localhost HTTP server for the status web UI.
|
||||
|
||||
Serves the status/decision/health payloads at ``/api/*`` and the built React
|
||||
bundle (``web/dist``) as static files. Binds to loopback only and never exposes
|
||||
a secret: every payload comes from :mod:`screen_locker._web_payload`, which
|
||||
reads state files but never the sync token or the HMAC key.
|
||||
|
||||
Read-only by construction, like the MCP server it sits beside: there is no
|
||||
route that logs a workout, clears a lock, or writes anything at all. A local
|
||||
web page that could log a workout would be a hole straight through the gate.
|
||||
|
||||
Two things depend on this process being up: the browser UI, and
|
||||
steam-backlog-enforcer, which asks ``/api/status`` whether today has a workout
|
||||
before deciding the gaming budget. The enforcer fails closed to the smaller
|
||||
budget when this is unreachable, so a dead server costs gaming time rather than
|
||||
silently handing it back.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from http import HTTPStatus
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
import json
|
||||
import logging
|
||||
import mimetypes
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from urllib.parse import parse_qs, urlsplit
|
||||
|
||||
from screen_locker._web_payload import (
|
||||
DEFAULT_DECISION_LIMIT,
|
||||
build_decisions_payload,
|
||||
build_health_payload,
|
||||
build_status_payload,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Built frontend lives at <repo>/web/dist (sibling of the package directory).
|
||||
WEB_DIST = (Path(__file__).resolve().parent.parent / "web" / "dist").resolve()
|
||||
|
||||
DEFAULT_HOST = "127.0.0.1"
|
||||
# 8000 is taken by steam-backlog-enforcer's own web UI.
|
||||
DEFAULT_PORT = 8770
|
||||
|
||||
_API_STATUS = "/api/status"
|
||||
_API_DECISIONS = "/api/decisions"
|
||||
_API_HEALTH = "/api/health"
|
||||
|
||||
# Content types that are text but not under the ``text/`` prefix.
|
||||
_EXTRA_TEXT_TYPES = frozenset(
|
||||
{"application/javascript", "application/json", "image/svg+xml"}
|
||||
)
|
||||
_NOT_BUILT_MSG = (
|
||||
b"Frontend not built. Run: npm --prefix web install && npm --prefix web run build"
|
||||
)
|
||||
|
||||
|
||||
def _decision_limit(query: str) -> int:
|
||||
"""Parse ``?limit=`` from a query string, falling back to the default.
|
||||
|
||||
Args:
|
||||
query: The raw query string.
|
||||
|
||||
Returns:
|
||||
A positive limit; the default when absent, non-numeric or out of range.
|
||||
"""
|
||||
raw = parse_qs(query).get("limit", [""])[0]
|
||||
try:
|
||||
value = int(raw)
|
||||
except ValueError:
|
||||
if raw:
|
||||
# Benign (a hand-typed URL), but say so: a silently ignored
|
||||
# parameter is how someone concludes the endpoint is broken.
|
||||
logger.warning(
|
||||
"Ignoring unparsable ?limit=%r; serving the default %d decisions",
|
||||
raw,
|
||||
DEFAULT_DECISION_LIMIT,
|
||||
)
|
||||
return DEFAULT_DECISION_LIMIT
|
||||
return value if value > 0 else DEFAULT_DECISION_LIMIT
|
||||
|
||||
|
||||
class _Handler(BaseHTTPRequestHandler):
|
||||
"""Serve the status payloads and the static frontend bundle (read-only)."""
|
||||
|
||||
def log_message(self, fmt: str, *args: object) -> None:
|
||||
"""Route the default request log to ``logging`` at debug level.
|
||||
|
||||
Args:
|
||||
fmt: printf-style format string from BaseHTTPRequestHandler.
|
||||
args: Its arguments.
|
||||
"""
|
||||
logger.debug("%s - %s", self.address_string(), fmt % args)
|
||||
|
||||
def do_GET(self) -> None:
|
||||
"""Dispatch a GET to one of the APIs or to a static file."""
|
||||
split = urlsplit(self.path)
|
||||
if split.path == _API_STATUS:
|
||||
self._serve_json("status", build_status_payload)
|
||||
elif split.path == _API_DECISIONS:
|
||||
limit = _decision_limit(split.query)
|
||||
self._serve_json("decisions", lambda: build_decisions_payload(limit=limit))
|
||||
elif split.path == _API_HEALTH:
|
||||
self._serve_json("health", build_health_payload)
|
||||
else:
|
||||
self._serve_static(split.path)
|
||||
|
||||
def _serve_json(self, name: str, build: Callable[[], dict[str, Any]]) -> None:
|
||||
"""Build a payload and send it as JSON, or a 500 naming the failure.
|
||||
|
||||
Args:
|
||||
name: Payload name, used in the error message and the log.
|
||||
build: Zero-argument callable returning the payload.
|
||||
"""
|
||||
try:
|
||||
body = json.dumps(build()).encode("utf-8")
|
||||
except OSError, ValueError, KeyError, TypeError:
|
||||
# Never a bare swallow: the reason has to reach the journal, or a
|
||||
# broken endpoint looks identical to an endpoint with nothing to say.
|
||||
logger.exception("Failed to build the %s payload", name)
|
||||
self._send(
|
||||
HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||
f"{name} error - see the journal for screen-locker-web".encode(),
|
||||
"text/plain",
|
||||
)
|
||||
return
|
||||
self._send(HTTPStatus.OK, body, "application/json")
|
||||
|
||||
def _serve_static(self, path: str) -> None:
|
||||
"""Serve a file from ``WEB_DIST`` with SPA fallback and traversal guard.
|
||||
|
||||
Args:
|
||||
path: The request path.
|
||||
"""
|
||||
rel = path.lstrip("/") or "index.html"
|
||||
candidate = (WEB_DIST / rel).resolve()
|
||||
# Reject path traversal, then fall back to index.html for SPA routes.
|
||||
if not candidate.is_relative_to(WEB_DIST) or not candidate.is_file():
|
||||
candidate = WEB_DIST / "index.html"
|
||||
if not candidate.is_file():
|
||||
self._send(HTTPStatus.NOT_FOUND, _NOT_BUILT_MSG, "text/plain")
|
||||
return
|
||||
ctype, _ = mimetypes.guess_type(candidate.name)
|
||||
self._send(HTTPStatus.OK, candidate.read_bytes(), ctype or "text/plain")
|
||||
|
||||
def _send(self, status: HTTPStatus, body: bytes, ctype: str) -> None:
|
||||
"""Write a complete response with the given status, body, and type.
|
||||
|
||||
Args:
|
||||
status: HTTP status to send.
|
||||
body: Response body.
|
||||
ctype: Content type, charset appended for text types.
|
||||
"""
|
||||
if ctype.startswith("text/") or ctype in _EXTRA_TEXT_TYPES:
|
||||
ctype = f"{ctype}; charset=utf-8"
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", ctype)
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
|
||||
def create_server(
|
||||
host: str = DEFAULT_HOST,
|
||||
port: int = DEFAULT_PORT,
|
||||
) -> ThreadingHTTPServer:
|
||||
"""Create (but do not start) the threading HTTP server.
|
||||
|
||||
Args:
|
||||
host: Address to bind. Must stay on loopback: the API is unauthenticated.
|
||||
port: Port to bind.
|
||||
|
||||
Returns:
|
||||
The unstarted server.
|
||||
|
||||
Raises:
|
||||
ValueError: If asked to bind anywhere but loopback.
|
||||
"""
|
||||
if not host.startswith("127.") and host != "localhost":
|
||||
# The payloads are unauthenticated, so a bind must never leave this
|
||||
# machine. Refuse loudly rather than quietly publishing them.
|
||||
msg = f"Refusing to bind {host}: the status API is loopback-only"
|
||||
raise ValueError(msg)
|
||||
return ThreadingHTTPServer((host, port), _Handler)
|
||||
|
||||
|
||||
def serve(host: str = DEFAULT_HOST, port: int = DEFAULT_PORT) -> None:
|
||||
"""Run the web server until interrupted.
|
||||
|
||||
Args:
|
||||
host: Address to bind.
|
||||
port: Port to bind.
|
||||
"""
|
||||
server = create_server(host, port)
|
||||
logger.info("Screen locker status UI: http://%s:%d", host, port)
|
||||
try:
|
||||
server.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
# Warning, not info: this process is load-bearing for the gaming
|
||||
# budget, so its shutdown must be visible in the journal.
|
||||
logger.warning("Interrupted — shutting the status server down.")
|
||||
finally:
|
||||
server.server_close()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""Console-script entry point for ``screen-locker-web``.
|
||||
|
||||
Returns:
|
||||
Process exit code.
|
||||
"""
|
||||
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
|
||||
serve()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@ -37,6 +37,7 @@ from screen_locker._status_data import (
|
||||
format_summary_line,
|
||||
gather_status,
|
||||
)
|
||||
from screen_locker._status_projection import compliance_state_word
|
||||
from screen_locker._status_sections import StatusSectionsMixin
|
||||
from screen_locker._status_view_verify import PhoneCheckMixin, _make_bare_verifier
|
||||
from screen_locker._surface_group import FrameGroup
|
||||
@ -199,20 +200,6 @@ class StatusWindow(
|
||||
self.render(gather_status())
|
||||
|
||||
|
||||
def _compliance_state_word(snapshot: StatusSnapshot) -> str:
|
||||
"""One-word compliance state for the tray icon: ``ok`` / ``warn`` / ``lock``.
|
||||
|
||||
``lock`` — the lock would fire right now (no skip condition applies).
|
||||
``warn`` — not locked, but this week's minimum isn't met yet.
|
||||
``ok`` — not locked and the weekly minimum is already met.
|
||||
"""
|
||||
if snapshot.lock_explanation.fired:
|
||||
return "lock"
|
||||
if snapshot.week.remaining > 0:
|
||||
return "warn"
|
||||
return "ok"
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> None:
|
||||
"""Entry point: ``--summary``/``--state``/``--sync`` print one line.
|
||||
|
||||
@ -223,7 +210,7 @@ def main(argv: list[str] | None = None) -> None:
|
||||
print(format_summary_line(gather_status()))
|
||||
return
|
||||
if "--state" in args:
|
||||
print(_compliance_state_word(gather_status()))
|
||||
print(compliance_state_word(gather_status()))
|
||||
return
|
||||
if "--sync" in args:
|
||||
# On-disk only, like the other two -- safe on an i3blocks tick.
|
||||
|
||||
135
screen_locker/tests/test_armed_state.py
Normal file
135
screen_locker/tests/test_armed_state.py
Normal file
@ -0,0 +1,135 @@
|
||||
"""Tests for _armed_state: is the locker actually schedulable.
|
||||
|
||||
The distinction these tests defend is the one that cost thirteen days in
|
||||
2026-08: ``is-enabled`` says "enabled" for a timer whose job systemd deleted to
|
||||
break an ordering cycle, so enabled alone is not armed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from screen_locker._armed_state import (
|
||||
REQUIRED_TIMERS,
|
||||
TimerState,
|
||||
collect_states,
|
||||
systemctl_available,
|
||||
)
|
||||
|
||||
_PKG = "screen_locker._armed_state"
|
||||
|
||||
_LISTED = """NEXT LEFT LAST PASSED UNIT
|
||||
Sun 2026-08-30 08:30:00 CEST 10h - - early-bird-workout-check.timer
|
||||
Sat 2026-08-29 23:00:00 CEST 30min - - workout-locker.timer
|
||||
"""
|
||||
|
||||
|
||||
def _completed(stdout: str, code: int = 0) -> MagicMock:
|
||||
"""Build a stand-in for a finished subprocess.
|
||||
|
||||
Args:
|
||||
stdout: What the command printed.
|
||||
code: Its return code.
|
||||
|
||||
Returns:
|
||||
An object with the two attributes ``_run`` reads.
|
||||
"""
|
||||
proc = MagicMock()
|
||||
proc.returncode = code
|
||||
proc.stdout = stdout
|
||||
return proc
|
||||
|
||||
|
||||
class TestTimerState:
|
||||
"""armed is the conjunction; describe() names every missing half."""
|
||||
|
||||
def test_enabled_and_scheduled_is_armed(self) -> None:
|
||||
"""Both halves present means the timer will really fire."""
|
||||
state = TimerState("t", enabled=True, enabled_raw="enabled", scheduled=True)
|
||||
assert state.armed is True
|
||||
assert "OK" in state.describe()
|
||||
|
||||
def test_enabled_but_unscheduled_is_not_armed(self) -> None:
|
||||
"""The exact 2026-08 failure: enabled, but the job was deleted."""
|
||||
state = TimerState("t", enabled=True, enabled_raw="enabled", scheduled=False)
|
||||
assert state.armed is False
|
||||
assert "no next trigger" in state.describe()
|
||||
|
||||
def test_scheduled_but_disabled_is_not_armed(self) -> None:
|
||||
"""A transient start does not survive a reboot."""
|
||||
state = TimerState("t", enabled=False, enabled_raw="disabled", scheduled=True)
|
||||
assert state.armed is False
|
||||
assert "not enabled (is-enabled=disabled)" in state.describe()
|
||||
|
||||
def test_unknown_enabled_state_is_named(self) -> None:
|
||||
"""An empty is-enabled reads as unknown, not as a blank."""
|
||||
state = TimerState("t", enabled=False, enabled_raw="", scheduled=False)
|
||||
assert "is-enabled=unknown" in state.describe()
|
||||
|
||||
|
||||
class TestSystemctlAvailable:
|
||||
""" "Could not check" must be distinguishable from "checked and fine"."""
|
||||
|
||||
def test_present(self) -> None:
|
||||
"""systemctl on PATH means the check can run."""
|
||||
with patch(f"{_PKG}.shutil.which", return_value="/usr/bin/systemctl"):
|
||||
assert systemctl_available() is True
|
||||
|
||||
def test_absent(self) -> None:
|
||||
"""No systemctl (containers, CI) is reported, never assumed fine."""
|
||||
with patch(f"{_PKG}.shutil.which", return_value=None):
|
||||
assert systemctl_available() is False
|
||||
|
||||
|
||||
class TestCollectStates:
|
||||
"""collect_states reads systemd's own view, not the unit files."""
|
||||
|
||||
def test_all_armed(self) -> None:
|
||||
"""Both timers enabled and listed with a next trigger."""
|
||||
with patch(f"{_PKG}.subprocess.run") as run:
|
||||
run.side_effect = [
|
||||
_completed(_LISTED),
|
||||
*[_completed("enabled\n") for _ in REQUIRED_TIMERS],
|
||||
]
|
||||
states = collect_states()
|
||||
assert [s.name for s in states] == list(REQUIRED_TIMERS)
|
||||
assert all(s.armed for s in states)
|
||||
|
||||
def test_timer_listed_with_no_next_trigger_is_unscheduled(self) -> None:
|
||||
"""An "n/a" NEXT column means systemd will never run it."""
|
||||
listed = (
|
||||
"NEXT LEFT LAST PASSED UNIT\n n/a - - - workout-locker.timer\n"
|
||||
)
|
||||
with patch(f"{_PKG}.subprocess.run") as run:
|
||||
run.side_effect = [
|
||||
_completed(listed),
|
||||
*[_completed("enabled\n") for _ in REQUIRED_TIMERS],
|
||||
]
|
||||
states = collect_states()
|
||||
assert all(not s.scheduled for s in states)
|
||||
|
||||
def test_disabled_timer_is_reported(self) -> None:
|
||||
"""A non-zero is-enabled marks the timer disabled."""
|
||||
with patch(f"{_PKG}.subprocess.run") as run:
|
||||
run.side_effect = [
|
||||
_completed(_LISTED),
|
||||
*[_completed("disabled\n", code=1) for _ in REQUIRED_TIMERS],
|
||||
]
|
||||
states = collect_states()
|
||||
assert all(not s.enabled for s in states)
|
||||
|
||||
def test_a_check_that_cannot_run_is_not_a_pass(self) -> None:
|
||||
"""When systemctl cannot be executed, nothing reads as armed."""
|
||||
with patch(f"{_PKG}.subprocess.run", side_effect=OSError("no exec")):
|
||||
states = collect_states()
|
||||
assert all(not s.armed for s in states)
|
||||
|
||||
def test_a_timeout_is_not_a_pass_either(self) -> None:
|
||||
"""A hung systemctl must fail closed, same as a missing one."""
|
||||
with patch(
|
||||
f"{_PKG}.subprocess.run",
|
||||
side_effect=subprocess.TimeoutExpired(cmd="systemctl", timeout=10),
|
||||
):
|
||||
states = collect_states()
|
||||
assert all(not s.armed for s in states)
|
||||
@ -103,7 +103,7 @@ class TestReadTools:
|
||||
with (
|
||||
patch.object(_mcp, "gather_status") as gs,
|
||||
patch.object(_mcp, "format_summary_line", return_value="LINE") as fsl,
|
||||
patch.object(_mcp, "_compliance_state_word", return_value="ok") as csw,
|
||||
patch.object(_mcp, "compliance_state_word", return_value="ok") as csw,
|
||||
):
|
||||
out = _mcp.get_summary()
|
||||
assert out == {"summary_line": "LINE", "compliance_state": "ok"}
|
||||
|
||||
@ -2,9 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from screen_locker.status_view import (
|
||||
_compliance_state_word,
|
||||
)
|
||||
from screen_locker._status_projection import compliance_state_word
|
||||
from screen_locker.tests._status_view_helpers import (
|
||||
_lock_explanation,
|
||||
_snapshot,
|
||||
@ -18,7 +16,7 @@ class TestComplianceStateWord:
|
||||
def test_lock_when_fired(self) -> None:
|
||||
"""Lock when fired."""
|
||||
snap = _snapshot(lock_explanation=_lock_explanation(fired=True))
|
||||
assert _compliance_state_word(snap) == "lock"
|
||||
assert compliance_state_word(snap) == "lock"
|
||||
|
||||
def test_warn_when_not_fired_but_remaining(self) -> None:
|
||||
"""Warn when not fired but remaining."""
|
||||
@ -26,7 +24,7 @@ class TestComplianceStateWord:
|
||||
lock_explanation=_lock_explanation(fired=False),
|
||||
week=_week(remaining=2),
|
||||
)
|
||||
assert _compliance_state_word(snap) == "warn"
|
||||
assert compliance_state_word(snap) == "warn"
|
||||
|
||||
def test_ok_when_not_fired_and_minimum_met(self) -> None:
|
||||
"""Ok when not fired and minimum met."""
|
||||
@ -34,4 +32,4 @@ class TestComplianceStateWord:
|
||||
lock_explanation=_lock_explanation(fired=False),
|
||||
week=_week(remaining=0),
|
||||
)
|
||||
assert _compliance_state_word(snap) == "ok"
|
||||
assert compliance_state_word(snap) == "ok"
|
||||
|
||||
144
screen_locker/tests/test_web_payload.py
Normal file
144
screen_locker/tests/test_web_payload.py
Normal file
@ -0,0 +1,144 @@
|
||||
"""Tests for _web_payload: the JSON the UI and the enforcer both read.
|
||||
|
||||
The load-bearing assertion here is that ``gaming.workout_today`` comes from the
|
||||
same credit rule the locker enforces with. steam-backlog-enforcer cuts the
|
||||
gaming budget on that field, so if it ever drifted from
|
||||
``count_day_credits`` the two repos would disagree about what a workout day is.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
import json
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from unittest.mock import patch
|
||||
|
||||
from screen_locker._web_payload import (
|
||||
build_decisions_payload,
|
||||
build_status_payload,
|
||||
workout_credits_today,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
_PKG = "screen_locker._web_payload"
|
||||
|
||||
_SATURDAY = datetime(2026, 8, 29, 21, 0, 0, tzinfo=UTC)
|
||||
|
||||
|
||||
def _entry(wtype: str) -> dict[str, Any]:
|
||||
"""Build a log entry of the given type.
|
||||
|
||||
Args:
|
||||
wtype: The ``workout_data.type`` value.
|
||||
|
||||
Returns:
|
||||
A log entry dict.
|
||||
"""
|
||||
return {"timestamp": "2026-08-29T10:00:00+00:00", "workout_data": {"type": wtype}}
|
||||
|
||||
|
||||
def _with_log(entries: dict[str, list[dict[str, Any]]], tmp_path: Path) -> Any:
|
||||
"""Patch the module's LOG_FILE at a temporary log holding ``entries``.
|
||||
|
||||
Args:
|
||||
entries: Date-keyed log contents.
|
||||
tmp_path: pytest temporary directory.
|
||||
|
||||
Returns:
|
||||
A patch context manager for the module-level LOG_FILE.
|
||||
"""
|
||||
log = tmp_path / "log.json"
|
||||
log.write_text(json.dumps(entries), encoding="utf-8")
|
||||
return patch(f"{_PKG}.LOG_FILE", log)
|
||||
|
||||
|
||||
class TestWorkoutCreditsToday:
|
||||
"""Today's credits come from the enforcement rule, not a second count."""
|
||||
|
||||
def test_no_entries_is_zero(self, tmp_path: Path) -> None:
|
||||
"""An untouched day earns nothing."""
|
||||
with _with_log({}, tmp_path):
|
||||
assert workout_credits_today(now=_SATURDAY) == 0
|
||||
|
||||
def test_a_workout_counts(self, tmp_path: Path) -> None:
|
||||
"""A verified workout today earns a credit."""
|
||||
with _with_log({"2026-08-29": [_entry("phone_verified")]}, tmp_path):
|
||||
assert workout_credits_today(now=_SATURDAY) == 1
|
||||
|
||||
def test_the_stronglifts_pair_still_counts_once(self, tmp_path: Path) -> None:
|
||||
"""Both ingestion paths for one session share today's credit too."""
|
||||
entries = {
|
||||
"2026-08-29": [_entry("phone_verified"), _entry("pc_workout_verified")],
|
||||
}
|
||||
with _with_log(entries, tmp_path):
|
||||
assert workout_credits_today(now=_SATURDAY) == 1
|
||||
|
||||
def test_a_relaxed_day_skip_earns_nothing(self, tmp_path: Path) -> None:
|
||||
"""Dismissing the popup is not a workout — so it is a 6h gaming day."""
|
||||
with _with_log({"2026-08-29": [_entry("relaxed_day_skip")]}, tmp_path):
|
||||
assert workout_credits_today(now=_SATURDAY) == 0
|
||||
|
||||
def test_yesterdays_workout_does_not_count_today(self, tmp_path: Path) -> None:
|
||||
"""The budget is per gaming day, so only today's entries count."""
|
||||
with _with_log({"2026-08-28": [_entry("phone_verified")]}, tmp_path):
|
||||
assert workout_credits_today(now=_SATURDAY) == 0
|
||||
|
||||
def test_non_dict_entries_are_ignored(self, tmp_path: Path) -> None:
|
||||
"""A malformed entry must not crash the endpoint."""
|
||||
log = tmp_path / "log.json"
|
||||
log.write_text(json.dumps({"2026-08-29": ["junk"]}), encoding="utf-8")
|
||||
with patch(f"{_PKG}.LOG_FILE", log):
|
||||
assert workout_credits_today(now=_SATURDAY) == 0
|
||||
|
||||
|
||||
class TestStatusPayload:
|
||||
"""The status payload is JSON-safe and carries the gaming fact."""
|
||||
|
||||
def test_no_workout_today_reads_as_a_cut_budget(self, tmp_path: Path) -> None:
|
||||
"""workout_today False is what makes the enforcer apply 6h."""
|
||||
with _with_log({}, tmp_path):
|
||||
payload = build_status_payload(now=_SATURDAY)
|
||||
assert payload["gaming"]["workout_today"] is False
|
||||
assert "no counted workout" in payload["gaming"]["reason"]
|
||||
|
||||
def test_a_workout_today_reads_as_earned(self, tmp_path: Path) -> None:
|
||||
"""A logged workout restores the full budget."""
|
||||
with _with_log({"2026-08-29": [_entry("phone_verified")]}, tmp_path):
|
||||
payload = build_status_payload(now=_SATURDAY)
|
||||
assert payload["gaming"]["workout_today"] is True
|
||||
assert payload["gaming"]["credits_today"] == 1
|
||||
|
||||
def test_payload_round_trips_through_json(self, tmp_path: Path) -> None:
|
||||
"""No dataclass or datetime may leak into the HTTP body."""
|
||||
with _with_log({}, tmp_path):
|
||||
payload = build_status_payload(now=_SATURDAY)
|
||||
assert json.loads(json.dumps(payload))["summary_line"]
|
||||
|
||||
def test_payload_carries_the_derived_one_liners(self, tmp_path: Path) -> None:
|
||||
"""The i3blocks line and the tray word come from the same snapshot."""
|
||||
with _with_log({}, tmp_path):
|
||||
payload = build_status_payload(now=_SATURDAY)
|
||||
assert payload["compliance_state"] in {"ok", "warn", "lock"}
|
||||
assert "snapshot" in payload
|
||||
|
||||
|
||||
class TestDecisionsPayload:
|
||||
"""The Why view gets a bounded, newest-first slice of the trail."""
|
||||
|
||||
def test_newest_first(self) -> None:
|
||||
"""The browser reads top-down; the trail is stored oldest-first."""
|
||||
trail = [{"timestamp": "1"}, {"timestamp": "2"}, {"timestamp": "3"}]
|
||||
with patch(f"{_PKG}.read_decisions", return_value=trail):
|
||||
payload = build_decisions_payload(limit=2)
|
||||
assert [d["timestamp"] for d in payload["decisions"]] == ["3", "2"]
|
||||
assert payload["total"] == 3
|
||||
assert payload["returned"] == 2
|
||||
|
||||
def test_a_non_positive_limit_returns_everything(self) -> None:
|
||||
"""Guards the slice: limit=0 must not silently mean "no history"."""
|
||||
trail = [{"timestamp": "1"}, {"timestamp": "2"}]
|
||||
with patch(f"{_PKG}.read_decisions", return_value=trail):
|
||||
payload = build_decisions_payload(limit=0)
|
||||
assert payload["returned"] == 2
|
||||
138
screen_locker/tests/test_web_payload_part2.py
Normal file
138
screen_locker/tests/test_web_payload_part2.py
Normal file
@ -0,0 +1,138 @@
|
||||
"""Tests for _web_payload.build_health_payload.
|
||||
|
||||
Split out of test_web_payload.py for the 250-line cap.
|
||||
|
||||
Every field asserted here exists because its absence once went unnoticed: a
|
||||
timer systemd deleted to break an ordering cycle, a disarm marker nobody
|
||||
removed, a log that quietly stopped being written. The rule the tests enforce
|
||||
is that "could not check" is its own state and never reads as a pass.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest.mock import patch
|
||||
|
||||
from screen_locker._armed_state import TimerState
|
||||
from screen_locker._web_payload import build_health_payload
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
_PKG = "screen_locker._web_payload"
|
||||
|
||||
_SATURDAY = datetime(2026, 8, 29, 21, 0, 0, tzinfo=UTC)
|
||||
|
||||
|
||||
class TestHealthPayload:
|
||||
"""Health turns every silent absence into a rendered value."""
|
||||
|
||||
def _states(self, *, armed: bool) -> list[TimerState]:
|
||||
"""Build timer states for the fake.
|
||||
|
||||
Args:
|
||||
armed: Whether the timers should read as armed.
|
||||
|
||||
Returns:
|
||||
One state per required timer.
|
||||
"""
|
||||
return [
|
||||
TimerState("workout-locker.timer", armed, "enabled", armed),
|
||||
]
|
||||
|
||||
def test_armed_when_everything_checks_out(self, tmp_path: Path) -> None:
|
||||
"""Timers armed, no marker, systemctl present."""
|
||||
with (
|
||||
patch(f"{_PKG}.systemctl_available", return_value=True),
|
||||
patch(f"{_PKG}.collect_states", return_value=self._states(armed=True)),
|
||||
patch(f"{_PKG}.DISARM_MARKER", tmp_path / "absent"),
|
||||
):
|
||||
payload = build_health_payload(now=_SATURDAY)
|
||||
assert payload["armed"] is True
|
||||
assert payload["timers"][0]["armed"] is True
|
||||
|
||||
def test_unchecked_is_never_armed(self, tmp_path: Path) -> None:
|
||||
"""Without systemctl the answer is unknown, which is not a pass."""
|
||||
with (
|
||||
patch(f"{_PKG}.systemctl_available", return_value=False),
|
||||
patch(f"{_PKG}.DISARM_MARKER", tmp_path / "absent"),
|
||||
):
|
||||
payload = build_health_payload(now=_SATURDAY)
|
||||
assert payload["timers_checked"] is False
|
||||
assert payload["armed"] is False
|
||||
assert payload["timers"] == []
|
||||
|
||||
def test_disarm_marker_overrides_armed_timers(self, tmp_path: Path) -> None:
|
||||
"""The marker is the deliberate off switch; it must win."""
|
||||
marker = tmp_path / "DISARMED"
|
||||
marker.write_text("off for a reason", encoding="utf-8")
|
||||
with (
|
||||
patch(f"{_PKG}.systemctl_available", return_value=True),
|
||||
patch(f"{_PKG}.collect_states", return_value=self._states(armed=True)),
|
||||
patch(f"{_PKG}.DISARM_MARKER", marker),
|
||||
):
|
||||
payload = build_health_payload(now=_SATURDAY)
|
||||
assert payload["disarmed"] is True
|
||||
assert payload["armed"] is False
|
||||
|
||||
def test_missing_log_reports_unknown_age(self, tmp_path: Path) -> None:
|
||||
"""A log that is not there reads as unknown, not as fresh."""
|
||||
with (
|
||||
patch(f"{_PKG}.systemctl_available", return_value=False),
|
||||
patch(f"{_PKG}.DISARM_MARKER", tmp_path / "absent"),
|
||||
patch(f"{_PKG}.LOG_FILE", tmp_path / "missing.json"),
|
||||
patch(f"{_PKG}.read_decisions", return_value=[]),
|
||||
):
|
||||
payload = build_health_payload(now=_SATURDAY)
|
||||
assert payload["log_age_seconds"] is None
|
||||
assert payload["last_decision_age_seconds"] is None
|
||||
|
||||
def test_decision_age_is_measured_from_the_newest_entry(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""A quiet locker shows up as an old last decision."""
|
||||
trail = [{"timestamp": "2026-08-29T20:00:00+00:00"}]
|
||||
with (
|
||||
patch(f"{_PKG}.systemctl_available", return_value=False),
|
||||
patch(f"{_PKG}.DISARM_MARKER", tmp_path / "absent"),
|
||||
patch(f"{_PKG}.read_decisions", return_value=trail),
|
||||
):
|
||||
payload = build_health_payload(now=_SATURDAY)
|
||||
assert payload["last_decision_age_seconds"] == 3600
|
||||
|
||||
def test_a_corrupt_timestamp_reads_as_unknown(self, tmp_path: Path) -> None:
|
||||
"""An unparsable stamp must not crash the Health view."""
|
||||
with (
|
||||
patch(f"{_PKG}.systemctl_available", return_value=False),
|
||||
patch(f"{_PKG}.DISARM_MARKER", tmp_path / "absent"),
|
||||
patch(f"{_PKG}.read_decisions", return_value=[{"timestamp": "nonsense"}]),
|
||||
):
|
||||
payload = build_health_payload(now=_SATURDAY)
|
||||
assert payload["last_decision_age_seconds"] is None
|
||||
|
||||
def test_a_missing_timestamp_reads_as_unknown(self, tmp_path: Path) -> None:
|
||||
"""A decision line without a timestamp is handled, not assumed."""
|
||||
with (
|
||||
patch(f"{_PKG}.systemctl_available", return_value=False),
|
||||
patch(f"{_PKG}.DISARM_MARKER", tmp_path / "absent"),
|
||||
patch(f"{_PKG}.read_decisions", return_value=[{"reason": "enforced"}]),
|
||||
):
|
||||
payload = build_health_payload(now=_SATURDAY)
|
||||
assert payload["last_decision_age_seconds"] is None
|
||||
|
||||
def test_log_age_is_measured_from_mtime(self, tmp_path: Path) -> None:
|
||||
"""A written log reports a real, non-negative age."""
|
||||
log = tmp_path / "log.json"
|
||||
log.write_text("{}", encoding="utf-8")
|
||||
with (
|
||||
patch(f"{_PKG}.systemctl_available", return_value=False),
|
||||
patch(f"{_PKG}.DISARM_MARKER", tmp_path / "absent"),
|
||||
patch(f"{_PKG}.LOG_FILE", log),
|
||||
patch(f"{_PKG}.read_decisions", return_value=[]),
|
||||
):
|
||||
payload = build_health_payload()
|
||||
age = payload["log_age_seconds"]
|
||||
assert isinstance(age, float)
|
||||
assert age >= 0
|
||||
209
screen_locker/tests/test_web_server.py
Normal file
209
screen_locker/tests/test_web_server.py
Normal file
@ -0,0 +1,209 @@
|
||||
"""Tests for _web_server: routing, static serving, and the loopback guard.
|
||||
|
||||
The server is started on an ephemeral port and driven over real HTTP rather
|
||||
than by calling handler methods directly, so the response codes and content
|
||||
types tested here are the ones a browser and the enforcer actually see.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import contextmanager
|
||||
from http.client import HTTPConnection
|
||||
import json
|
||||
import socket
|
||||
import threading
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from screen_locker._web_server import _decision_limit, create_server, main, serve
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterator
|
||||
from pathlib import Path
|
||||
|
||||
_PKG = "screen_locker._web_server"
|
||||
|
||||
# conftest's autouse _block_real_network patches
|
||||
# "screen_locker._http_workout_fetch.socket.create_connection", and that
|
||||
# attribute path resolves to the *global* socket module -- so every test in the
|
||||
# suite has socket.create_connection stubbed, including loopback connections to
|
||||
# a server we started ourselves. Captured at import time, before any fixture
|
||||
# runs, which is the only moment the real function is still reachable. The
|
||||
# fixture's own docstring says tests needing a real probe patch it locally.
|
||||
_REAL_CREATE_CONNECTION = socket.create_connection
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def real_loopback(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Restore real socket connections for tests that drive the server.
|
||||
|
||||
Args:
|
||||
monkeypatch: pytest's attribute patcher, which undoes this on teardown.
|
||||
"""
|
||||
monkeypatch.setattr(socket, "create_connection", _REAL_CREATE_CONNECTION)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _running() -> Iterator[int]:
|
||||
"""Start the server on an ephemeral port in a thread; yield the port.
|
||||
|
||||
Yields:
|
||||
The bound port.
|
||||
"""
|
||||
server = create_server("127.0.0.1", 0)
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
yield server.server_address[1]
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
thread.join(timeout=2)
|
||||
|
||||
|
||||
def _get(port: int, path: str) -> tuple[int, bytes, str]:
|
||||
"""Make a GET request, returning ``(status, body, content-type)``.
|
||||
|
||||
Args:
|
||||
port: Port to connect to.
|
||||
path: Request path.
|
||||
|
||||
Returns:
|
||||
The status code, body bytes and Content-Type header.
|
||||
"""
|
||||
conn = HTTPConnection("127.0.0.1", port, timeout=5)
|
||||
try:
|
||||
conn.request("GET", path)
|
||||
resp = conn.getresponse()
|
||||
return resp.status, resp.read(), resp.headers.get("Content-Type", "")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _make_dist(tmp_path: Path, *, with_index: bool = True) -> Path:
|
||||
"""Create a fake built-frontend directory.
|
||||
|
||||
Args:
|
||||
tmp_path: pytest temporary directory.
|
||||
with_index: Whether to write an ``index.html``.
|
||||
|
||||
Returns:
|
||||
The resolved dist directory.
|
||||
"""
|
||||
dist = (tmp_path / "dist").resolve()
|
||||
dist.mkdir()
|
||||
if with_index:
|
||||
(dist / "index.html").write_text("<html>INDEX</html>", encoding="utf-8")
|
||||
(dist / "app.css").write_text("body{}", encoding="utf-8")
|
||||
(dist / "icon.png").write_bytes(b"\x89PNG\r\n\x1a\n")
|
||||
return dist
|
||||
|
||||
|
||||
class TestDecisionLimit:
|
||||
"""?limit= is parsed defensively; a bad value never reaches the reader."""
|
||||
|
||||
def test_absent_limit_uses_the_default(self) -> None:
|
||||
"""No query string means the default page size."""
|
||||
assert _decision_limit("") > 0
|
||||
|
||||
def test_explicit_limit_is_honoured(self) -> None:
|
||||
"""A sane limit passes through."""
|
||||
assert _decision_limit("limit=5") == 5
|
||||
|
||||
def test_non_numeric_limit_falls_back(self) -> None:
|
||||
"""Garbage in the query string must not 500 the endpoint."""
|
||||
assert _decision_limit("limit=abc") == _decision_limit("")
|
||||
|
||||
def test_zero_or_negative_limit_falls_back(self) -> None:
|
||||
"""A limit of zero would render an empty history for no reason."""
|
||||
assert _decision_limit("limit=0") == _decision_limit("")
|
||||
|
||||
|
||||
class TestApiRoutes:
|
||||
"""Each API path serves its own payload as JSON."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _network(self, real_loopback: None) -> None:
|
||||
"""Every test in this class talks to the server over real HTTP."""
|
||||
|
||||
def test_status_route(self) -> None:
|
||||
"""/api/status serves the status payload."""
|
||||
with (
|
||||
patch(f"{_PKG}.build_status_payload", return_value={"ok": True}),
|
||||
_running() as port,
|
||||
):
|
||||
status, body, ctype = _get(port, "/api/status")
|
||||
assert status == 200
|
||||
assert json.loads(body) == {"ok": True}
|
||||
assert ctype.startswith("application/json")
|
||||
|
||||
def test_health_route(self) -> None:
|
||||
"""/api/health serves the health payload."""
|
||||
with (
|
||||
patch(f"{_PKG}.build_health_payload", return_value={"armed": False}),
|
||||
_running() as port,
|
||||
):
|
||||
status, body, _ = _get(port, "/api/health")
|
||||
assert status == 200
|
||||
assert json.loads(body) == {"armed": False}
|
||||
|
||||
def test_decisions_route_passes_the_limit_through(self) -> None:
|
||||
"""/api/decisions forwards ?limit= to the payload builder."""
|
||||
with (
|
||||
patch(
|
||||
f"{_PKG}.build_decisions_payload", return_value={"total": 0}
|
||||
) as build,
|
||||
_running() as port,
|
||||
):
|
||||
status, _, _ = _get(port, "/api/decisions?limit=7")
|
||||
assert status == 200
|
||||
build.assert_called_once_with(limit=7)
|
||||
|
||||
def test_a_broken_payload_returns_500_and_says_which(self) -> None:
|
||||
"""A failing builder must never look like an endpoint with no news."""
|
||||
with (
|
||||
patch(f"{_PKG}.build_status_payload", side_effect=OSError("disk gone")),
|
||||
_running() as port,
|
||||
):
|
||||
status, body, _ = _get(port, "/api/status")
|
||||
assert status == 500
|
||||
assert b"status error" in body
|
||||
|
||||
|
||||
class TestBindGuard:
|
||||
"""The API is unauthenticated, so it may only ever bind loopback."""
|
||||
|
||||
@pytest.mark.parametrize("host", ["127.0.0.1", "localhost"])
|
||||
def test_loopback_is_allowed(self, host: str) -> None:
|
||||
"""Both loopback spellings are accepted."""
|
||||
server = create_server(host, 0)
|
||||
server.server_close()
|
||||
|
||||
def test_a_public_bind_is_refused(self) -> None:
|
||||
"""A LAN address would publish the payloads beyond this machine."""
|
||||
with pytest.raises(ValueError, match="loopback-only"):
|
||||
create_server("192.0.2.1", 0)
|
||||
|
||||
|
||||
class TestServeEntryPoints:
|
||||
"""serve() and main() wire the server to the process lifetime."""
|
||||
|
||||
def test_serve_closes_the_socket_on_interrupt(self) -> None:
|
||||
"""Ctrl-C shuts down cleanly rather than leaving the port bound."""
|
||||
server = create_server("127.0.0.1", 0)
|
||||
with (
|
||||
patch(f"{_PKG}.create_server", return_value=server),
|
||||
patch.object(server, "serve_forever", side_effect=KeyboardInterrupt),
|
||||
patch.object(server, "server_close") as close,
|
||||
):
|
||||
serve()
|
||||
close.assert_called_once()
|
||||
server.server_close()
|
||||
|
||||
def test_main_returns_zero(self) -> None:
|
||||
"""The console-script entry point reports success on a clean exit."""
|
||||
with patch(f"{_PKG}.serve") as served:
|
||||
assert main() == 0
|
||||
served.assert_called_once()
|
||||
81
screen_locker/tests/test_web_server_part2.py
Normal file
81
screen_locker/tests/test_web_server_part2.py
Normal file
@ -0,0 +1,81 @@
|
||||
"""Tests for _web_server static serving: SPA fallback and traversal guard.
|
||||
|
||||
Split out of test_web_server.py for the 250-line cap; the server harness and
|
||||
the real_loopback fixture stay in that module and are imported here.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from screen_locker.tests.test_web_server import (
|
||||
_PKG,
|
||||
_get,
|
||||
_make_dist,
|
||||
_running,
|
||||
real_loopback,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
__all__ = ["real_loopback"]
|
||||
|
||||
|
||||
class TestStaticServing:
|
||||
"""The built bundle is served with an SPA fallback and a traversal guard."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _network(self, real_loopback: None) -> None:
|
||||
"""Every test in this class talks to the server over real HTTP."""
|
||||
|
||||
def test_index_is_served_at_the_root(self, tmp_path: Path) -> None:
|
||||
"""/ serves index.html."""
|
||||
with patch(f"{_PKG}.WEB_DIST", _make_dist(tmp_path)), _running() as port:
|
||||
status, body, ctype = _get(port, "/")
|
||||
assert status == 200
|
||||
assert b"INDEX" in body
|
||||
assert ctype.startswith("text/html")
|
||||
|
||||
def test_an_asset_is_served_with_its_own_type(self, tmp_path: Path) -> None:
|
||||
"""A real file is served rather than the SPA fallback."""
|
||||
with patch(f"{_PKG}.WEB_DIST", _make_dist(tmp_path)), _running() as port:
|
||||
status, body, ctype = _get(port, "/app.css")
|
||||
assert status == 200
|
||||
assert body == b"body{}"
|
||||
assert ctype.startswith("text/css")
|
||||
|
||||
def test_a_binary_asset_gets_no_charset(self, tmp_path: Path) -> None:
|
||||
"""A charset on image/png would be wrong; only text types get one."""
|
||||
with patch(f"{_PKG}.WEB_DIST", _make_dist(tmp_path)), _running() as port:
|
||||
status, body, ctype = _get(port, "/icon.png")
|
||||
assert status == 200
|
||||
assert body.startswith(b"\x89PNG")
|
||||
assert ctype == "image/png"
|
||||
|
||||
def test_an_unknown_route_falls_back_to_index(self, tmp_path: Path) -> None:
|
||||
"""SPA routes are handled by the bundle, not by the server."""
|
||||
with patch(f"{_PKG}.WEB_DIST", _make_dist(tmp_path)), _running() as port:
|
||||
status, body, _ = _get(port, "/why/2026-08-29")
|
||||
assert status == 200
|
||||
assert b"INDEX" in body
|
||||
|
||||
def test_traversal_never_escapes_the_bundle(self, tmp_path: Path) -> None:
|
||||
"""A ../ path is refused, not resolved against the filesystem."""
|
||||
secret = tmp_path / "secret.txt"
|
||||
secret.write_text("do not serve me", encoding="utf-8")
|
||||
with patch(f"{_PKG}.WEB_DIST", _make_dist(tmp_path)), _running() as port:
|
||||
status, body, _ = _get(port, "/../secret.txt")
|
||||
assert status == 200
|
||||
assert b"do not serve me" not in body
|
||||
|
||||
def test_missing_bundle_says_how_to_build_it(self, tmp_path: Path) -> None:
|
||||
"""404 carries the build command instead of an empty page."""
|
||||
dist = _make_dist(tmp_path, with_index=False)
|
||||
with patch(f"{_PKG}.WEB_DIST", dist), _running() as port:
|
||||
status, body, _ = _get(port, "/")
|
||||
assert status == 404
|
||||
assert b"npm --prefix web run build" in body
|
||||
@ -28,109 +28,23 @@ Usage:
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
from screen_locker._armed_state import collect_states, systemctl_available
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
# Every unit that must be armed for a workout-less day to end in a lock.
|
||||
# workout-locker.service itself is WantedBy=graphical-session.target and so is
|
||||
# only ever a login one-shot; the timers are what make enforcement recur.
|
||||
REQUIRED_TIMERS = (
|
||||
"early-bird-workout-check.timer",
|
||||
"workout-locker.timer",
|
||||
)
|
||||
|
||||
_TIMEOUT_SECONDS = 10
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TimerState:
|
||||
"""What systemd currently believes about one timer."""
|
||||
|
||||
name: str
|
||||
enabled: bool
|
||||
enabled_raw: str
|
||||
scheduled: bool
|
||||
|
||||
@property
|
||||
def armed(self) -> bool:
|
||||
"""True only when the timer is both enabled and actually scheduled.
|
||||
|
||||
Both halves matter. ``is-enabled`` alone passes for a timer whose job
|
||||
systemd deleted to break an ordering cycle; ``list-timers`` alone passes
|
||||
for a transient ``start``ed timer that will not survive a reboot.
|
||||
"""
|
||||
return self.enabled and self.scheduled
|
||||
|
||||
def describe(self) -> str:
|
||||
"""Return a one-line human summary of this timer's state."""
|
||||
if self.armed:
|
||||
return f"OK {self.name}: enabled and scheduled"
|
||||
problems = []
|
||||
if not self.enabled:
|
||||
problems.append(f"not enabled (is-enabled={self.enabled_raw or 'unknown'})")
|
||||
if not self.scheduled:
|
||||
problems.append("no next trigger in list-timers")
|
||||
return f"DISARMED {self.name}: {', '.join(problems)}"
|
||||
|
||||
|
||||
def _report(message: str) -> None:
|
||||
"""Write one line to stderr, so a disarmed locker is visible in the unit."""
|
||||
"""Write one line to stderr, so a disarmed locker is visible in the unit.
|
||||
|
||||
Args:
|
||||
message: The line to write.
|
||||
"""
|
||||
sys.stderr.write(f"{message}\n")
|
||||
|
||||
|
||||
def _run(args: list[str]) -> tuple[int, str]:
|
||||
"""Run ``args``, returning (returncode, stdout). Never raises."""
|
||||
try:
|
||||
# Explicit argument list, never shell=True.
|
||||
proc = subprocess.run(
|
||||
args,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=_TIMEOUT_SECONDS,
|
||||
check=False,
|
||||
)
|
||||
except OSError, subprocess.SubprocessError:
|
||||
# A check that cannot run must not look like a check that passed.
|
||||
_logger.exception("Could not run %s", " ".join(args))
|
||||
return 1, ""
|
||||
return proc.returncode, proc.stdout
|
||||
|
||||
|
||||
def _scheduled_timers() -> set[str]:
|
||||
"""Return the timer names systemd currently has a next trigger for."""
|
||||
_, out = _run(["systemctl", "--user", "list-timers", "--all", "--no-pager"])
|
||||
scheduled: set[str] = set()
|
||||
for line in out.splitlines():
|
||||
for name in REQUIRED_TIMERS:
|
||||
# A timer with no next trigger prints "n/a" in the NEXT column.
|
||||
if name in line and " n/a " not in f" {line} ":
|
||||
scheduled.add(name)
|
||||
return scheduled
|
||||
|
||||
|
||||
def collect_states() -> list[TimerState]:
|
||||
"""Return the current arming state of every required timer."""
|
||||
scheduled = _scheduled_timers()
|
||||
states = []
|
||||
for name in REQUIRED_TIMERS:
|
||||
code, out = _run(["systemctl", "--user", "is-enabled", name])
|
||||
raw = out.strip()
|
||||
states.append(
|
||||
TimerState(
|
||||
name=name,
|
||||
enabled=code == 0 and raw == "enabled",
|
||||
enabled_raw=raw,
|
||||
scheduled=name in scheduled,
|
||||
)
|
||||
)
|
||||
return states
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
"""Report arming state; return 1 when enforcement would not happen."""
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
@ -141,7 +55,7 @@ def main(argv: list[str] | None = None) -> int:
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
if shutil.which("systemctl") is None:
|
||||
if not systemctl_available():
|
||||
# Reachable in containers/CI. Say so explicitly rather than passing:
|
||||
# "could not check" must never be recorded as "checked and fine".
|
||||
_logger.error("systemctl not found — the arming check could not run")
|
||||
|
||||
32
web/eslint.config.js
Normal file
32
web/eslint.config.js
Normal file
@ -0,0 +1,32 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
// Deliberately stricter than steam-backlog-enforcer/web, which this was copied
|
||||
// from: that one extends only `tseslint.configs.recommended` and so runs no
|
||||
// type-aware rules at all. `strictTypeChecked` needs `projectService`, which is
|
||||
// why tsconfig.test.json exists — test files are outside tsconfig.app.json's
|
||||
// program and would otherwise be unlintable.
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist', 'coverage']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.strictTypeChecked,
|
||||
tseslint.configs.stylisticTypeChecked,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
globals: globals.browser,
|
||||
parserOptions: {
|
||||
projectService: true,
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
},
|
||||
])
|
||||
12
web/index.html
Normal file
12
web/index.html
Normal file
@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Screen Locker — status</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
4046
web/package-lock.json
generated
Normal file
4046
web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
41
web/package.json
Normal file
41
web/package.json
Normal file
@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "screen-locker-web",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"typecheck": "tsc -b --noEmit false --emitDeclarationOnly false",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"coverage": "vitest run --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "19.2.8",
|
||||
"react-dom": "19.2.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "10.0.1",
|
||||
"@testing-library/dom": "10.4.1",
|
||||
"@testing-library/jest-dom": "7.0.1",
|
||||
"@testing-library/react": "16.3.3",
|
||||
"@testing-library/user-event": "14.6.6",
|
||||
"@types/node": "26.4.0",
|
||||
"@types/react": "19.2.18",
|
||||
"@types/react-dom": "19.2.5",
|
||||
"@vitejs/plugin-react": "6.1.1",
|
||||
"@vitest/coverage-v8": "4.1.11",
|
||||
"eslint": "10.9.1",
|
||||
"eslint-plugin-react-hooks": "7.1.1",
|
||||
"eslint-plugin-react-refresh": "0.5.5",
|
||||
"globals": "17.11.0",
|
||||
"jsdom": "30.0.1",
|
||||
"typescript": "6.0.3",
|
||||
"typescript-eslint": "8.68.0",
|
||||
"vite": "8.2.2",
|
||||
"vitest": "4.1.11"
|
||||
}
|
||||
}
|
||||
53
web/src/App.test.tsx
Normal file
53
web/src/App.test.tsx
Normal file
@ -0,0 +1,53 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { App } from './App'
|
||||
import { makeDecisions, makeHealth, makeStatus } from './test/factories'
|
||||
|
||||
function stubEndpoints(overrides: Record<string, unknown> = {}): void {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn((url: string) => {
|
||||
const body =
|
||||
url.startsWith('/api/status')
|
||||
? (overrides.status ?? makeStatus())
|
||||
: url.startsWith('/api/health')
|
||||
? (overrides.health ?? makeHealth())
|
||||
: (overrides.decisions ?? makeDecisions())
|
||||
return Promise.resolve({ ok: true, json: () => Promise.resolve(body) })
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('App', () => {
|
||||
it('renders all three views once the endpoints answer', async () => {
|
||||
stubEndpoints()
|
||||
render(<App />)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('heading', { name: 'Now' })).toBeInTheDocument()
|
||||
})
|
||||
expect(screen.getByRole('heading', { name: 'Health' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('heading', { name: 'Why' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows a loading line before the first status arrives', () => {
|
||||
vi.stubGlobal('fetch', vi.fn(() => new Promise(() => undefined)))
|
||||
render(<App />)
|
||||
expect(screen.getByText('Reading status…')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('surfaces a failing endpoint instead of silently showing nothing', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({ ok: false, status: 503, statusText: 'Unavailable' }),
|
||||
)
|
||||
render(<App />)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('alert')).toBeInTheDocument()
|
||||
})
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('API returned 503')
|
||||
})
|
||||
})
|
||||
43
web/src/App.tsx
Normal file
43
web/src/App.tsx
Normal file
@ -0,0 +1,43 @@
|
||||
import { useCallback } from 'react'
|
||||
import { HealthCard } from './components/HealthCard'
|
||||
import { NowCard } from './components/NowCard'
|
||||
import { WhyTable } from './components/WhyTable'
|
||||
import { fetchDecisions, fetchHealth, fetchStatus } from './api'
|
||||
import { usePoll } from './useStatusPoll'
|
||||
|
||||
const DECISION_LIMIT = 200
|
||||
|
||||
export function App() {
|
||||
// Stable identities: usePoll re-subscribes whenever the fetcher changes, and
|
||||
// an inline arrow would restart every interval on each render.
|
||||
const decisionFetcher = useCallback(() => fetchDecisions(DECISION_LIMIT), [])
|
||||
const status = usePoll(fetchStatus)
|
||||
const decisions = usePoll(decisionFetcher)
|
||||
const health = usePoll(fetchHealth)
|
||||
|
||||
const errors = [status.error, decisions.error, health.error].filter(
|
||||
(e): e is string => e !== null,
|
||||
)
|
||||
|
||||
return (
|
||||
<main>
|
||||
<h1>Screen locker</h1>
|
||||
{errors.length > 0 && (
|
||||
<div className="error" role="alert">
|
||||
{/* Shown rather than swallowed: a UI that silently keeps rendering
|
||||
stale data is the same failure the locker itself had. */}
|
||||
{errors.map((message) => (
|
||||
<div key={message}>{message}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{status.data === null ? (
|
||||
<p className="muted">Reading status…</p>
|
||||
) : (
|
||||
<NowCard status={status.data} />
|
||||
)}
|
||||
{health.data !== null && <HealthCard health={health.data} />}
|
||||
{decisions.data !== null && <WhyTable decisions={decisions.data} />}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
43
web/src/api.test.ts
Normal file
43
web/src/api.test.ts
Normal file
@ -0,0 +1,43 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { fetchDecisions, fetchHealth, fetchStatus } from './api'
|
||||
|
||||
function mockFetch(response: Partial<Response>): void {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(response))
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('api', () => {
|
||||
it('returns the parsed body for a 200', async () => {
|
||||
mockFetch({ ok: true, json: () => Promise.resolve({ total: 1 }) })
|
||||
await expect(fetchDecisions(5)).resolves.toStrictEqual({ total: 1 })
|
||||
})
|
||||
|
||||
it('passes the limit through in the query string', async () => {
|
||||
const spy = vi.fn().mockResolvedValue({ ok: true, json: () => Promise.resolve({}) })
|
||||
vi.stubGlobal('fetch', spy)
|
||||
await fetchDecisions(42)
|
||||
expect(spy).toHaveBeenCalledWith('/api/decisions?limit=42')
|
||||
})
|
||||
|
||||
it('throws with the status text on a failure, rather than returning empty', async () => {
|
||||
mockFetch({ ok: false, status: 500, statusText: 'Internal Server Error' })
|
||||
await expect(fetchStatus()).rejects.toThrow('API returned 500 Internal Server Error')
|
||||
})
|
||||
|
||||
it('requests the health endpoint', async () => {
|
||||
const spy = vi.fn().mockResolvedValue({ ok: true, json: () => Promise.resolve({}) })
|
||||
vi.stubGlobal('fetch', spy)
|
||||
await fetchHealth()
|
||||
expect(spy).toHaveBeenCalledWith('/api/health')
|
||||
})
|
||||
|
||||
it('requests the status endpoint', async () => {
|
||||
const spy = vi.fn().mockResolvedValue({ ok: true, json: () => Promise.resolve({}) })
|
||||
vi.stubGlobal('fetch', spy)
|
||||
await fetchStatus()
|
||||
expect(spy).toHaveBeenCalledWith('/api/status')
|
||||
})
|
||||
})
|
||||
21
web/src/api.ts
Normal file
21
web/src/api.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import type { DecisionsPayload, HealthPayload, StatusPayload } from './types'
|
||||
|
||||
async function getJson<T>(path: string): Promise<T> {
|
||||
const resp = await fetch(path)
|
||||
if (!resp.ok) {
|
||||
throw new Error(`API returned ${String(resp.status)} ${resp.statusText}`)
|
||||
}
|
||||
return (await resp.json()) as T
|
||||
}
|
||||
|
||||
export function fetchStatus(): Promise<StatusPayload> {
|
||||
return getJson<StatusPayload>('/api/status')
|
||||
}
|
||||
|
||||
export function fetchDecisions(limit: number): Promise<DecisionsPayload> {
|
||||
return getJson<DecisionsPayload>(`/api/decisions?limit=${String(limit)}`)
|
||||
}
|
||||
|
||||
export function fetchHealth(): Promise<HealthPayload> {
|
||||
return getJson<HealthPayload>('/api/health')
|
||||
}
|
||||
61
web/src/components/HealthCard.test.tsx
Normal file
61
web/src/components/HealthCard.test.tsx
Normal file
@ -0,0 +1,61 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { HealthCard } from './HealthCard'
|
||||
import { makeHealth } from '../test/factories'
|
||||
|
||||
describe('HealthCard', () => {
|
||||
it('reports an armed locker', () => {
|
||||
render(<HealthCard health={makeHealth()} />)
|
||||
expect(screen.getByText('armed')).toBeInTheDocument()
|
||||
expect(screen.getByText('enabled and scheduled')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('says an unchecked arming state is NOT a pass', () => {
|
||||
render(
|
||||
<HealthCard
|
||||
health={makeHealth({ timers_checked: false, timers: [], armed: false })}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText(/This is not a pass/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the disarm marker when enforcement is switched off', () => {
|
||||
render(<HealthCard health={makeHealth({ disarmed: true, armed: false })} />)
|
||||
expect(screen.getByText(/present at/)).toBeInTheDocument()
|
||||
expect(screen.getByText('NOT armed')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows a disarmed timer with its own reason', () => {
|
||||
render(
|
||||
<HealthCard
|
||||
health={makeHealth({
|
||||
armed: false,
|
||||
timers: [
|
||||
{
|
||||
name: 'workout-locker.timer',
|
||||
enabled: false,
|
||||
scheduled: false,
|
||||
armed: false,
|
||||
describe: 'DISARMED workout-locker.timer: not enabled',
|
||||
},
|
||||
],
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
expect(
|
||||
screen.getByText('DISARMED workout-locker.timer: not enabled'),
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders unknown ages rather than hiding them', () => {
|
||||
render(
|
||||
<HealthCard
|
||||
health={makeHealth({
|
||||
log_age_seconds: null,
|
||||
last_decision_age_seconds: null,
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getAllByText('unknown ago')).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
56
web/src/components/HealthCard.tsx
Normal file
56
web/src/components/HealthCard.tsx
Normal file
@ -0,0 +1,56 @@
|
||||
import { formatAge } from '../format'
|
||||
import type { HealthPayload } from '../types'
|
||||
|
||||
interface Props {
|
||||
readonly health: HealthPayload
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the locker could actually fire.
|
||||
*
|
||||
* Every row here is something that once went wrong silently: a timer systemd
|
||||
* deleted to break an ordering cycle, a disarm marker nobody removed, a log
|
||||
* that stopped being written. Absences are rendered as values so they cannot
|
||||
* be mistaken for good news.
|
||||
*/
|
||||
export function HealthCard({ health }: Props) {
|
||||
const armedClass = health.armed ? 'state-ok' : 'state-lock'
|
||||
return (
|
||||
<section className="card" aria-labelledby="health-heading">
|
||||
<h2 id="health-heading">Health</h2>
|
||||
{!health.timers_checked && (
|
||||
<p className="state-warn">
|
||||
{'systemctl unavailable — arming was NOT checked. This is not a pass.'}
|
||||
</p>
|
||||
)}
|
||||
<div className="row">
|
||||
<span>Enforcement</span>
|
||||
<span className={armedClass}>{health.armed ? 'armed' : 'NOT armed'}</span>
|
||||
</div>
|
||||
{health.disarmed && (
|
||||
<div className="row">
|
||||
<span>Disarm marker</span>
|
||||
<span className="state-lock">{`present at ${health.disarm_marker}`}</span>
|
||||
</div>
|
||||
)}
|
||||
{health.timers.map((timer) => (
|
||||
<div className="row" key={timer.name}>
|
||||
<span>{timer.name}</span>
|
||||
<span className={timer.armed ? 'state-ok' : 'state-lock'}>
|
||||
{timer.armed ? 'enabled and scheduled' : timer.describe}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="row">
|
||||
<span>Workout log written</span>
|
||||
<span className="muted">{`${formatAge(health.log_age_seconds)} ago`}</span>
|
||||
</div>
|
||||
<div className="row">
|
||||
<span>Last decision recorded</span>
|
||||
<span className="muted">
|
||||
{`${formatAge(health.last_decision_age_seconds)} ago`}
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
92
web/src/components/NowCard.test.tsx
Normal file
92
web/src/components/NowCard.test.tsx
Normal file
@ -0,0 +1,92 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { NowCard } from './NowCard'
|
||||
import { makeStatus } from '../test/factories'
|
||||
|
||||
describe('NowCard', () => {
|
||||
it('shows the cut budget and says why when no workout was logged', () => {
|
||||
render(<NowCard status={makeStatus()} />)
|
||||
expect(screen.getByText('No lock')).toBeInTheDocument()
|
||||
expect(screen.getByText('6h')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText('cut from 8h — no workout logged today'),
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the earned budget once a workout is logged', () => {
|
||||
const status = makeStatus({
|
||||
gaming: {
|
||||
workout_today: true,
|
||||
credits_today: 1,
|
||||
reason: '1 counted workout(s) logged today',
|
||||
},
|
||||
})
|
||||
render(<NowCard status={status} />)
|
||||
expect(screen.getByText('8h')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText('earned by a workout logged today'),
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('says the lock would fire when it would', () => {
|
||||
const base = makeStatus()
|
||||
render(
|
||||
<NowCard
|
||||
status={{
|
||||
...base,
|
||||
compliance_state: 'lock',
|
||||
snapshot: {
|
||||
...base.snapshot,
|
||||
lock_explanation: {
|
||||
...base.snapshot.lock_explanation,
|
||||
fired: true,
|
||||
stage: 'full_lock_pending_heat_check',
|
||||
reason: 'No skip condition applies.',
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText('Lock would fire')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('counts down the remaining workouts', () => {
|
||||
render(<NowCard status={makeStatus()} />)
|
||||
expect(screen.getByText(/3\/5 workouts — 2 to go/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('drops the countdown once the week is met', () => {
|
||||
const base = makeStatus()
|
||||
render(
|
||||
<NowCard
|
||||
status={{
|
||||
...base,
|
||||
snapshot: {
|
||||
...base.snapshot,
|
||||
week: { ...base.snapshot.week, counted_count: 5, remaining: 0 },
|
||||
},
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText(/5\/5 workouts$/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('flags degraded sources rather than rendering a confident answer', () => {
|
||||
const base = makeStatus()
|
||||
render(
|
||||
<NowCard
|
||||
status={{
|
||||
...base,
|
||||
snapshot: {
|
||||
...base.snapshot,
|
||||
lock_explanation: {
|
||||
...base.snapshot.lock_explanation,
|
||||
sources_degraded: true,
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText(/degraded/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
58
web/src/components/NowCard.tsx
Normal file
58
web/src/components/NowCard.tsx
Normal file
@ -0,0 +1,58 @@
|
||||
import { budgetHours } from '../format'
|
||||
import type { StatusPayload } from '../types'
|
||||
|
||||
interface Props {
|
||||
readonly status: StatusPayload
|
||||
}
|
||||
|
||||
/** The decision as it stands right now, in words rather than slugs. */
|
||||
export function NowCard({ status }: Props) {
|
||||
const { snapshot, gaming, compliance_state: state } = status
|
||||
const { week, lock_explanation: lock } = snapshot
|
||||
const hours = budgetHours(gaming.workout_today)
|
||||
|
||||
return (
|
||||
<section className="card" aria-labelledby="now-heading">
|
||||
<h2 id="now-heading">Now</h2>
|
||||
<p className={`headline state-${state}`}>
|
||||
{lock.fired ? 'Lock would fire' : 'No lock'}
|
||||
</p>
|
||||
<p className="muted">{lock.reason}</p>
|
||||
|
||||
<div className="row">
|
||||
<span>This week</span>
|
||||
<span>
|
||||
{week.counted_count}/{week.minimum} workouts
|
||||
{week.remaining > 0 ? ` — ${String(week.remaining)} to go` : ''}
|
||||
</span>
|
||||
</div>
|
||||
<div className="row">
|
||||
<span>Today</span>
|
||||
<span>{gaming.reason}</span>
|
||||
</div>
|
||||
<div className="row">
|
||||
<span>Gaming budget today</span>
|
||||
<span>
|
||||
<span className={gaming.workout_today ? 'pill pill-ok' : 'pill pill-lock'}>
|
||||
{hours}h
|
||||
</span>{' '}
|
||||
<span className="muted">
|
||||
{gaming.workout_today
|
||||
? 'earned by a workout logged today'
|
||||
: 'cut from 8h — no workout logged today'}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="row">
|
||||
<span>Decision stage</span>
|
||||
<span className="muted">{lock.stage}</span>
|
||||
</div>
|
||||
{lock.sources_degraded && (
|
||||
<div className="row">
|
||||
<span>Data sources</span>
|
||||
<span className="state-warn">degraded — some inputs unreadable</span>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
79
web/src/components/WhyTable.test.tsx
Normal file
79
web/src/components/WhyTable.test.tsx
Normal file
@ -0,0 +1,79 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { WhyTable } from './WhyTable'
|
||||
import { makeDecisions } from '../test/factories'
|
||||
|
||||
describe('WhyTable', () => {
|
||||
it('renders the decision that hid the 2026-08-29 bug', () => {
|
||||
render(<WhyTable decisions={makeDecisions()} />)
|
||||
expect(
|
||||
screen.getByText('Skipped — weekly minimum already met'),
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByText('5/5')).toBeInTheDocument()
|
||||
expect(screen.getByText('also: early_bird_window_active')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('says how much of the trail is shown', () => {
|
||||
render(<WhyTable decisions={makeDecisions()} />)
|
||||
expect(screen.getByText(/Showing 1 of 3000/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('marks a decision that actually locked', () => {
|
||||
render(
|
||||
<WhyTable
|
||||
decisions={makeDecisions({
|
||||
decisions: [
|
||||
{
|
||||
timestamp: '2026-08-30T20:00:00+00:00',
|
||||
locked: true,
|
||||
reason: 'enforced',
|
||||
},
|
||||
],
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText('locked')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders a no-decision run as a dash with no week column', () => {
|
||||
render(
|
||||
<WhyTable
|
||||
decisions={makeDecisions({
|
||||
decisions: [
|
||||
{
|
||||
timestamp: '2026-08-29T20:00:05+00:00',
|
||||
locked: null,
|
||||
reason: 'mode_makes_no_decision',
|
||||
mode: '--sync-only',
|
||||
},
|
||||
],
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText('—')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText('No decision (status or sync-only run)'),
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('omits the also line when there is none', () => {
|
||||
render(
|
||||
<WhyTable
|
||||
decisions={makeDecisions({
|
||||
decisions: [
|
||||
{
|
||||
timestamp: '2026-08-30T20:00:00+00:00',
|
||||
locked: false,
|
||||
reason: 'relaxed_day',
|
||||
also: '',
|
||||
weekly_count: 2,
|
||||
},
|
||||
],
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
expect(screen.queryByText(/^also:/)).not.toBeInTheDocument()
|
||||
// weekly_required missing falls back to 0 rather than rendering undefined.
|
||||
expect(screen.getByText('2/0')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
62
web/src/components/WhyTable.tsx
Normal file
62
web/src/components/WhyTable.tsx
Normal file
@ -0,0 +1,62 @@
|
||||
import { describeReason, formatStamp } from '../format'
|
||||
import type { DecisionsPayload } from '../types'
|
||||
|
||||
interface Props {
|
||||
readonly decisions: DecisionsPayload
|
||||
}
|
||||
|
||||
/**
|
||||
* The decision history.
|
||||
*
|
||||
* This is the view that would have surfaced the 2026-08-29 bug without anyone
|
||||
* reading the journal: every half-hourly tick recorded
|
||||
* `weekly_minimum_met 5/5` while the real total was three.
|
||||
*/
|
||||
export function WhyTable({ decisions }: Props) {
|
||||
return (
|
||||
<section className="card" aria-labelledby="why-heading">
|
||||
<h2 id="why-heading">Why</h2>
|
||||
<p className="muted">
|
||||
Showing {decisions.returned} of {decisions.total} recorded decisions,
|
||||
newest first.
|
||||
</p>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">When</th>
|
||||
<th scope="col">Outcome</th>
|
||||
<th scope="col">Reason</th>
|
||||
<th scope="col">Week</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{decisions.decisions.map((decision, index) => (
|
||||
<tr key={`${decision.timestamp}-${String(index)}`}>
|
||||
<td>{formatStamp(decision.timestamp)}</td>
|
||||
<td className={decision.locked === true ? 'state-lock' : 'muted'}>
|
||||
{decision.locked === null
|
||||
? '—'
|
||||
: decision.locked
|
||||
? 'locked'
|
||||
: 'no lock'}
|
||||
</td>
|
||||
<td>
|
||||
{describeReason(decision.reason)}
|
||||
{decision.also !== undefined && decision.also !== '' && (
|
||||
<div className="muted">also: {decision.also}</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="muted">
|
||||
{decision.weekly_count === undefined
|
||||
? ''
|
||||
: `${String(decision.weekly_count)}/${String(
|
||||
decision.weekly_required ?? 0,
|
||||
)}`}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
53
web/src/format.test.ts
Normal file
53
web/src/format.test.ts
Normal file
@ -0,0 +1,53 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { budgetHours, describeReason, formatAge, formatStamp } from './format'
|
||||
|
||||
describe('formatAge', () => {
|
||||
it('reports a missing age as unknown, never as fresh', () => {
|
||||
expect(formatAge(null)).toBe('unknown')
|
||||
})
|
||||
|
||||
it('handles a clock skew rather than printing a negative age', () => {
|
||||
expect(formatAge(-5)).toBe('in the future')
|
||||
})
|
||||
|
||||
it.each([
|
||||
[30, '30s'],
|
||||
[90, '1m'],
|
||||
[3600, '1h'],
|
||||
[90000, '1d'],
|
||||
])('renders %i seconds as %s', (seconds, expected) => {
|
||||
expect(formatAge(seconds)).toBe(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatStamp', () => {
|
||||
it('renders a valid ISO timestamp', () => {
|
||||
expect(formatStamp('2026-08-29T20:01:02+00:00')).toMatch(/Aug/)
|
||||
})
|
||||
|
||||
it('passes an unparseable value through unchanged', () => {
|
||||
expect(formatStamp('not a date')).toBe('not a date')
|
||||
})
|
||||
})
|
||||
|
||||
describe('describeReason', () => {
|
||||
it('spells out a known slug', () => {
|
||||
expect(describeReason('weekly_minimum_met')).toBe(
|
||||
'Skipped — weekly minimum already met',
|
||||
)
|
||||
})
|
||||
|
||||
it('degrades gracefully for a slug added on the Python side later', () => {
|
||||
expect(describeReason('some_new_reason')).toBe('some new reason')
|
||||
})
|
||||
})
|
||||
|
||||
describe('budgetHours', () => {
|
||||
it('gives 8h when a workout was logged today', () => {
|
||||
expect(budgetHours(true)).toBe(8)
|
||||
})
|
||||
|
||||
it('cuts to 6h when none was', () => {
|
||||
expect(budgetHours(false)).toBe(6)
|
||||
})
|
||||
})
|
||||
64
web/src/format.ts
Normal file
64
web/src/format.ts
Normal file
@ -0,0 +1,64 @@
|
||||
/** Pure formatting helpers, shared by the three views. */
|
||||
|
||||
const MINUTE = 60
|
||||
const HOUR = 60 * MINUTE
|
||||
const DAY = 24 * HOUR
|
||||
|
||||
/**
|
||||
* Render a duration in seconds as a short human age ("3m", "5h", "2d").
|
||||
*
|
||||
* Returns "unknown" for null so a missing timestamp can never be mistaken for
|
||||
* a fresh one — the whole point of the Health view is that silence is visible.
|
||||
*/
|
||||
export function formatAge(seconds: number | null): string {
|
||||
if (seconds === null) return 'unknown'
|
||||
if (seconds < 0) return 'in the future'
|
||||
if (seconds < MINUTE) return `${String(Math.floor(seconds))}s`
|
||||
if (seconds < HOUR) return `${String(Math.floor(seconds / MINUTE))}m`
|
||||
if (seconds < DAY) return `${String(Math.floor(seconds / HOUR))}h`
|
||||
return `${String(Math.floor(seconds / DAY))}d`
|
||||
}
|
||||
|
||||
/** Render an ISO-8601 timestamp as a local "MMM DD HH:MM" stamp. */
|
||||
export function formatStamp(iso: string): string {
|
||||
const when = new Date(iso)
|
||||
if (Number.isNaN(when.getTime())) return iso
|
||||
return when.toLocaleString(undefined, {
|
||||
month: 'short',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn a decision reason slug into a sentence.
|
||||
*
|
||||
* Falls back to the slug with underscores stripped, so a reason added to the
|
||||
* Python side later still reads sensibly here instead of rendering blank.
|
||||
*/
|
||||
export function describeReason(reason: string): string {
|
||||
const known: Record<string, string> = {
|
||||
enforced: 'Locked — no skip condition applied',
|
||||
weekly_minimum_met: 'Skipped — weekly minimum already met',
|
||||
scheduled_skip_day: 'Skipped — this date was scheduled off',
|
||||
relaxed_day: 'Relaxed day (Tue–Thu) — lock optional',
|
||||
relaxed_day_already_skipped: 'Skipped — relaxed day already dismissed',
|
||||
workout_logged_today: 'Skipped — a workout was logged today',
|
||||
early_bird_window_active: 'Skipped — inside the early-bird window',
|
||||
early_bird_banked: 'Skipped — early-bird marker banked',
|
||||
early_bird_auto_upgraded: 'Skipped — early-bird workout confirmed',
|
||||
sick_day: 'Skipped — sick day',
|
||||
sick_day_auto_upgraded: 'Skipped — sick day, workout confirmed',
|
||||
wake_alarm_skip: 'Skipped — earned by the wake alarm',
|
||||
heat_skip: 'Skipped — too hot',
|
||||
no_sick_day_to_verify: 'Nothing to verify',
|
||||
mode_makes_no_decision: 'No decision (status or sync-only run)',
|
||||
}
|
||||
return known[reason] ?? reason.replace(/_/g, ' ')
|
||||
}
|
||||
|
||||
/** Budget hours the enforcer will apply, given whether today has a workout. */
|
||||
export function budgetHours(workoutToday: boolean): number {
|
||||
return workoutToday ? 8 : 6
|
||||
}
|
||||
133
web/src/index.css
Normal file
133
web/src/index.css
Normal file
@ -0,0 +1,133 @@
|
||||
/*
|
||||
* Tokens from ~/utils/unified-design-system (DOCS-tokens.md). Values are copied
|
||||
* rather than imported because that system ships as documentation plus
|
||||
* per-stack generators, not an npm package -- keep these in step with it rather
|
||||
* than inventing new ones.
|
||||
*/
|
||||
:root {
|
||||
--ink: #211d1b;
|
||||
--ink-raised-1: #2b2624;
|
||||
--ink-raised-2: #38312e;
|
||||
--line-dark: #463e3a;
|
||||
--text-on-dark: #eceae9;
|
||||
--muted-on-dark: #aaa09a;
|
||||
|
||||
--accent: #b8862e;
|
||||
--success: #8a9a3c;
|
||||
--warning: #e0a63c;
|
||||
--danger: #e2585f;
|
||||
|
||||
--space-1: 4px;
|
||||
--space-2: 8px;
|
||||
--space-3: 16px;
|
||||
--space-4: 24px;
|
||||
--space-5: 32px;
|
||||
|
||||
--radius: 8px;
|
||||
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--ink);
|
||||
color: var(--text-on-dark);
|
||||
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
font-size: 16px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
main {
|
||||
margin: 0 auto;
|
||||
max-width: 900px;
|
||||
padding: var(--space-4) var(--space-3);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 24px;
|
||||
letter-spacing: -0.1px;
|
||||
margin: 0 0 var(--space-4);
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 20px;
|
||||
margin: 0 0 var(--space-3);
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--ink-raised-1);
|
||||
border: 1px solid var(--line-dark);
|
||||
border-radius: var(--radius);
|
||||
margin-bottom: var(--space-4);
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
.headline {
|
||||
font-size: 32px;
|
||||
letter-spacing: -0.2px;
|
||||
margin: 0 0 var(--space-2);
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--muted-on-dark);
|
||||
}
|
||||
|
||||
.state-ok { color: var(--success); }
|
||||
.state-warn { color: var(--warning); }
|
||||
.state-lock { color: var(--danger); }
|
||||
|
||||
.row {
|
||||
border-top: 1px solid var(--line-dark);
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
justify-content: space-between;
|
||||
padding: var(--space-2) 0;
|
||||
}
|
||||
|
||||
.row:first-of-type {
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
font-size: 14px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
th {
|
||||
color: var(--muted-on-dark);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.05em;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
border-bottom: 1px solid var(--line-dark);
|
||||
padding: var(--space-2) var(--space-2) var(--space-2) 0;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.pill {
|
||||
background: var(--ink-raised-2);
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.1em;
|
||||
padding: var(--space-1) var(--space-2);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pill-lock { background: var(--danger); color: var(--ink); }
|
||||
.pill-ok { background: var(--success); color: var(--ink); }
|
||||
|
||||
.error {
|
||||
border: 1px solid var(--danger);
|
||||
border-radius: var(--radius);
|
||||
color: var(--danger);
|
||||
margin-bottom: var(--space-4);
|
||||
padding: var(--space-3);
|
||||
}
|
||||
12
web/src/main.tsx
Normal file
12
web/src/main.tsx
Normal file
@ -0,0 +1,12 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { App } from './App'
|
||||
import './index.css'
|
||||
|
||||
const root = document.getElementById('root')
|
||||
if (root === null) throw new Error('#root is missing from index.html')
|
||||
createRoot(root).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
88
web/src/test/factories.ts
Normal file
88
web/src/test/factories.ts
Normal file
@ -0,0 +1,88 @@
|
||||
import type {
|
||||
DecisionsPayload,
|
||||
HealthPayload,
|
||||
StatusPayload,
|
||||
} from '../types'
|
||||
|
||||
export function makeStatus(over: Partial<StatusPayload> = {}): StatusPayload {
|
||||
return {
|
||||
snapshot: {
|
||||
today: {
|
||||
date: '2026-08-29',
|
||||
label: 'Sat Aug 29',
|
||||
entry_types: [],
|
||||
source: '',
|
||||
counted: false,
|
||||
day_count: 0,
|
||||
is_sick_day: false,
|
||||
},
|
||||
week: {
|
||||
days: [],
|
||||
counted_count: 3,
|
||||
minimum: 5,
|
||||
remaining: 2,
|
||||
extra: 0,
|
||||
},
|
||||
lock_explanation: {
|
||||
fired: false,
|
||||
stage: 'scheduled_skip',
|
||||
reason: 'Manually scheduled skip day.',
|
||||
trace: [],
|
||||
heat_skip_evaluated: false,
|
||||
sources_degraded: false,
|
||||
},
|
||||
generated_at: '2026-08-29T20:30:00+00:00',
|
||||
},
|
||||
summary_line: '… 3/5 workouts',
|
||||
compliance_state: 'warn',
|
||||
gaming: {
|
||||
workout_today: false,
|
||||
credits_today: 0,
|
||||
reason: 'no counted workout logged today',
|
||||
},
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
export function makeHealth(over: Partial<HealthPayload> = {}): HealthPayload {
|
||||
return {
|
||||
timers_checked: true,
|
||||
timers: [
|
||||
{
|
||||
name: 'workout-locker.timer',
|
||||
enabled: true,
|
||||
scheduled: true,
|
||||
armed: true,
|
||||
describe: 'OK workout-locker.timer: enabled and scheduled',
|
||||
},
|
||||
],
|
||||
armed: true,
|
||||
disarmed: false,
|
||||
disarm_marker: '/home/kuhy/.local/share/screen_locker/DISARMED',
|
||||
log_file: '/home/kuhy/screen-locker/screen_locker/log.json',
|
||||
log_age_seconds: 3600,
|
||||
decision_log_file: '/home/kuhy/.local/share/screen_locker/decisions.jsonl',
|
||||
last_decision_age_seconds: 120,
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
export function makeDecisions(
|
||||
over: Partial<DecisionsPayload> = {},
|
||||
): DecisionsPayload {
|
||||
return {
|
||||
total: 3000,
|
||||
returned: 1,
|
||||
decisions: [
|
||||
{
|
||||
timestamp: '2026-08-29T20:01:02+00:00',
|
||||
locked: false,
|
||||
reason: 'weekly_minimum_met',
|
||||
weekly_count: 5,
|
||||
weekly_required: 5,
|
||||
also: 'early_bird_window_active',
|
||||
},
|
||||
],
|
||||
...over,
|
||||
}
|
||||
}
|
||||
8
web/src/test/setup.ts
Normal file
8
web/src/test/setup.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
import { cleanup } from '@testing-library/react'
|
||||
import { afterEach } from 'vitest'
|
||||
|
||||
// React Testing Library does not auto-clean when Vitest globals are disabled.
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
})
|
||||
102
web/src/types.ts
Normal file
102
web/src/types.ts
Normal file
@ -0,0 +1,102 @@
|
||||
/** Shapes served by screen_locker._web_payload. */
|
||||
|
||||
/** One predicate the lock decision walked past, and whether it fired. */
|
||||
export interface TraceStep {
|
||||
readonly name: string
|
||||
readonly fired: boolean
|
||||
readonly reason: string
|
||||
}
|
||||
|
||||
/** Why the locker would or would not fire right now. */
|
||||
export interface LockExplanation {
|
||||
readonly fired: boolean
|
||||
readonly stage: string
|
||||
readonly reason: string
|
||||
readonly trace: readonly TraceStep[]
|
||||
readonly heat_skip_evaluated: boolean
|
||||
readonly sources_degraded: boolean
|
||||
}
|
||||
|
||||
/** One day of this ISO week. */
|
||||
export interface DayStatus {
|
||||
readonly date: string
|
||||
readonly label: string
|
||||
readonly entry_types: readonly string[]
|
||||
readonly source: string
|
||||
readonly counted: boolean
|
||||
readonly day_count: number
|
||||
readonly is_sick_day: boolean
|
||||
}
|
||||
|
||||
/** This ISO week against the weekly minimum. */
|
||||
export interface WeeklySummary {
|
||||
readonly days: readonly DayStatus[]
|
||||
readonly counted_count: number
|
||||
readonly minimum: number
|
||||
readonly remaining: number
|
||||
readonly extra: number
|
||||
}
|
||||
|
||||
/** The parts of the status snapshot this UI renders. */
|
||||
export interface StatusSnapshot {
|
||||
readonly today: DayStatus
|
||||
readonly week: WeeklySummary
|
||||
readonly lock_explanation: LockExplanation
|
||||
readonly generated_at: string
|
||||
}
|
||||
|
||||
/** The fact the gaming budget turns on. */
|
||||
export interface GamingFact {
|
||||
readonly workout_today: boolean
|
||||
readonly credits_today: number
|
||||
readonly reason: string
|
||||
}
|
||||
|
||||
/** GET /api/status. */
|
||||
export interface StatusPayload {
|
||||
readonly snapshot: StatusSnapshot
|
||||
readonly summary_line: string
|
||||
readonly compliance_state: string
|
||||
readonly gaming: GamingFact
|
||||
}
|
||||
|
||||
/** One recorded decision from decisions.jsonl. */
|
||||
export interface Decision {
|
||||
readonly timestamp: string
|
||||
readonly locked: boolean | null
|
||||
readonly reason: string
|
||||
readonly detail?: string
|
||||
readonly weekly_count?: number
|
||||
readonly weekly_required?: number
|
||||
readonly also?: string
|
||||
readonly mode?: string
|
||||
}
|
||||
|
||||
/** GET /api/decisions. */
|
||||
export interface DecisionsPayload {
|
||||
readonly total: number
|
||||
readonly returned: number
|
||||
readonly decisions: readonly Decision[]
|
||||
}
|
||||
|
||||
/** One systemd timer's arming state. */
|
||||
export interface TimerHealth {
|
||||
readonly name: string
|
||||
readonly enabled: boolean
|
||||
readonly scheduled: boolean
|
||||
readonly armed: boolean
|
||||
readonly describe: string
|
||||
}
|
||||
|
||||
/** GET /api/health. */
|
||||
export interface HealthPayload {
|
||||
readonly timers_checked: boolean
|
||||
readonly timers: readonly TimerHealth[]
|
||||
readonly armed: boolean
|
||||
readonly disarmed: boolean
|
||||
readonly disarm_marker: string
|
||||
readonly log_file: string
|
||||
readonly log_age_seconds: number | null
|
||||
readonly decision_log_file: string
|
||||
readonly last_decision_age_seconds: number | null
|
||||
}
|
||||
92
web/src/useStatusPoll.test.ts
Normal file
92
web/src/useStatusPoll.test.ts
Normal file
@ -0,0 +1,92 @@
|
||||
import { renderHook, waitFor } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { POLL_MS, usePoll } from './useStatusPoll'
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('usePoll', () => {
|
||||
it('loads immediately rather than waiting for the first interval', async () => {
|
||||
const fetcher = vi.fn().mockResolvedValue('value')
|
||||
const { result } = renderHook(() => usePoll(fetcher))
|
||||
await waitFor(() => {
|
||||
expect(result.current.data).toBe('value')
|
||||
})
|
||||
expect(result.current.error).toBeNull()
|
||||
})
|
||||
|
||||
it('surfaces an Error message', async () => {
|
||||
const fetcher = vi.fn().mockRejectedValue(new Error('boom'))
|
||||
const { result } = renderHook(() => usePoll(fetcher))
|
||||
await waitFor(() => {
|
||||
expect(result.current.error).toBe('boom')
|
||||
})
|
||||
})
|
||||
|
||||
it('stringifies a non-Error rejection instead of rendering nothing', async () => {
|
||||
const fetcher = vi.fn().mockRejectedValue('plain string')
|
||||
const { result } = renderHook(() => usePoll(fetcher))
|
||||
await waitFor(() => {
|
||||
expect(result.current.error).toBe('plain string')
|
||||
})
|
||||
})
|
||||
|
||||
it('clears a previous error once a load succeeds again', async () => {
|
||||
const fetcher = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error('boom'))
|
||||
.mockResolvedValue('ok')
|
||||
const { result, rerender } = renderHook(
|
||||
({ interval }: { interval: number }) => usePoll(fetcher, interval),
|
||||
|
||||
{ initialProps: { interval: 100000 } },
|
||||
)
|
||||
await waitFor(() => {
|
||||
expect(result.current.error).toBe('boom')
|
||||
})
|
||||
rerender({ interval: 200000 })
|
||||
await waitFor(() => {
|
||||
expect(result.current.data).toBe('ok')
|
||||
})
|
||||
expect(result.current.error).toBeNull()
|
||||
})
|
||||
|
||||
it('polls again on the interval', async () => {
|
||||
vi.useFakeTimers()
|
||||
const fetcher = vi.fn().mockResolvedValue('value')
|
||||
renderHook(() => usePoll(fetcher, 1000))
|
||||
expect(fetcher).toHaveBeenCalledTimes(1)
|
||||
await vi.advanceTimersByTimeAsync(2000)
|
||||
expect(fetcher).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('stops updating after unmount', async () => {
|
||||
const fetcher = vi.fn().mockResolvedValue('value')
|
||||
const { result, unmount } = renderHook(() => usePoll(fetcher))
|
||||
unmount()
|
||||
await Promise.resolve()
|
||||
expect(result.current.data).toBeNull()
|
||||
})
|
||||
|
||||
it('ignores a rejection that lands after unmount', async () => {
|
||||
// The cancelled guard in the catch path: without it React warns about a
|
||||
// state update on an unmounted component, and a stale error would stick.
|
||||
let reject: (reason: Error) => void = () => undefined
|
||||
const fetcher = vi.fn(
|
||||
() =>
|
||||
new Promise<string>((_resolve, rej) => {
|
||||
reject = rej
|
||||
}),
|
||||
)
|
||||
const { result, unmount } = renderHook(() => usePoll(fetcher))
|
||||
unmount()
|
||||
reject(new Error('late failure'))
|
||||
await Promise.resolve()
|
||||
expect(result.current.error).toBeNull()
|
||||
})
|
||||
|
||||
it('exposes a sane default interval', () => {
|
||||
expect(POLL_MS).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
49
web/src/useStatusPoll.ts
Normal file
49
web/src/useStatusPoll.ts
Normal file
@ -0,0 +1,49 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
export const POLL_MS = 15000
|
||||
|
||||
interface PollResult<T> {
|
||||
readonly data: T | null
|
||||
readonly error: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll a fetcher on an interval, keeping the last good value on failure.
|
||||
*
|
||||
* Deliberately has no `document.hidden` guard. The enforcer's equivalent hook
|
||||
* had one and it swallowed the first load, leaving automation-driven tabs stuck
|
||||
* on the loading message forever; see steam-backlog-enforcer useBudgetPoll.
|
||||
*/
|
||||
export function usePoll<T>(
|
||||
fetcher: () => Promise<T>,
|
||||
intervalMs: number = POLL_MS,
|
||||
): PollResult<T> {
|
||||
const [data, setData] = useState<T | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
const load = () => {
|
||||
fetcher()
|
||||
.then((value) => {
|
||||
if (!cancelled) {
|
||||
setData(value)
|
||||
setError(null)
|
||||
}
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
if (!cancelled) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
}
|
||||
})
|
||||
}
|
||||
load()
|
||||
const handle = setInterval(load, intervalMs)
|
||||
return () => {
|
||||
cancelled = true
|
||||
clearInterval(handle)
|
||||
}
|
||||
}, [fetcher, intervalMs])
|
||||
|
||||
return { data, error }
|
||||
}
|
||||
27
web/tsconfig.app.json
Normal file
27
web/tsconfig.app.json
Normal file
@ -0,0 +1,27 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["es2023", "DOM", "DOM.Iterable"],
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"types": ["vite/client"],
|
||||
"skipLibCheck": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"exactOptionalPropertyTypes": true,
|
||||
"noImplicitOverride": true,
|
||||
"noImplicitReturns": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
7
web/tsconfig.json
Normal file
7
web/tsconfig.json
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
23
web/tsconfig.node.json
Normal file
23
web/tsconfig.node.json
Normal file
@ -0,0 +1,23 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["es2023"],
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
8
web/tsconfig.test.json
Normal file
8
web/tsconfig.test.json
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "./tsconfig.app.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.test.tsbuildinfo",
|
||||
"types": ["vite/client", "node"]
|
||||
},
|
||||
"include": ["src", "vite.config.ts"]
|
||||
}
|
||||
35
web/vite.config.ts
Normal file
35
web/vite.config.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import react from '@vitejs/plugin-react'
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
// The Python API (`screen-locker-web`) runs on 127.0.0.1:8770 — 8000 belongs to
|
||||
// steam-backlog-enforcer's own UI. In dev, Vite serves the UI and proxies
|
||||
// `/api/*` to it, so there are no CORS concerns. In production the Python
|
||||
// server serves the built bundle from `web/dist`.
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': 'http://127.0.0.1:8770',
|
||||
},
|
||||
},
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
setupFiles: './src/test/setup.ts',
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
include: ['src/**/*.{ts,tsx}'],
|
||||
exclude: [
|
||||
'src/**/*.test.{ts,tsx}',
|
||||
'src/test/**',
|
||||
'src/main.tsx',
|
||||
'src/vite-env.d.ts',
|
||||
],
|
||||
thresholds: {
|
||||
statements: 100,
|
||||
branches: 100,
|
||||
functions: 100,
|
||||
lines: 100,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Loading…
Reference in New Issue
Block a user