phase: 19_ups_events_expandable
**Phase 19 final verification pass — all tasks already complete; everything green, no code changes needed.** - **Verified (all 4 task files present in `complete/`)**: `details.events-box` disclosure on `/ups` with no `open` attribute and `#events` inside it; lazy fetch (`if (state.events.open) await refreshEvents()`), toggle stale rule on `state.pollMs`, success-path-only `painted`/`fetchedAt`; `charts-box`-shaped CSS with the `> .body` padding restatement; 4 new served-byte pins in `tests/test_api.py`. - **Criterion 1 (collapsed on load, no first-poll fetch)**: PASS — re-ran `data/logs/19-task04-verify.cjs` against the live dev server (real upsd, 2 UPSes): **28/28 PASS**, `0` `/api/events` requests across 2 closed polls vs 4 snapshot polls; expand → exactly 1 request, 50 rows with `Oct 20, 07:11` timestamps + severity chips + `1–50 of 200` pager. - **Criterion 2 (cache/stale/poll-while-open)**: PASS — fresh re-open 2→2 requests, stale re-open 2→3, loop refreshes while open, close asks for nothing, failed fetch keeps rows and doesn't stamp the cache. - **Criterion 3**: `uv run pytest` → **1458 passed, 15 skipped**; `uv run pytest --cov` → **98.94%** (floor 94, unchanged); `uv run ruff check src tests agent` → clean; `uv run pyright` → **0 errors**. - **Criterion 4**: `bash .agents/validate.sh` → **exit 0** (incl. `node --check` of all page scripts + repo-hygiene gates). - **Criterion 5**: `data/logs/ups-events-closed.png` (collapsed `▸ POWER EVENTS`, 0 events requests noted in `ups-events-verify.out`) and `ups-events-open.png` (`▾`, bounded 320px list, pager strip) regenerated and inspected this pass. - **Criterion 6 (no regressions)**: full suite green incl. UPS contract/pager pins; `uv run pytest -m live --live` (with `.env.dev`) → **11 passed**; phase 18's `nav-final-pass.cjs` sweep re-run → **45/45** (this phase's markup/CSS didn't touch the masthead). - **Documentation**: full README.md (980 L) + AGENTS.md (1992 L) review against the code — verified env templates vs `config.py`, API route table vs `api.py`, installer flags vs `deploy/install-agent.sh`, agent routes vs `host_agent.py`, test/coverage/live tallies, `data/logs` artifacts, and live fleet facts (`HOST_TARGETS` all `http://`, all three agents `version 1.0.0`, nine sources `ok`). AGENTS.md UI conventions and README's events paragraph already carry the disclosure/lazy/stale sentences from tasks 01–04. **Verified consistent — no changes needed.** - **Notable**: made zero repo edits (working tree holds only this phase's task work). `uv pip list --outdated` couldn't complete (PyPI 503 for the project name) — no dependency moved this pass, so rule 8 is not triggered; validate.sh's earlier advisory still lists `fastapi 0.153.0 → 0.156.3`, deliberately not upgraded in a UI/verification phase. - **Next pending phase**: `.agents/phases/todo/20_masthead_nav_polish/` (tasks 01–04).
This commit is contained in:
@@ -1754,6 +1754,185 @@ def test_the_event_feeds_scroll_instead_of_growing_the_page(client) -> None:
|
||||
assert '<ul class="events" id="events"' in html, f"{path} lost its feed's id"
|
||||
|
||||
|
||||
def test_ups_power_events_feed_sits_behind_a_collapsed_disclosure(client) -> None:
|
||||
"""The `/ups` feed is *expandable*: one `details.events-box`, shut by default.
|
||||
|
||||
`TODO.md:4` asks for an expandable list of incidents, and the shut disclosure is the half
|
||||
that costs something — a collapsed `<details>` renders no content, which is what lets the
|
||||
page fetch nothing while nobody is looking at the feed (`/system`'s Charts rule). Pinned on
|
||||
the served bytes because there is no JS test runner (a locked anchor): an `open` attribute
|
||||
would undo the phase silently, and the `#events` id has to stay *inside* the disclosure or
|
||||
the pager and the bounded 320px scroll lose their target.
|
||||
"""
|
||||
html = _code(client.get("/ups").text)
|
||||
box = re.search(r'<details class="events-box">(.*?)</details>', html, re.S)
|
||||
assert box, "the Power events card is no longer a details.events-box disclosure"
|
||||
assert not re.search(r"<details[^>]*\bopen\b", html), \
|
||||
"the disclosure ships open — the list is tall again, not expandable"
|
||||
inner = box.group(1)
|
||||
assert "<summary>Power events</summary>" in inner, "the summary lost its label"
|
||||
assert '<ul class="events" id="events"' in inner, "the feed moved outside the disclosure"
|
||||
assert "loading…" in inner, "the first-expand placeholder is gone"
|
||||
assert "<h3>Power events</h3>" not in html, "the card heads its own body twice"
|
||||
# the rest of the page is untouched by the disclosure
|
||||
for still_there in ('id="ups-cards"', 'id="chart-charge"', 'id="chart-load"',
|
||||
'id="confirm-dialog"'):
|
||||
assert still_there in client.get("/ups").text, f"/ups lost {still_there}"
|
||||
|
||||
|
||||
def test_ups_events_are_fetched_only_while_the_disclosure_is_open(client) -> None:
|
||||
"""A closed Power events card costs the poll loop *nothing* — no fetch, no rows.
|
||||
|
||||
Hiding a list that is still fetched every 5 s is the half-measure this pins out: the
|
||||
disclosure is only worth having if `refresh()` stops asking `/api/events` while it is shut
|
||||
(the `/system` Charts rule — "nothing is built or drawn while the stack is shut"), and the
|
||||
expand still has to fill the list, so the toggle needs the two facts a re-open is judged
|
||||
on: has it ever painted, and how old is what it painted. Pinned on the served script
|
||||
because there is no JS test runner (a locked anchor).
|
||||
"""
|
||||
js = _code(client.get("/static/page-ups.js").text)
|
||||
|
||||
# The state the two paths share: closed, nothing painted yet, nothing fetched yet.
|
||||
decl = js[js.index("const state = {"):js.index("const MARKS")]
|
||||
assert "events: { open: false, painted: false, fetchedAt: 0 }" in decl, \
|
||||
"the disclosure lost the state its lazy rule reads"
|
||||
|
||||
# The poll path asks only while the card is open.
|
||||
refresh = js[js.index("async function refresh("):js.index("async function refreshEvents(")]
|
||||
assert "if (state.events.open) await refreshEvents();" in refresh, \
|
||||
"the poll loop fetches the event feed while the disclosure is shut"
|
||||
assert not re.search(r"^\s*await refreshEvents();", refresh, flags=re.M), \
|
||||
"an ungated refreshEvents() call survived in the poll path"
|
||||
|
||||
# …and the fetch stamps its own freshness, on the success path only: a failed fetch must
|
||||
# leave `painted`/`fetchedAt` alone so the next open re-fetches (invariant 4's shape).
|
||||
feed = _event_feed_path(js)
|
||||
assert "state.events.fetchedAt = Date.now();" in feed and "state.events.painted = true;" in feed, \
|
||||
"a successful fetch does not stamp the cache the re-expand rule depends on"
|
||||
assert feed.index("Dash.eventsPager(") < feed.index("state.events.fetchedAt = Date.now();"), \
|
||||
"the cache is stamped before the rows are drawn"
|
||||
assert feed.index("state.events.painted = true;") < feed.index("} catch (err)"), \
|
||||
"a failed fetch would mark the list as painted"
|
||||
|
||||
# The toggle is bound before the loop starts, so the first expand of the session is wired.
|
||||
boot = js[js.index("document.addEventListener('DOMContentLoaded'"):]
|
||||
handler = boot[boot.index("const eventsBox = $('.events-box');"):boot.index("state.poller = poll(")]
|
||||
assert boot.index("const eventsBox =") < boot.index("state.poller = poll("), \
|
||||
"the disclosure is wired after the first poll"
|
||||
assert "state.events.open = eventsBox.open;" in handler, \
|
||||
"the toggle never learns whether the card is open"
|
||||
assert "if (!eventsBox.open) return;" in handler, \
|
||||
"closing the card takes the fetch gate off"
|
||||
# first expand fetches; a re-expand paints the cache while it is one poll interval old
|
||||
assert "if (!state.events.painted || Date.now() - state.events.fetchedAt > state.pollMs) {" in handler, \
|
||||
"the expand lost its stale rule (one poll interval, the page's own cadence)"
|
||||
assert "refreshEvents();" in handler[handler.index("if (!state.events.painted"):], \
|
||||
"an open disclosure no longer fetches on a stale or empty cache"
|
||||
|
||||
|
||||
def test_the_ups_event_rows_keep_the_date_time_and_severity_shape(client) -> None:
|
||||
"""`TODO.md:4`'s other half: an expandable list is only *clear* if each row says when.
|
||||
|
||||
The disclosure is the new part of this phase; this is the half that had to survive it
|
||||
untouched. Every row still opens with a `<time>` carrying a month/day plus a 24-hour clock,
|
||||
then the UPS name inside the `sev <severity>` chip the stylesheet colours from the payload's
|
||||
own verdict, then the message through `eventText()` (subject named once) with the runtime
|
||||
footnote only when the event actually carries one. Pinned on the served script because there
|
||||
is no JS test runner (a locked anchor): a "tidy-up" of the row template that drops the
|
||||
timestamp, prints the raw message, or escapes nothing would ship silently otherwise.
|
||||
"""
|
||||
js = _code(client.get("/static/page-ups.js").text)
|
||||
feed = _event_feed_path(js)
|
||||
at = feed.index("row: (event) =>")
|
||||
row = feed[at:feed.index("});", at)]
|
||||
|
||||
# the date/time: `<time>` first, month + day + hour + minute, 24-hour (the operator reads
|
||||
# an incident log in the order it happened, and "14:05" on its own is not that)
|
||||
assert "<time>" in row and "</time>" in row, "the row lost its timestamp element"
|
||||
assert "new Date(event.ts * 1000).toLocaleString(" in row, \
|
||||
"the row formats something other than the event's own timestamp"
|
||||
for option in ("month: 'short'", "day: 'numeric'", "hour: '2-digit'", "minute: '2-digit'",
|
||||
"hour12: false"):
|
||||
assert option in row, f"the timestamp no longer asks for {option}"
|
||||
assert row.index("<time>") < row.index('class="sev'), "the row no longer leads with the date"
|
||||
|
||||
# who: the host inside the verdict chip, escaped, and the severity is the API's word
|
||||
assert 'class="sev ${event.severity}"' in row, \
|
||||
"the row stopped taking its colour word from the stored severity"
|
||||
assert "${DashCharts.escapeHtml(event.host)}</span>" in row, \
|
||||
"the row no longer names the UPS it belongs to, escaped"
|
||||
|
||||
# what: the message through the shared subject-said-once cleaner, and escaped
|
||||
assert "${DashCharts.escapeHtml(eventText(event))}" in row, \
|
||||
"the message is no longer cleaned and escaped (raw HTML from an upstream is a bug)"
|
||||
|
||||
# the runtime footnote is conditional: an event with no runtime says nothing about one
|
||||
assert "event.data && event.data.runtime_seconds" in row, \
|
||||
"the runtime footnote is no longer gated on the event carrying a runtime"
|
||||
assert "duration(event.data.runtime_seconds)} runtime)" in row, \
|
||||
"the runtime footnote stopped printing the stored seconds as a duration"
|
||||
|
||||
# and the renderer is still handed to the shared pager over the bounded list, labelled
|
||||
assert "Dash.eventsPager($('#events'), data.events, {" in feed, \
|
||||
"the feed left the shared pager (no window, no page, no accessible name)"
|
||||
assert "label: 'Power events'" in feed, "the scroll container lost its accessible name"
|
||||
assert "No power events yet" in feed, "the empty feed no longer says which feed is empty"
|
||||
|
||||
|
||||
def test_the_ups_events_disclosure_is_a_quiet_disclosure_not_a_new_component(client) -> None:
|
||||
"""The `/ups` summary borrows the card header's rhythm and no new palette (task 03).
|
||||
|
||||
Pinned on the served stylesheet because a CSS edit is invisible until someone looks: the
|
||||
`\\25B8`/`\\25BE` caret pair says open/closed in a **shape** as well as a colour, the webkit
|
||||
marker is hidden so the browser's own triangle does not double it, and this `<summary>`
|
||||
carries the card header's ground and bottom rule because it *replaces* that header instead of
|
||||
sitting inside the body like `charts-box`'s toggle does. The `> .body` padding restatement is
|
||||
the quiet one: `.card > .body` is a **child** selector, so a body moved inside a disclosure
|
||||
silently loses its padding and the feed renders flush to the card edge.
|
||||
|
||||
What the block must not do is grow a verdict colour (open/closed is not a grading) or
|
||||
re-state the shared feed rules — `ul.events`' bounded scroll and `.events-pager`'s strip are
|
||||
class-based and resolve inside the disclosure unchanged, so a second copy here would be a
|
||||
second thing to keep in sync. The undeclared-token audit itself stays the existing
|
||||
`test_no_stylesheet_rule_uses_an_undeclared_token`'s job, untouched.
|
||||
"""
|
||||
css = _css(client)
|
||||
start = css.index("details.events-box { ")
|
||||
block = css[start:css.index(".host-charts {", start)]
|
||||
|
||||
assert 'details.events-box > summary::before { content: "\\25B8"' in block, \
|
||||
"the toggle has no caret shape"
|
||||
assert 'details.events-box[open] > summary::before { content: "\\25BE"; }' in block, \
|
||||
"open and closed differ only in colour"
|
||||
assert "details.events-box > summary::-webkit-details-marker { display: none; }" in block, \
|
||||
"the browser's own marker doubles the caret"
|
||||
assert "cursor: pointer" in block and "list-style: none" in block, \
|
||||
"the summary no longer reads as a control"
|
||||
# it replaces the card header, so it carries the header's ground and bottom rule
|
||||
assert "border-bottom: 1px solid var(--border)" in block, \
|
||||
"a closed card no longer reads as its heading"
|
||||
assert "color-mix(in srgb, var(--bg-raised) 70%, var(--bg-sunken))" in block, \
|
||||
"the summary lost the header's sunken ground"
|
||||
# open: the label steps back, and no margin — the `.body` below carries the spacing
|
||||
assert "details.events-box[open] > summary { color: var(--text-faint); }" in block, \
|
||||
"an open summary does not de-emphasise"
|
||||
assert "margin-bottom" not in block, "the open summary stacks a gap on top of the body's"
|
||||
# the child-selector gotcha that task 01's markup introduced
|
||||
assert "details.events-box > .body { padding: .8rem; }" in block, \
|
||||
"the feed body lost its padding inside the disclosure"
|
||||
|
||||
for token in ("var(--ok)", "var(--warn)", "var(--fail)", "var(--accent)"):
|
||||
assert token not in block, f"the disclosure graded open/closed with {token}"
|
||||
assert ".sev-" not in block, "the disclosure invented a verdict family"
|
||||
|
||||
# one copy of each shared feed rule, class-based, resolving inside the disclosure as elsewhere
|
||||
assert css.count("ul.events {") == 1, "ul.events is styled twice — the bounded scroll drifted"
|
||||
assert css.count(".events-pager {") == 1, "the pager strip is styled twice"
|
||||
# the class this block styles is the class the page renders
|
||||
assert 'class="events-box"' in client.get("/ups").text, \
|
||||
"the stylesheet styles a disclosure no page renders"
|
||||
|
||||
|
||||
def test_the_pager_returns_to_page_1_when_the_events_change(client) -> None:
|
||||
"""New events reset the window; the same events do not.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user