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:
@@ -471,5 +471,118 @@ function sparkline(container, points, opts) {
|
||||
<path d="${d}" fill="none" stroke="${stroke}" stroke-width="1.4"/>${marks}</svg>${foot}`;
|
||||
}
|
||||
|
||||
window.DashCharts = { LineChart, niceTicks, renderLegend, sparkline, cssVar,
|
||||
CHART_COLORS, DEFAULT_FORMAT, escapeHtml };
|
||||
/**
|
||||
* An inline-SVG battery whose fill *is* the charge, for the `/ups` hero row.
|
||||
*
|
||||
* `battery(container, {charge, severity, state, label, decorative})`
|
||||
* - `charge`: 0-100, or `null` when the driver does not publish it. Clamped to the scale,
|
||||
* and **never rounded up to visible**: 2 % fills 2 % of the interior, because a
|
||||
* reassuring full-looking battery above a flat cell is a lie (the same rule that makes
|
||||
* an absent number print `n/a` instead of `0`).
|
||||
* - `severity`: the payload verdict (`'ok'|'warn'|'fail'`, or a `{severity}` cell) → the
|
||||
* fill and outline take that theme token through `cssVar()`. Anything else is *no
|
||||
* verdict*: a muted outline, and a measured charge drawn faint and grey — a shape, not
|
||||
* a judgement (`sparkline`'s unrated rule, applied to the one thing here that is a
|
||||
* shape). A measured charge never renders as an empty shell; "0 %" and "not rated" are
|
||||
* different sentences and only one of them is an alarm.
|
||||
* - `state`: `'online' | 'onbattery' | 'charging' | 'unknown'` — the page derives it from
|
||||
* `charger_status` + `on_battery` (see `tests/ups_contract.py`), never by re-parsing
|
||||
* NUT flags here. Each state has a *shape*, so the glyph reads in monochrome and to a
|
||||
* screen reader, not only in colour:
|
||||
* online solid shell
|
||||
* charging solid shell + a bolt over the plate
|
||||
* onbattery **dashed** shell outline
|
||||
* unknown no fill, a dash pattern **through** the body
|
||||
* - `label`: names this battery ("apc") in the accessible name. No percent text is drawn
|
||||
* inside the glyph: the hero number next to it already says it, and a card that prints
|
||||
* one value twice is the pattern this app avoids everywhere else.
|
||||
*
|
||||
* Accessible **by default**: `role="img"` + `<title>` + `aria-label` built from the inputs
|
||||
* ("apc: Charge 100 percent, on line"), which is the choice this component makes — the
|
||||
* glyph carries information the markup does not repeat in words. A page that prints the
|
||||
* same sentence beside it passes `decorative: true` to make the `<svg>` `aria-hidden`
|
||||
* instead of saying it twice; the `<title>` stays for the hover tooltip.
|
||||
*
|
||||
* Scales with its container: one `viewBox` + `preserveAspectRatio`, `width:100%` and
|
||||
* `height:auto` (the `.battery` rule in `dash.css` caps it), so the hero row gets a big
|
||||
* cell on a desktop and a small one on a phone with no redraw and no ResizeObserver.
|
||||
* No animation and no transition at all — the fill is re-drawn per render, and a bar that
|
||||
* slides competes with the number it annotates, so `prefers-reduced-motion` holds by
|
||||
* construction. Colours are resolved from the tokens at draw time, so a theme switch
|
||||
* repaints with the next render (every page re-renders on its own poll).
|
||||
*/
|
||||
let batterySeq = 0;
|
||||
const BATTERY_STATES = ['online', 'onbattery', 'charging', 'unknown'];
|
||||
const BATTERY_STATE_LABELS = { online: 'on line', onbattery: 'on battery',
|
||||
charging: 'charging', unknown: 'state unknown' };
|
||||
|
||||
function battery(container, opts) {
|
||||
const el = typeof container === 'string' ? document.querySelector(container) : container;
|
||||
if (!el) return null;
|
||||
const o = Object.assign({ charge: null, severity: null, state: 'unknown', label: null,
|
||||
decorative: false }, opts || {});
|
||||
|
||||
// Geometry in viewBox units, chosen so 1 unit of fill == 1 % of charge.
|
||||
const W = 128;
|
||||
const H = 56;
|
||||
const shell = { x: 2, y: 8, w: 110, h: 40, r: 9, stroke: 3 };
|
||||
const inner = { x: 7, y: 12, w: 100, h: 32, r: 4 };
|
||||
const nub = { x: shell.x + shell.w + 3, y: 20, w: 9, h: 16, r: 3 };
|
||||
|
||||
const raw = Number(o.charge);
|
||||
const measured = o.charge != null && isFinite(raw);
|
||||
const pctValue = measured ? Math.max(0, Math.min(100, raw)) : null;
|
||||
let sev = o.severity;
|
||||
if (sev && typeof sev === 'object') sev = sev.severity;
|
||||
const ink = sev === 'ok' ? cssVar('--ok')
|
||||
: sev === 'warn' ? cssVar('--warn')
|
||||
: sev === 'fail' ? cssVar('--fail') : null;
|
||||
const stroke = ink || cssVar('--text-muted');
|
||||
const state = BATTERY_STATES.indexOf(o.state) >= 0 ? o.state : 'unknown';
|
||||
|
||||
const clipId = `dash-battery-${(batterySeq += 1)}`;
|
||||
const dashed = state === 'onbattery' ? ' stroke-dasharray="7 5"' : '';
|
||||
let fillRect = '';
|
||||
if (pctValue != null && pctValue > 0) {
|
||||
const w = (pctValue / 100) * inner.w;
|
||||
fillRect = `<rect x="${inner.x}" y="${inner.y}" width="${w.toFixed(2)}" height="${inner.h}"` +
|
||||
` fill="${ink || cssVar('--text-faint')}"${ink ? '' : ' opacity=".5"'}/>`;
|
||||
}
|
||||
// A dash *through* the body: the cell exists, the reading does not.
|
||||
const noReading = state === 'unknown'
|
||||
? `<line x1="${inner.x + 5}" y1="${inner.y + inner.h / 2}" x2="${inner.x + inner.w - 5}"` +
|
||||
` y2="${inner.y + inner.h / 2}" stroke="${cssVar('--text-faint')}" stroke-width="3"` +
|
||||
` stroke-linecap="round" stroke-dasharray="5 6"/>`
|
||||
: '';
|
||||
const bolt = state === 'charging'
|
||||
? `<path d="M13 2 L3 14 h7 l-1 8 L19 10 h-7 z" transform="translate(43.8 13.6) scale(1.2)"` +
|
||||
` fill="${ink || cssVar('--text-muted')}" stroke="${cssVar('--bg-raised')}"` +
|
||||
` stroke-width="1.6" stroke-linejoin="round"/>`
|
||||
: '';
|
||||
|
||||
const chargePhrase = pctValue == null ? 'Charge unknown' : `Charge ${Math.round(pctValue)} percent`;
|
||||
const statePhrase = BATTERY_STATE_LABELS[state];
|
||||
const spoken = `${chargePhrase}, ${statePhrase}`;
|
||||
const name = o.label ? `${o.label}: ${spoken}` : spoken;
|
||||
// `decorative` is the opt-out described above; labelled is the default.
|
||||
const a11y = o.decorative ? 'aria-hidden="true"'
|
||||
: `role="img" aria-label="${escapeHtml(name)}"`;
|
||||
|
||||
el.classList.add('battery');
|
||||
el.innerHTML = `<svg viewBox="0 0 ${W} ${H}" width="${W}" height="${H}"
|
||||
preserveAspectRatio="xMidYMid meet" ${a11y}>
|
||||
<title>${escapeHtml(name)}</title>
|
||||
<defs><clipPath id="${clipId}"><rect x="${inner.x}" y="${inner.y}" width="${inner.w}"
|
||||
height="${inner.h}" rx="${inner.r}"/></clipPath></defs>
|
||||
<rect x="${shell.x}" y="${shell.y}" width="${shell.w}" height="${shell.h}" rx="${shell.r}"
|
||||
fill="none" stroke="${stroke}" stroke-width="${shell.stroke}"${dashed}/>
|
||||
<rect x="${nub.x}" y="${nub.y}" width="${nub.w}" height="${nub.h}" rx="${nub.r}" fill="${stroke}"/>
|
||||
<g clip-path="url(#${clipId})">
|
||||
<rect x="${inner.x}" y="${inner.y}" width="${inner.w}" height="${inner.h}" fill="${cssVar('--bg-sunken')}"/>
|
||||
${fillRect}
|
||||
</g>${noReading}${bolt}</svg>`;
|
||||
return el;
|
||||
}
|
||||
|
||||
window.DashCharts = { LineChart, niceTicks, renderLegend, sparkline, battery, cssVar,
|
||||
CHART_COLORS, DEFAULT_FORMAT, escapeHtml };
|
||||
|
||||
Reference in New Issue
Block a user