Files
dashboards/tests/ups_contract.py
T
ducoterra 34854d32af phase: 05_ups_hero_battery_glyph
**Phase 05 final pass — all criteria met; whole project 100 % green.**

Verified (live upsd + isolated simulator):
- Real `apc`/`cyberpower`: Charge/Load/Runtime are the card's three largest elements (38.4 px vs 15.2 px next), coloured only by API `severity` — live right now 100 % ok / 79.0 % **warn** / 5m **fail**; glyph fill = charge (100/100 viewBox units, `--ok` token), `<title>` "apc: Charge 100 percent, on line"; SIMULATED hidden; battery °C `n/a` uncoloured; every button disabled with its reason; `confirm_text: DANGER` on power-cutting commands; **0 non-GET requests** → `data/logs/ups-verify-live-{card,full}.png`.
- Simulator on `:8091` (own `/tmp` DB, killed after): caution dialog → Cancel, dangerous dialog → Run disabled until `DANGER` → Cancel, **0 POSTs**; one deliberate quick test *to the sim only* flipped the card to `OB` (glyph `stroke-dasharray="7 5"`, "on battery", health "discharging, on battery") then `OL CHRG` (bolt) then `OL` → `ups-verify-sim-{dialog-caution,dialog-danger,onbattery-button,onbattery-page,charging-button}.png`; glyph matrix `ups-glyph-*.png` (100/mid/low/unknown/empty/unrated, dark/light/200 %) present; `grep -c "function battery"` = 1.

Defects found in earlier work and fixed:
1. **`poller.py` could stop polling in silence** — 4 of 6 dev-server sources had frozen `attempts` (ages 12–38 min) while `/api/health` still said `ok: true`: the store-write half of `run_once` was outside the `try`, and a dead task's exception is never retrieved. Now `_commit()` moves `last_ok` only after the write lands, `_store_refused()` reports it (`ok:false`, real reason, one `store-error` event + one `recording` on recovery, payload kept), and `_loop()` catches anything that escapes and keeps polling — 4 new tests in `tests/test_poller.py`.
2. **`nut_sim.py` (modified by this phase) sat at 83 % coverage**; its whole command path was untested. Added tests (every advertised command implemented, unknown → `ValueError`, alarms → card + `assess()` fail, `NUT_SIMULATE` truthy rule) and fixed two model lies: `beeper.*` answered OK while changing nothing (now publishes `ups.beeper.status` enabled/muted/disabled) and a finished self-test never wrote `ups.test.result` (aborted writes none). → 100 %.
3. `nut_sim.from_env()` was dead code duplicating `app._truthy`; `app.py` now builds the simulator through it, so the enable gate lives once.

Tests / lint (exact): `bash .agents/validate.sh` → **EXIT=0** — `uv run pytest --cov --cov-fail-under=94` **543 passed, 8 skipped, coverage 97.91 %**; `uv run ruff check src tests agent` clean; `uv run pyright` 0 errors; `node --check` clean; repo hygiene clean. `uv run pytest -m live --live` **with `.env.dev` sourced → 8 passed** (one earlier run: 2 transient `9121/snapshot timed out` against the 4 s `HOST_TIMEOUT`; three later runs 8/8). `uv pip list --outdated` empty. Dev server restarted on the final code: `health ok`, 6/6 sources fresh.

Docs: AGENTS.md — invariant 4 extended ("nothing may stop polling in silence" + store-refusal semantics), `poller.py`/`nut_sim.py` responsibility lines, 533→543 and 96.9 %→97.9 %, "~97 %"→"~98 %". README.md — poller bullet gained the same guarantee, Testing gained "source `.env.dev` before `-m live --live`" (bare runs skip 7 of 8 live tests, which reads like a pass), Battery-test section gained the `deploy/nut-sudoers` install line and the read-before-write warning.

Notes/deviations: no live instant command was ever sent to real hardware (the four gates leave them disabled — AGENTS gap 1 unchanged); the on-battery proof was driven through the real button against `NUT_SIMULATE=1`, which is stronger than the NOTES hand-driven states. `NOTES-verify.md` under `todo/` left untouched per instructions. Next phase: **none** — 05 is the last in the PLAN.md roadmap.
2026-10-17 10:27:23 -04:00

149 lines
7.8 KiB
Python

"""The `/ups` hero-row and battery-glyph payload contract, shared by the two suites.
Phase 05 makes every UPS card open with a battery glyph that visibly holds its charge and
three big colour-coded numbers — **charge %**, **load %**, **runtime left** — with the health
checks and the command buttons below them. The paths below are the fields that row (and the
glyph) may read; they are asserted twice on purpose:
* hermetically, from **verbatim upsd captures** (`tests/fixtures/nut_apc.vars`,
`nut_cyberpower.vars`) in `tests/test_ups_contract.py`, and
* against the **real batteries** in
`tests/test_live_fleet.py::test_ups_hero_fields_are_present_on_the_real_batteries`.
One table, so the live assertions cannot drift away from the fixture assertions
(the same reasoning as `tests/card_contract.py` for `/system`).
=========================== ==============================================================
Hero element Path in one card of `/api/ups/snapshot`
=========================== ==============================================================
charge % ``numbers.charge_pct`` + ``severity.charge_pct``
load % ``numbers.load_pct`` + ``severity.load_pct``
runtime left ``numbers.battery_runtime`` + ``severity.battery_runtime``
battery volts ``numbers.battery_voltage``
battery °C ``numbers.battery_temperature`` (``None`` on both real UPSes)
glyph state ``charger_status`` + ``on_battery`` (+ ``states`` for a label)
card header ``health.overall`` + ``health.counts``
=========================== ==============================================================
**The glyph's state is ``charger_status`` and ``on_battery``, never a re-parse of
``ups.status`` in JavaScript.** ``UpsReading`` already turns the flags into ``states[]`` with
labels and severities (`UPS_STATE_LABELS` / `CHARGER_STATUS`), and a second copy of that map
in a page is a map that can disagree with the API. Recorded here because both captures prove
the fallback is not theoretical: **neither the APC nor the CyberPower publishes
``battery.charger.status`` at all**, so on this fleet ``on_battery`` is the field that says
whether the glyph is holding charge or draining, and ``charger_status`` must stay optional.
Values may honestly be ``None`` (that renders `n/a`, per the "never invent a number" rule);
**present** is the contract.
"""
from __future__ import annotations
from pathlib import Path
from typing import Any
from card_contract import dig
from dashboards.sources import nut
FIXTURES = Path(__file__).resolve().parent / "fixtures"
#: The UPS shapes the captures in `tests/fixtures/` cover. A new driver joins this list and
#: gets captured with `./scripts/capture-fixtures.sh` — `test_every_captured_ups_is_in_the_contract`
#: fails if it does not, the way `HOST_SHAPES` does for `/system`.
UPS_SHAPES: tuple[str, ...] = ("apc", "cyberpower")
#: Hero row / glyph element -> dotted payload paths inside one UPS card. Present is the
#: contract; a value may be ``None`` on a driver that does not report it.
HERO_FIELDS: dict[str, tuple[str, ...]] = {
"charge %": ("numbers.charge_pct", "severity.charge_pct"),
"load %": ("numbers.load_pct", "severity.load_pct"),
"runtime left": ("numbers.battery_runtime", "severity.battery_runtime"),
"battery volts": ("numbers.battery_voltage",),
"battery °C": ("numbers.battery_temperature",),
# the glyph: the rich status when the driver has one, `on_battery` when it does not,
# `states[]` for the label, and `status` only ever as the raw mono chip beside it
"glyph state": ("charger_status", "on_battery", "states", "status"),
# unchanged since before phase 05 — the card header keeps using these
"card header": ("health.overall", "health.counts"),
}
#: The verdicts the hero row knows. `severity.py`'s vocabulary and nothing else; `unknown`
#: is `assess()`'s word for a condition, not a colour for a value.
VERDICTS: tuple[str | None, ...] = ("ok", "warn", "fail", None)
#: The health-check rows the hero row depends on being able to explain itself with.
CHECK_NAMES: tuple[str, ...] = ("Charge", "Runtime estimate", "Load", "Battery temperature")
def parse_capture(name: str) -> nut.UpsReading:
"""Rebuild an `UpsReading` from a verbatim upsd capture.
The lines are split with the client's own `_split_fields()` rather than a second parser:
upsd quotes a field only when it must (`VAR apc ups.status "OL"` is what NUT 2.8.5
actually sends), so a hand-rolled splitter would be a second opinion about the wire —
exactly the thing `AGENTS.md` rule 7 refuses to have.
"""
path = FIXTURES / f"nut_{name}.vars"
if not path.exists():
raise AssertionError(
f"missing capture {path.name} — run ./scripts/capture-fixtures.sh with the upsd "
f"forwarded (DEV_FORWARDS); the UPS hero row is contracted against real drivers"
)
variables: dict[str, str] = {}
commands: list[str] = []
description = ""
for line in path.read_text().splitlines():
if not line or line.startswith("#"):
continue
fields = nut._split_fields(line)
if fields[0] == "UPS" and len(fields) >= 2:
description = " ".join(fields[2:])
elif fields[0] == "VAR" and len(fields) >= 4:
variables[fields[2]] = " ".join(fields[3:])
elif fields[0] == "CMD" and len(fields) >= 3:
commands.append(fields[2])
return nut.UpsReading(name=name, description=description, vars=variables,
commands=commands, ts=1_767_225_600.0)
def with_vars(reading: nut.UpsReading, **overrides: str | None) -> nut.UpsReading:
"""The same capture with a few variables changed (or removed with ``None``).
Changing one variable instead of writing a new VAR table keeps every *other* real value
in the payload, so a test about charge severity is still a test about a real APC.
"""
variables = dict(reading.vars)
for key, value in overrides.items():
if value is None:
variables.pop(key, None)
else:
variables[key] = value
return nut.UpsReading(name=reading.name, description=reading.description, vars=variables,
commands=list(reading.commands), ts=reading.ts)
def assert_ups_hero_contract(card: dict[str, Any]) -> None:
"""Every hero/glyph path exists on this UPS card, with the vocabulary the page renders."""
for paths in HERO_FIELDS.values():
for path in paths:
dig(card, path)
verdicts = card["severity"]
assert isinstance(verdicts, dict), "severity must be a dict of hero verdicts"
for key in ("charge_pct", "load_pct", "battery_runtime"):
assert key in verdicts, f"severity.{key} is missing — the hero would colour nothing"
assert verdicts[key] in VERDICTS, f"severity.{key}={verdicts[key]!r} is not the app vocabulary"
assert isinstance(card["on_battery"], bool), "on_battery is a bool, not a truthy string"
charger = card["charger_status"]
assert charger is None or charger in nut.CHARGER_STATUS, (
f"charger_status={charger!r} is not a CHARGER_STATUS token (and not None) — the glyph "
f"would have no state to draw"
)
states = card["states"]
assert isinstance(states, list), "states is a list of {id,label,severity} chips"
for state in states:
assert {"id", "label", "severity"} <= set(state), f"state chip is incomplete: {state}"
counts = card["health"]["counts"]
assert {"ok", "warn", "fail", "unknown"} <= set(counts), f"health.counts: {sorted(counts)}"
names = {check["name"] for check in card["health"]["checks"]}
missing = set(CHECK_NAMES) - names
assert not missing, f"health.checks has no {sorted(missing)} row — the card cannot explain itself"