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
+260 -7
View File
@@ -575,17 +575,20 @@ def test_hero_reads_the_verdict_instead_of_a_threshold(client) -> None:
def test_severity_for_is_kept_as_the_documented_fallback(client) -> None:
"""The old call sites still work until the remaining phases convert each page.
"""Every page call site is gone now that phase 05 converted `/ups`.
`/` was converted in phase 02, `/system` in phase 03 and `/llm` in phase 04: each
colours itself from the `severity` verdicts the API already sends, so it carries no
threshold of its own. `/ups` is phase 05's job and must keep compiling against the
fallback until it is converted — deleting it early would break that page.
`/` was converted in phase 02, `/system` in phase 03, `/llm` in phase 04 and `/ups` in
phase 05, so no page colours anything from a threshold it wrote itself. The function
itself stays: it is the documented fallback `setBar()` uses when a caller names no
verdict, and a page that ever needs a verdict the API does not send should add it to
`severity.py` rather than here — which is what the docstring on it says.
"""
js = client.get("/static/app.js").text
assert "FALLBACK ONLY" in js
assert "severityFor" in client.get("/static/page-ups.js").text, \
"/ups still uses the fallback until phase 05 converts it"
assert "severityFor" in js, "the documented fallback itself was deleted"
for page in ("page-index.js", "page-system.js", "page-llm.js", "page-ups.js"):
assert "severityFor" not in client.get(f"/static/{page}").text, \
f"{page} colours something from a threshold of its own"
def test_overview_colours_from_the_payload_not_from_js_thresholds(client) -> None:
@@ -826,6 +829,256 @@ def test_sparkline_ships_the_fixed_scale_and_verdict_options(client) -> None:
assert "transition:" not in body and "animation:" not in body
# ---- the battery glyph (phase 05 task 02) ---------------------------------
#
# `DashCharts.battery()` is the one visual in this app whose *shape* is the data: the fill
# is the charge. That makes the honesty rules heavier here than anywhere else — a fill
# rounded up to look reassuring, a colour invented for an unrated cell, or a state only
# readable as green-vs-red would each turn a glance at the battery into a wrong claim.
# There is no JS test runner (a locked anchor), so the construction is pinned on the served
# bytes and shown in a browser (`data/logs/ups-glyph-*.png`).
def _battery_js(client: TestClient) -> str:
"""The glyph's code as served (state table + body), comments stripped.
Prose cannot satisfy these assertions, and the phrase table the accessible name is
built from is part of the component, so the slice starts above `function battery`.
"""
charts = client.get("/static/charts.js").text
body = charts[charts.index("const BATTERY_STATES"):charts.index("window.DashCharts")]
assert "function battery(container, opts)" in body
return _code(body)
def test_battery_glyph_is_exported_from_the_shared_chart_library(client) -> None:
"""The phase's own grep criterion, as a test: exactly one glyph, and it is public."""
charts = client.get("/static/charts.js").text
assert charts.count("function battery") == 1
exports = charts[charts.index("window.DashCharts"):]
assert re.search(r"\bbattery\b", exports), "DashCharts.battery is not exported"
# the wrapper the stylesheet sizes, added by the component rather than each caller
assert ".battery {" in _css(client) and ".battery svg" in _css(client)
def test_battery_fill_is_the_charge_and_is_never_padded(client) -> None:
"""2 % must look nearly empty, and 0 % must leave an empty shell, not a stray pixel."""
body = _battery_js(client)
# clamped to the scale, and the clamp is the only thing that touches the number: a
# minimum visible width would be a reassuring battery over a flat one
assert "Math.max(0, Math.min(100," in body
assert "(pctValue / 100) * inner.w" in body, "the fill stopped being a fraction of charge"
assert not re.search(r"Math\.max\(\s*[0-9.]*[1-9]\s*,", body), \
"the charge fill got a minimum width — a small charge would look bigger than it is"
# nothing is drawn for a cell at zero, and the plate it fills is a clip, so a low fill
# cannot leak outside the rounded interior
assert "pctValue > 0" in body
assert "<clipPath" in body and 'clip-path="url(#' in body
# an empty cell is the sunken plate, never a transparent hole in the card
assert "cssVar('--bg-sunken')" in body
def test_battery_colours_come_from_the_verdict_tokens_only(client) -> None:
"""The verdict paints; an unrated cell draws a grey shape and no colour at all."""
body = _battery_js(client)
for token in ("cssVar('--ok')", "cssVar('--warn')", "cssVar('--fail')"):
assert token in body, f"the glyph stopped taking its colour from {token}"
# no verdict → muted outline, and a measured charge in faint grey at half strength: a
# shape, not a judgement (an unknown number must never look green)
assert "cssVar('--text-muted')" in body and "cssVar('--text-faint')" in body
assert ' opacity=".5"' in body, "an unrated charge is drawn with the same weight as a rated one"
assert ".sev-" not in body, "the glyph invented a parallel severity vocabulary"
assert "severityFor(" not in body, "the glyph compares the charge to a threshold itself"
def test_battery_states_are_readable_without_colour(client) -> None:
"""Every state has a *shape*, so the glyph survives monochrome and colour-blindness.
on battery = dashed outline, charging = bolt, unknown = a dash pattern through the
body, online = a plain solid shell. A word the page did not intend falls back to
`unknown` rather than to a confident "on line".
"""
body = _battery_js(client)
assert 'BATTERY_STATES.indexOf(o.state) >= 0' in body, \
"an unrecognised state would be drawn as some other state"
assert 'state === \'onbattery\'' in body and 'stroke-dasharray="7 5"' in body, \
"the on-battery outline is no longer dashed"
assert "state === 'charging'" in body and 'd="M13 2 L3 14' in body, "the charging bolt is gone"
assert "state === 'unknown'" in body and 'stroke-dasharray="5 6"' in body, \
"an unknown charge no longer draws a dash through the body"
# and the unknown case really is *no fill*: the shape has to say it on its own
assert "pctValue != null" in body
def test_battery_glyph_names_itself_and_can_say_it_once(client) -> None:
"""`role="img"` + `<title>` built from the inputs, with an explicit `aria-hidden` opt-out."""
body = _battery_js(client)
assert 'role="img"' in body and "<title>" in body and "aria-label=" in body
for phrase in ("Charge ", " percent", "Charge unknown", "on line", "on battery",
"charging", "state unknown"):
assert phrase in body, f"the accessible name lost {phrase!r}"
# the label is a UPS name from upsd, so it goes through the one escaper
assert body.count("escapeHtml(name)") == 2, "<title> or aria-label stopped escaping the label"
assert 'aria-hidden="true"' in body, "the decorative opt-out for a duplicate hero number is gone"
def test_battery_glyph_scales_with_its_container_and_never_animates(client) -> None:
"""One `viewBox` + `preserveAspectRatio` for a phone and a 27" monitor; no motion at all."""
body = _battery_js(client)
assert 'viewBox="0 0 ${W} ${H}"' in body
assert 'preserveAspectRatio="xMidYMid meet"' in body, "the glyph stopped scaling with its box"
assert "ResizeObserver" not in body, "the glyph redraws on resize instead of scaling"
assert "transition" not in body and "animation" not in body
css = _code(_css(client))
wrapper = re.search(r"\.battery \{([^}]*)\}", css)
inner = re.search(r"\.battery svg \{([^}]*)\}", css)
assert wrapper and inner, "dash.css no longer sizes the glyph box"
assert "width: 100%" in wrapper.group(1) and "max-width" in wrapper.group(1), \
"the glyph is not capped inside its container"
assert "width: 100%" in inner.group(1) and "height: auto" in inner.group(1), \
"the svg would stretch instead of keeping the viewBox ratio"
both = wrapper.group(1) + inner.group(1)
assert "transition" not in both and "animation" not in both
# ---- the `/ups` hero row (phase 05 task 03) ------------------------------
#
# The card used to be nine equal `.metric` cells in which charge, load and runtime were the
# same size as the battery amperage. These pin the shape that replaced it on the served
# bytes (a locked anchor: no JS test runner), because the whole point of the row is a size
# and colour hierarchy a later "tidy up" can quietly flatten back to nine equal cells.
def _ups_card_js(client: TestClient) -> str:
"""The card markup as served, from `upsCard()` to `renderCard()`."""
js = client.get("/static/page-ups.js").text
return js[js.index("function upsCard("):js.index("function renderCard(")]
def test_ups_card_opens_with_the_glyph_and_three_heroes(client) -> None:
"""Charge / Load / Runtime are the card's three largest elements, next to the glyph.
`data-sev` names the verdict path each slot colours itself from, the same convention the
`/system` host card uses — so "which number is coloured by what" is readable in the
markup, and a rename that disconnects the page from `/api/ups/snapshot` fails here
instead of shipping a card of `n/a`.
"""
card = _ups_card_js(client)
assert '<div class="ups-hero">' in card, "the hero row left the top of the card"
assert 'data-k="glyph"' in card, "the battery glyph is not part of the hero row"
slots = re.findall(r'<div class="hero[^"]*" data-k="([^"]+)"', card)
assert slots == ["charge", "load", "runtime"], \
"the hero row is no longer charge / load / runtime in the agreed order"
for hook in ("severity.charge_pct", "severity.load_pct", "severity.battery_runtime"):
assert hook in card, f"the card no longer names {hook} as a colour source"
# the hero row is the first thing in the card body, above the health checks and commands
assert card.index("ups-hero") < card.index('data-k="checks"')
assert card.index("ups-hero") < card.index('data-k="commands"')
def test_ups_heroes_take_their_colour_from_the_payload(client) -> None:
"""Three `Dash.hero()` calls, three payload verdicts, no JS threshold.
The charge hero carries **no** `.bar`: the glyph beside it is that same value drawn as a
shape, and one value drawn twice in one row is the pattern this app avoids (load, which
has no other shape, keeps its bar). Runtime prose comes from `runtimeText()` alone, and
a runtime nobody estimated is passed as `null` so `hero()` renders the faint, uncoloured
unknown rather than the string `n/a` styled like a measurement.
"""
js = client.get("/static/page-ups.js").text
body = js[js.index("function renderCard("):js.index("// ---- command dialog")]
assert body.count("Dash.hero(q(") == 3, "the hero row is not three hero numbers"
for verdict in ("verdicts.charge_pct", "verdicts.load_pct", "verdicts.battery_runtime"):
assert verdict in body, f"{verdict} never reaches a hero"
charge = body[body.index("Dash.hero(q('charge')"):body.index("Dash.hero(q('load')")]
assert "bar" not in charge, "charge grew a second shape when the glyph already is one"
load = body[body.index("Dash.hero(q('load')"):body.index("Dash.hero(q('runtime')")]
assert "bar: true" in load, "load lost its bar"
assert "runtimeText(n.battery_runtime)" in body, "runtime prose bypasses runtimeText()"
runtime = body[body.index("Dash.hero(q('runtime')"):]
assert "n.battery_runtime == null ? null" in runtime, \
"an unmeasured runtime is rendered as a measurement"
assert "severityFor(" not in body
def test_ups_secondary_electrical_row_is_demoted_not_deleted(client) -> None:
"""Line in, output, battery volts and amps, battery °C: still there, visibly second.
These are the numbers you reach for *while* the UPS is on battery, so they were demoted
rather than folded into the disclosure — and they stay one step down (smaller, muted) so
the three verdicts remain the largest elements, which is also what keeps the card from
growing so tall that two stop fitting side by side on a laptop.
"""
card = _ups_card_js(client)
assert 'class="metrics ups-electrical"' in card
for key in ("line", "out", "batt", "amps", "temp"):
assert f'data-k="{key}"' in card, f"the electrical row lost {key}"
assert card.index("ups-electrical") > card.index("ups-hero")
css = _code(_css(client))
rule = re.search(r"\.ups-electrical \.metric \.value\.sm \{([^}]*)\}", css)
assert rule, "dash.css no longer styles the secondary row"
assert "var(--text-muted)" in rule.group(1), "the secondary row is not muted"
sizes = re.findall(r"font-size: ([\d.]+)rem", rule.group(1))
assert sizes and float(sizes[0]) < 1.3, "the secondary row competes with the heroes"
# `n/a` is fainter than a muted reading — neither real driver reports a battery temperature
null_rule = re.search(r"\.ups-electrical \.metric \.value\.sm\.is-null \{([^}]*)\}", css)
assert null_rule and "var(--text-faint)" in null_rule.group(1), \
"an unmeasured secondary value is not faint, so it reads like a number"
def test_ups_glyph_state_comes_from_the_api_fields(client) -> None:
"""`glyphState()` maps `on_battery` + `charger_status` — and `full` is **not** charging.
The mapping is presentation, so it lives in the page (task 02's assumption), but it is
still a mapping the real drivers exercise: neither UPS here exports
`battery.charger.status`, so the fallback that reads a state the API rated `ok` is what
draws a solid shell on both of them. Nothing here re-reads `ups.status` —
`test_the_page_does_not_re_parse_nut_status_flags` holds that line.
"""
js = _code(client.get("/static/page-ups.js").text)
glyph = js[js.index("function glyphState("):js.index("function drawGlyph(")]
assert "item.on_battery" in glyph and "'onbattery'" in glyph
assert "charger === 'chg'" in glyph and "'charging'" in glyph
assert "charger === 'dchg'" in glyph, "a discharging charger token is on battery too"
assert "charger === 'full'" in glyph and "'online'" in glyph, \
"a full, online battery must not be drawn as charging"
assert "item.states" in glyph, "a driver with no rich status lost its state entirely"
assert "'unknown'" in glyph, "an unknown state now degrades to a confident 'on line'"
def test_ups_glyph_repaints_only_when_something_visible_changes(client) -> None:
"""Charge jitters by a tenth of a percent every poll; the `<svg>` does not rebuild on it.
Rebuilding would re-resolve the theme tokens and re-announce the accessible name every
`NUT_INTERVAL_SECONDS` for a difference no eye can see. `decorative: true` is the
component's documented opt-out — the charge hero and the state chips already say
"Charge 100 percent, on line", and a screen reader should not hear it twice.
"""
js = _code(client.get("/static/page-ups.js").text)
draw = js[js.index("function drawGlyph("):js.index("function upsCard(")]
assert "DashCharts.battery(" in draw
assert "cell.dataset.drawn === drawn" in draw, "the glyph rebuilds on every poll"
assert "decorative: true" in draw, "the glyph duplicates the hero number in a11y"
assert "glyphState(item)" in draw
def test_ups_hero_row_makes_room_for_the_glyph(client) -> None:
"""The glyph gets a real column, and the row stacks rather than clipping on a phone.
The track floor is the widest runtime prose the page prints (`9h 59m`) — a number that
cannot wrap needs the floor to be its own width, or the row grows a clipped `1h 28`.
"""
css = _code(_css(client))
row = re.search(r"\.ups-hero \{([^}]*)\}", css)
assert row, "dash.css lost the UPS hero row"
assert "minmax(112px, 156px) minmax(0, 1fr)" in row.group(1), "the glyph has no column"
assert "align-items: center" in row.group(1)
heroes = re.search(r"\.ups-hero \.heroes \{([^}]*)\}", css)
assert heroes and "minmax(min(140px, 100%), 1fr)" in heroes.group(1), \
"a hero track can now clip the widest runtime prose"
at = css.index("@media (max-width: 620px)")
block = css[at:at + 240]
assert ".ups-hero" in block and "minmax(0, 1fr)" in block, "the row never stacks"
# ---- the `/llm` layout (phase 04 task 03) ---------------------------------
#
# The drift this phase fixes was a 14-cell grid where everything was the same size and