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.
This commit is contained in:
@@ -0,0 +1,148 @@
|
||||
"""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"
|
||||
Reference in New Issue
Block a user