phase: 26_ups_event_split
**Phase 26 `ups_event_split` — final verification pass (all 3 tasks already complete; nothing missing from `complete/`).**
Verified
- `page-ups.js`: `POWER_EVENT_KINDS = {status,test,action}` split, unconditional `refreshEvents()`, no `state.events`/toggle-fetch, shared `eventRow`, `#ups-errors-card.hidden = errors.length === 0`; `ups.html` error card above the disclosure, `hidden`, outside every `<details>`. Kind set cross-checked against the real writers (`collectors.py:393/409`, `api.py:1043/1058`, `poller.py:127/157/164/190`) — classification is correct.
- Re-ran the phase's browser pass live: `node data/logs/26-task03-verify.cjs` → **31 checks, 0 skipped, ALL PASS** (`data/logs/26-final-verify.out`) — poll-failure rows readable with the disclosure shut, card hidden on a power-only feed and appearing on the first `unreachable`, lists paging independently, 360 px clean.
Tests / lint (all green)
- `uv run pytest` → **1545 passed, 15 skipped**, exit 0 · `bash .agents/validate.sh` → exit 0, coverage **98.94%** (floor 94%) · `uv run ruff check src tests agent` clean · `uv run pyright` 0 errors · `node --check` clean · `uv run pytest -m live --live` (with `.env.dev`) → **11 passed**, dev server reads all 9 sources `ok`.
Completion criteria: poll-failure visible + power collapsed ✅ (pinned + browser); hidden-when-empty card, one `/api/events?source=ups` fetch, shared pager ✅; stale-last-good via the empty `catch` pin ✅; pytest/coverage/ruff/pyright ✅; `validate.sh` ✅; no behaviour change to prior work — `dash.css`/`app.js` untouched, disclosure CSS / pager / row-shape pins pass unchanged ✅.
Docs
- Fixed (pre-existing + this phase's drift): AGENTS.md "one helper with four call sites" → **five over four pages** and the `row(event)` sentence (phase 26 added a second `/ups` call site); invariant 5's `web/static/charts.js` → `src/dashboards/web/static/charts.js`; recorded the completion-pass transcript. README: "one helper renders every feed" now notes five lists. Full sweep otherwise consistent — all 38 env vars match `config.py` exactly, all served routes/scripts/contracts referenced exist, suite/coverage/live counts and marker splits verified.
Deviations: `fastapi 0.153.0 → 0.159.1` advisory left alone (validate.sh declares it advisory-not-a-gate; this phase touched no dependencies, matching prior phases' precedent). Next phase: **`27_mobile_tables_and_audit`**.
This commit is contained in:
+248
-61
@@ -1986,6 +1986,21 @@ def _event_feed_path(js: str) -> str:
|
||||
return js[js.index("async function refreshEvents("):]
|
||||
|
||||
|
||||
def _pager_call(feed: str, selector: str) -> str:
|
||||
"""The options object of one `Dash.eventsPager($('<selector>'), …)` call inside the feed path.
|
||||
|
||||
Sliced per list so an assertion can name *which* feed lost its renderer, its accessible name
|
||||
or its empty sentence. `/ups` is the page with two of them, so a whole-file assertion there
|
||||
cannot tell one list from the other.
|
||||
"""
|
||||
head = f"Dash.eventsPager($('{selector}')"
|
||||
assert head in feed, f"this feed is no longer rendered through Dash.eventsPager ({selector})"
|
||||
start = feed.index(head)
|
||||
end = feed.find("});", start)
|
||||
assert end != -1, f"the {selector} pager call is never closed"
|
||||
return feed[start:end]
|
||||
|
||||
|
||||
def _feed_fetch_url(js: str) -> str:
|
||||
"""The `/api/events` URL a feed asks for, quoted or templated."""
|
||||
found = re.search(r"fetchJSON\([`'](/api/events[^`']*)[`']\)", _event_feed_path(js))
|
||||
@@ -2072,15 +2087,36 @@ 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.
|
||||
def _details_blocks(html: str) -> list[str]:
|
||||
"""Every `<details>…</details>` block on a served page, in document order.
|
||||
|
||||
`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.
|
||||
Nesting is not a thing on these pages (one disclosure per card), so a flat scan of pairs is
|
||||
enough — and it is what lets a pin say *no disclosure contains this*, rather than only
|
||||
"not the one disclosure I happen to know about". Phase 26 needs exactly that: the operator's
|
||||
complaint was that a connection error was reachable only by opening something.
|
||||
"""
|
||||
blocks = []
|
||||
for found in re.finditer(r"<details\b[^>]*>", html):
|
||||
end = html.find("</details>", found.end())
|
||||
assert end != -1, "a served <details> is never closed"
|
||||
blocks.append(html[found.start():end])
|
||||
return blocks
|
||||
|
||||
|
||||
def test_ups_power_events_feed_sits_behind_a_collapsed_disclosure(client) -> None:
|
||||
"""The `/ups` power feed is *expandable*: one `details.events-box`, shut by default.
|
||||
|
||||
`TODO.md:4` asked for an expandable list of incidents and phase 26 kept that shape — what it
|
||||
overruled was *what* was behind it. A collapsed `<details>` renders no content, so the shut
|
||||
card is still the resting state; since phase 26 it gates **visibility only** (the feed is
|
||||
fetched on every poll, which the next test pins), and it holds only the power kinds.
|
||||
|
||||
Pinned on the served bytes because there is no JS test runner (a locked anchor): an `open`
|
||||
attribute would undo the phase silently, the `#events` id has to stay *inside* the disclosure
|
||||
or the pager and the bounded 320px scroll lose their target, and the **error** half
|
||||
(`#events-errors`) has to be outside *every* disclosure and ship `hidden` rather than open —
|
||||
a `NUT server NutError: cannot reach upsd …` line an operator has to click to read is the
|
||||
bug this phase exists to fix (`TODO.md:6`).
|
||||
"""
|
||||
html = _code(client.get("/ups").text)
|
||||
box = re.search(r'<details class="events-box">(.*?)</details>', html, re.S)
|
||||
@@ -2092,77 +2128,132 @@ def test_ups_power_events_feed_sits_behind_a_collapsed_disclosure(client) -> Non
|
||||
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 error half is on the page **and not collapsible**: no disclosure anywhere holds it.
|
||||
# The card-local pin in `test_the_ups_connection_errors_are_a_card_of_their_own_above_the_disclosure`
|
||||
# checks the card does not *contain* a `<details>`; this is the stronger direction — moving
|
||||
# that card inside a new disclosure somewhere else would pass there and fail here.
|
||||
assert 'id="events-errors"' in html, "the connection-error list left the served page"
|
||||
assert not any('id="events-errors"' in block for block in _details_blocks(html)), \
|
||||
"the error list is inside a disclosure — reading it needs a click again"
|
||||
card_open = html[html.index('<section class="card" id="ups-errors-card"'):
|
||||
html.index(">", html.index('<section class="card" id="ups-errors-card"'))]
|
||||
assert "hidden" in card_open, \
|
||||
"the error card's resting state is visible — an empty card on every quiet day"
|
||||
|
||||
# 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.
|
||||
# The event kinds `/ups` keeps behind the collapsed disclosure, and the writers that store
|
||||
# them. `collectors.py` writes `status` (an `ups.status` transition, power loss included)
|
||||
# and `test` (a self-test verdict); `api.py` writes `action` (a command this page sent).
|
||||
# Everything else in the feed is a connection/error event (`unreachable` / `reachable` /
|
||||
# `recording` / `store-error`) and belongs in the list that is never hidden.
|
||||
UPS_POWER_EVENT_KINDS = ("status", "test", "action")
|
||||
|
||||
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).
|
||||
|
||||
def _power_event_kinds(js: str) -> frozenset[str]:
|
||||
"""The kinds `page-ups.js` keeps behind the disclosure, read out of its own set literal.
|
||||
|
||||
Parsing beats matching one exact line. The claim is *these three and only these* — a kind
|
||||
added to the hidden list moves an operator-visible row out of sight, and a kind dropped from
|
||||
it hides a power event — so an exact-string assert that happens to survive a reformat is not
|
||||
the pin. An empty set is refused rather than returned, because it would make every event an
|
||||
error row and look like a passing classification.
|
||||
"""
|
||||
found = re.search(r"const POWER_EVENT_KINDS = new Set\(\[(.*?)]\)", js, re.S)
|
||||
assert found, "page-ups.js no longer defines POWER_EVENT_KINDS as a set literal of kind words"
|
||||
kinds = re.findall(r"['\"]([^'\"]+)['\"]", found.group(1))
|
||||
assert kinds, "POWER_EVENT_KINDS is empty — every event would land in the error list"
|
||||
return frozenset(kinds)
|
||||
|
||||
|
||||
def test_ups_events_are_fetched_on_every_poll(client) -> None:
|
||||
"""Phase 19's lazy gate is out, and the feed splits into power events and errors.
|
||||
|
||||
`TODO.md:6` is not a complaint about the disclosure existing, it is a complaint about what
|
||||
was behind it: a poll failure (`NUT server NutError: cannot reach upsd … (timed out after
|
||||
4s)`) sat in the same collapsed list as the power events, so the one thing an operator
|
||||
needs without opening anything was the one thing they had to open something for. The fix
|
||||
is page-side and keyed on the stored `kind`: `status` / `test` / `action` stay behind the
|
||||
disclosure, **everything else** is the always-visible error list — and because that list
|
||||
is visible, `refresh()` fetches on every poll (a visible list that ages is worse than one
|
||||
more 200-event fetch per interval). `/api/events` grew no vocabulary for any of this.
|
||||
|
||||
Pinned on the served script because there is no JS test runner (a locked anchor), and
|
||||
pinned in both directions: the gate's absence *and* the kind set, so a silent rename on
|
||||
the writer's side fails here instead of moving errors back into the hidden list.
|
||||
"""
|
||||
js = _code(client.get("/static/page-ups.js").text)
|
||||
|
||||
# The state the two paths share: closed, nothing painted yet, nothing fetched yet.
|
||||
# The kind set, read out of the literal instead of matched as one line: exactly these three.
|
||||
assert _power_event_kinds(js) == frozenset(UPS_POWER_EVENT_KINDS), \
|
||||
(f"the hidden list's kind set is {sorted(_power_event_kinds(js))}, not "
|
||||
f"{sorted(UPS_POWER_EVENT_KINDS)} — that classification is the operator's, not the page's")
|
||||
|
||||
# The lazy cache is gone: with an always-visible list there is nothing left to stamp.
|
||||
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"
|
||||
assert "events" not in decl, "the disclosure's lazy-fetch state came back"
|
||||
assert "state.events" not in js, "something still reads the deleted state.events object"
|
||||
|
||||
# The poll path asks only while the card is open.
|
||||
# The poll path asks every interval, gated on nothing.
|
||||
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"
|
||||
assert re.search(r"^\s*await refreshEvents\(\);", refresh, flags=re.M), \
|
||||
"the poll loop no longer fetches the feed on every poll"
|
||||
assert "state.events.open" not in refresh and "eventsBox" not in refresh, \
|
||||
"the fetch is gated on the disclosure again"
|
||||
|
||||
# …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.
|
||||
# The toggle handler's fetch logic is gone with it: the native `<details>` now gates only
|
||||
# visibility, so no `toggle` listener may exist to decide whether to fetch.
|
||||
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"
|
||||
assert "addEventListener('toggle'" not in boot, \
|
||||
"a toggle handler is deciding whether to fetch again"
|
||||
assert "eventsBox" not in boot, "the disclosure's fetch-on-expand handler came back"
|
||||
|
||||
# One fetch, two lists, split on `kind` — and the URL is the phase-19 one verbatim.
|
||||
feed = _event_feed_path(js)
|
||||
assert _feed_fetch_url(js) == "/api/events?limit=${Dash.EVENT_FEED_LIMIT}&source=ups", \
|
||||
"the feed's fetch is no longer the unchanged /api/events call"
|
||||
for vocabulary in ("kind=", "page=", "severity="):
|
||||
assert vocabulary not in _feed_fetch_url(js), \
|
||||
f"/api/events grew {vocabulary[:-1]} vocabulary for a page-side split"
|
||||
assert "const power = data.events.filter((e) => POWER_EVENT_KINDS.has(e.kind));" in feed, \
|
||||
"the power list is no longer the filter's positive arm"
|
||||
assert "const errors = data.events.filter((e) => !POWER_EVENT_KINDS.has(e.kind));" in feed, \
|
||||
"the error list is no longer the filter's default arm (an unknown kind must be visible)"
|
||||
|
||||
# Stale-last-good: the catch clause does *nothing*, so a poll that never answered leaves
|
||||
# both `<ul>`s holding the rows they already had (the pager rewrites a list only when its
|
||||
# markup changed — invariant 4's shape on the page side).
|
||||
opened = "} catch (err) {"
|
||||
at = feed.index(opened)
|
||||
catch_body = feed[at + len(opened):feed.index("}", at + len(opened))].strip()
|
||||
assert catch_body == "", \
|
||||
f"the failure path does something ({catch_body!r}) instead of leaving the last good rows"
|
||||
|
||||
|
||||
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.
|
||||
Phase 19's disclosure and phase 26's split are the new parts; this is the half that had to
|
||||
survive both 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.
|
||||
|
||||
Since phase 26 task 02 the renderer is **one const read by both lists**, so the two halves
|
||||
of one feed cannot grow two shapes — which is what this reads, rather than a `row:` option
|
||||
that could differ per list.
|
||||
"""
|
||||
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)]
|
||||
at = js.index("const eventRow = (event) =>")
|
||||
row = js[at:js.index("async function refreshEvents(", 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)
|
||||
@@ -2190,11 +2281,97 @@ def test_the_ups_event_rows_keep_the_date_time_and_severity_shape(client) -> Non
|
||||
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"
|
||||
# and the renderer is still handed to the shared pager over the bounded list, labelled.
|
||||
# Per **call**, not per file: `#events-errors` is a list of its own now, and the claim is
|
||||
# that it is drawn, named and emptied exactly like the power list — a count of `row:` in the
|
||||
# file would still pass if one call lost its renderer to a default.
|
||||
for selector, label, empty in (("#events", "Power events", "No power events yet"),
|
||||
("#events-errors", "Connection & errors",
|
||||
"No connection errors")):
|
||||
call = _pager_call(feed, selector)
|
||||
assert "row: eventRow," in call, f"the {selector} list is no longer drawn by the shared row"
|
||||
assert f"label: '{label}'" in call, f"the {selector} list lost its accessible name"
|
||||
assert empty in call, f"the {selector} list no longer says which feed is empty"
|
||||
|
||||
# one renderer, two lists: a template the two lists do not share would be two shapes for one
|
||||
# event, and the row may not branch on which list it landed in — the only split is the kind set
|
||||
assert js.count("const eventRow = (event) =>") == 1, \
|
||||
"the /ups lists no longer share one row template"
|
||||
assert "event.kind" not in row and "POWER_EVENT_KINDS" not in row, \
|
||||
"the row template reads the kind — the two lists are growing two shapes"
|
||||
|
||||
# The card's visibility is the one state this page claims about a list it cannot see, it is
|
||||
# decided by the **error** half alone, and only after both lists are painted (earlier would
|
||||
# flash the `loading…` seed on a first fetch with no error rows). The markup's own `hidden`
|
||||
# default is pinned in test_ups_power_events_feed_sits_behind_a_collapsed_disclosure.
|
||||
hidden_at = feed.index("$('#ups-errors-card').hidden")
|
||||
assert feed[:hidden_at].count("Dash.eventsPager(") == 2, \
|
||||
"the card's visibility is decided before both lists are drawn"
|
||||
assert feed[hidden_at:feed.index(";", hidden_at)].endswith("= errors.length === 0"), \
|
||||
"the error card's visibility no longer follows the error half of the feed"
|
||||
|
||||
|
||||
def test_the_ups_connection_errors_are_a_card_of_their_own_above_the_disclosure(client) -> None:
|
||||
"""Phase 26 task 02: the error half of the feed is a card, not a second list in a `<details>`.
|
||||
|
||||
`TODO.md:6` is that a `NUT server NutError: cannot reach upsd …` line is not something to
|
||||
open a summary for. So the error rows get their own `section.card` **above** the Power
|
||||
events disclosure, built from the vocabulary every other card on this page uses
|
||||
(`.card > header`, `.card > .body`, `ul.events`, the pager's strip) — **no new stylesheet
|
||||
selector**, because a second style for a second list is a second thing to keep in sync with
|
||||
the bounded 320px scroll and the `1\u201350 of N` footer.
|
||||
|
||||
`hidden` is the resting state in the markup and the script keeps it true while the feed has
|
||||
no non-power kinds: a permanent empty "Connection & errors" card is exactly the ink the
|
||||
page's empty-state rules exist to avoid, and the first `unreachable` row is what shows it.
|
||||
"""
|
||||
html = _code(client.get("/ups").text)
|
||||
|
||||
card_at = html.index('<section class="card" id="ups-errors-card"')
|
||||
card_end = html.index("</section>", card_at)
|
||||
card = html[card_at:card_end]
|
||||
opening = html[card_at:html.index(">", card_at)]
|
||||
assert "hidden" in opening, "the error card ships visible with nothing in it"
|
||||
assert '<ul class="events" id="events-errors"' in card, "the error card has no bounded feed"
|
||||
assert "<h3>Connection & errors</h3>" in card, "the card lost its title"
|
||||
# the whole point: this list is *not* behind a summary
|
||||
assert "<details" not in card, "the error list is inside a disclosure again"
|
||||
assert card_end < html.index('<details class="events-box">'), \
|
||||
"the error card sits below the power-events disclosure it is meant to lead"
|
||||
assert 'id="events"' in html[html.index('<details class="events-box">'):], \
|
||||
"the power list left its disclosure"
|
||||
|
||||
js = _code(client.get("/static/page-ups.js").text)
|
||||
feed = _event_feed_path(js)
|
||||
assert "Dash.eventsPager($('#events-errors'), errors, {" in feed, \
|
||||
"the error list is not rendered through the shared pager"
|
||||
assert "label: 'Connection & errors'" in feed, "the error list has no accessible name"
|
||||
assert "$('#ups-errors-card').hidden = errors.length === 0;" in feed, \
|
||||
"the error card no longer appears and disappears with its rows"
|
||||
# and the pager state is per `<ul>` (`app.js`: a `WeakMap` keyed by the list), so the two
|
||||
# lists page independently: this is the contract that makes a second call site free
|
||||
app_js = _code(client.get("/static/app.js").text)
|
||||
assert "const feedPagers = new WeakMap();" in app_js
|
||||
assert "feedPagers.get(list)" in app_js and "feedPagers.set(list, pager)" in app_js
|
||||
|
||||
|
||||
def test_the_ups_error_card_adds_no_stylesheet_selector(client) -> None:
|
||||
"""The second feed is markup + the existing vocabulary, and `dash.css` must stay that way.
|
||||
|
||||
Pinned as the *absence* of a rule, because the temptation this writes back into is a
|
||||
`#ups-errors-card` block restating the feed's scroll or the pager's strip: `ul.events {` and
|
||||
`.events-pager {` are each declared exactly once in the sheet (the disclosure pin already
|
||||
holds that), and the error card carries its own gap as an inline `margin-top` like every
|
||||
other section on this page rather than through an id rule.
|
||||
"""
|
||||
css = _css(client)
|
||||
for selector in ("#ups-errors-card", "#events-errors"):
|
||||
assert selector not in css, f"dash.css grew a rule for {selector}"
|
||||
assert css.count("ul.events {") == 1 and css.count(".events-pager {") == 1
|
||||
|
||||
html = client.get("/ups").text
|
||||
assert 'id="ups-errors-card" style="margin-top:.85rem"' in html, \
|
||||
"the error card no longer carries the page's own section gap inline"
|
||||
|
||||
|
||||
def test_the_ups_events_disclosure_is_a_quiet_disclosure_not_a_new_component(client) -> None:
|
||||
@@ -2250,6 +2427,16 @@ def test_the_ups_events_disclosure_is_a_quiet_disclosure_not_a_new_component(cli
|
||||
assert 'class="events-box"' in client.get("/ups").text, \
|
||||
"the stylesheet styles a disclosure no page renders"
|
||||
|
||||
# Phase 26's guard, here because it is the disclosure's own claim: the *second* feed this
|
||||
# page renders borrowed `section.card` + `ul.events` + `.events-pager` and nothing else, so
|
||||
# this block may not have grown a rule for it either. A second style for a second list is a
|
||||
# second copy of the bounded scroll and the pager strip to keep in sync — the phase-19
|
||||
# anti-pattern, one task later. (`test_the_ups_error_card_adds_no_stylesheet_selector` holds
|
||||
# the same guard for the card's own section, plus the inline-gap pin.)
|
||||
for selector in ("#ups-errors-card", "#events-errors"):
|
||||
assert selector not in block and selector not in css, \
|
||||
f"dash.css styles the second feed through {selector} instead of the shared classes"
|
||||
|
||||
|
||||
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