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:
+260
-7
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user