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:
2026-10-17 10:27:23 -04:00
parent f44a617ef9
commit 34854d32af
44 changed files with 2374 additions and 105 deletions
+137 -1
View File
@@ -8,13 +8,14 @@ directly, because those are what decide whether a battery test gets sent.
from __future__ import annotations
import asyncio
import time
import pytest
import pytest_asyncio
from dashboards.sources import nut
from dashboards.sources.nut import NutClient, NutError, UpsReading, normalise_celsius
from dashboards.sources.nut_sim import NutSimulator
from dashboards.sources.nut_sim import NutSimulator, SimUps, from_env
VAR_ROWS = {
"battery.charge": "100",
@@ -548,7 +549,142 @@ async def test_simulator_discharges_while_on_battery() -> None:
assert ups.charge < before
def test_states_never_say_the_same_word_twice() -> None:
"""A driver exporting both the 2.8 token and the legacy flag gets one chip per fact.
Both spellings arrive at the word "charging"; a chip row that says it twice reads as
two events. The token wins (it distinguishes trim/boost/full), the subsumed flag goes.
"""
both = UpsReading(name="x", vars={"battery.charger.status": "chg", "ups.status": "OL CHRG"})
assert [s["label"] for s in both.states()] == ["charging", "online"]
dischrg = UpsReading(name="x", vars={"battery.charger.status": "dchg", "ups.status": "OB DISCHRG"})
assert [s["label"] for s in dischrg.states()] == ["discharging", "on battery"]
plain = UpsReading(name="x", vars={"ups.status": "OL CHRG"})
assert [s["label"] for s in plain.states()] == ["online", "charging"], \
"the flag-only route keeps both facts"
def test_a_recharging_simulated_ups_reports_charging_like_a_real_driver() -> None:
"""A below-float pack on mains is *charging* — APC prints exactly `OL CHRG` there.
The `/ups` glyph's bolt is keyed on `charger_status == "chg"`, and a model that only
ever answered `ol`/`OL` made that state unreachable in dev — the flag and the charger
token also contradicted each other, plain `OL` next to a charge that was climbing.
"""
ups = SimUps("apc", "Smart-UPS 1500", "APC", "AS-1", charge=60.0)
vars_ = ups.vars(time.time())
assert vars_["ups.status"] == "OL CHRG"
assert vars_["battery.charger.status"] == "chg"
card = nut.payload(UpsReading(name="apc", vars=vars_, commands=[]))
assert card["charger_status"] == "chg", "the page derives the bolt from this token only"
assert card["on_battery"] is False, "OL CHRG is on line, not on the battery"
def test_a_discharging_simulated_ups_is_never_online() -> None:
"""`ol`/`full` next to `OB` is a contradiction; a drawn-from battery reports `dchg`."""
ups = SimUps("apc", "Smart-UPS 1500", "APC", "AS-1", charge=100.0, on_battery=True)
vars_ = ups.vars(time.time())
assert "OL" not in vars_["ups.status"].split()
assert vars_["battery.charger.status"] == "dchg"
async def test_simulator_command_for_unknown_ups_raises() -> None:
sim = NutSimulator()
with pytest.raises(KeyError):
sim.command("nope", "beeper.mute")
def test_nut_simulate_is_the_only_switch_and_says_so() -> None:
"""The enable rule lives once, next to the model it swaps in.
`app.py` builds the monitor through this, so `1/true/yes/on` mean the same thing in the
assembly and in the module — and anything else means *real upsd*, which is the default that
must never be reachable by an empty or half-set variable.
"""
assert isinstance(from_env("1"), NutSimulator)
assert isinstance(from_env(" TRUE "), NutSimulator)
assert from_env(None) is None
assert from_env("") is None
assert from_env("0") is None, "a commented-out or zeroed NUT_SIMULATE must read real upsd"
async def test_every_command_the_simulator_advertises_is_implemented() -> None:
"""`commands()` is what the `/ups` page turns into buttons, so it must not list a lie.
A command the model advertises but `run_command()` does not implement raises, and the dev
page would then show `refused` for a button that is supposed to work — the simulator is
the only driver this app's one write path is ever exercised against, so an unimplemented
advertised command is a bug in the model, not a missing feature.
"""
sim = NutSimulator()
ups = (await sim.read())[0]
assert ups.commands, "a simulator with no commands cannot exercise the buttons at all"
for command in ups.commands:
assert sim.command(ups.name, command).startswith("OK"), command
def test_an_unimplemented_command_is_refused_by_the_model_too() -> None:
"""Silently accepting a command the driver would reject hides a bad catalog entry."""
ups = SimUps("apc", "Smart-UPS 1500", "APC", "AS-1")
with pytest.raises(ValueError):
ups.run_command("outlet.1.switch.off", now=time.time())
def test_the_beeper_commands_change_the_status_a_driver_would_publish() -> None:
"""`beeper.mute` answering OK while nothing changes teaches people to trust a dead button.
Real drivers report `ups.beeper.status`, and the NUT variable dump on the card shows it,
so each of the three commands has its own observable state here — `mute` and `disable` are
different words in NUT and stay different here.
"""
ups = SimUps("apc", "Smart-UPS 1500", "APC", "AS-1")
assert ups.vars(time.time())["ups.beeper.status"] == "enabled"
now = time.time()
ups.run_command("beeper.mute", now=now)
assert ups.vars(now)["ups.beeper.status"] == "muted"
ups.run_command("beeper.disable", now=now)
assert ups.vars(now)["ups.beeper.status"] == "disabled"
ups.run_command("beeper.enable", now=now)
assert ups.vars(now)["ups.beeper.status"] == "enabled"
def test_a_finished_simulated_test_reports_its_result_and_a_stopped_one_reports_nothing() -> None:
"""The command → state change → health-check path the simulator exists for.
`ups.test.result` is the variable the "Last self-test" check reads, so a quick test that
ends with the pack back on line but the old result still published shows a card that never
learned the test happened. An aborted test writes no result: a driver does not.
"""
ups = SimUps("apc", "Smart-UPS 1500", "APC", "AS-1", test_result="FAILED",
test_date="2020-01-01")
now = time.time()
ups.run_command("test.battery.start.quick", now=now)
assert ups.on_battery is True
ups.tick(now + 20.0)
assert ups.on_battery is False, "a quick test is over after its ~12s window"
assert ups.test_result == "PASSED"
assert ups.test_date == time.strftime("%Y-%m-%d", time.gmtime(now))
aborted = SimUps("apc", "Smart-UPS 1500", "APC", "AS-1", test_result="FAILED")
aborted.run_command("test.battery.start.quick", now=now)
aborted.run_command("test.battery.stop", now=now)
aborted.tick(now + 20.0)
assert aborted.test_result == "FAILED", "an aborted test must not be reported as a pass"
def test_a_simulated_alarm_reaches_the_card_and_the_health_check() -> None:
"""The `alarm:` chip and the `Alarms` row are reachable in dev only through the model.
`assess()` fails that row for the codes that mean the pack or the load is in trouble, and
neither UPS on this fleet has ever raised one — so the alarm path is pinned here instead of
against a capture nobody can produce on demand.
"""
quiet = SimUps("apc", "Smart-UPS 1500", "APC", "AS-1")
now = time.time()
assert "ups.alarm" not in quiet.vars(now), "a quiet UPS must not invent an alarm"
loud = SimUps("apc", "Smart-UPS 1500", "APC", "AS-1", _alarms=["LB"])
card = nut.payload(UpsReading(name="apc", vars=loud.vars(now), commands=[]))
assert card["alarms"] == ["LB"]
check = next(c for c in card["health"]["checks"] if c["name"] == "Alarms")
assert check["status"] == "fail", "a low-battery alarm is not a warning"