mirror of
https://github.com/l5yth/potato-mesh.git
synced 2026-07-06 18:01:19 +02:00
web: add visual feedback to pubsub events (#824)
This commit is contained in:
+121
@@ -1487,3 +1487,124 @@ only adds a `last_heard` carry to the same merge helpers), the #755 / #756
|
||||
synthetic-merge specs in `database_spec.rb` / `data_processing_spec.rb`, and **B1**
|
||||
(all suites). No POST/GET/event contract change, so the Python ingestor and
|
||||
`CONTRACTS.md` are unaffected.
|
||||
|
||||
---
|
||||
|
||||
## Feature: Live-update visual feedback (flash + control cleanup)
|
||||
|
||||
Maps to SPEC decisions **VF1–VF7**. Live SSE updates now flash the affected
|
||||
element white (<100 ms); the poll-era Refresh button and "last updated" field are
|
||||
removed (play/pause stays). The flash-trigger logic + a flash helper live under
|
||||
`web/public/assets/js/app/main/` (with co-located `__tests__`); the highlight
|
||||
keyframe lives in `web/public/assets/styles/base.css`; the only server change is an
|
||||
additive `nodes` publish on `POST /api/messages`
|
||||
(`web/lib/potato_mesh/application/routes/ingest.rb`). *Run the server in public
|
||||
mode for the curl checks; run JS suites from `web/`.*
|
||||
|
||||
### VF-A1 — Poll-era controls removed; play/pause kept — VF1
|
||||
```bash
|
||||
git grep -nE 'id="refreshBtn"|id="status"' -- web/views
|
||||
git grep -nE 'id="autorefreshToggle"' -- web/views/layouts/app.erb
|
||||
( cd web && bundle exec rspec spec/app_spec.rb -e "does not render the Refresh button or last-updated field" )
|
||||
```
|
||||
**Expected:** the first grep prints **no output** — the `#refreshBtn` button and the
|
||||
`#status` "last updated" field are gone from the views. The second prints the
|
||||
`#autorefreshToggle` line — the play/pause control remains. The rspec example
|
||||
passes: the rendered dashboard contains no `id="refreshBtn"` and no `id="status"`
|
||||
refresh-timestamp element, and still contains `id="autorefreshToggle"`. `main.js`
|
||||
no longer writes `refreshing…` / `updated <time>` status text (it has no `#status`
|
||||
element to write to).
|
||||
|
||||
### VF-A2 — Flash fires only on SSE-ping deltas, never on load/resync/poll — VF2
|
||||
```bash
|
||||
( cd web && node --test public/assets/js/app/__tests__/main-flash.test.js )
|
||||
```
|
||||
**Expected:** pass. With a fake `EventSource` + stub fetch: the **initial load**
|
||||
applies **no** flash (no strobe on paint); a subsequent SSE `change` ping for a
|
||||
collection flashes the affected element; a reconnect (`open` → resync) and a
|
||||
safety-poll refresh apply **no** flash. The flash is driven only from the
|
||||
SSE-ping-driven targeted refresh (`runLiveRefresh`), confirmed by asserting a
|
||||
resync/poll-shaped refresh leaves the flash count unchanged.
|
||||
|
||||
### VF-A3 — Correct element flashes per collection (incl. message⇒node) — VF3
|
||||
```bash
|
||||
( cd web && node --test public/assets/js/app/__tests__/main-flash.test.js )
|
||||
( cd web && bundle exec rspec spec/pubsub_spec.rb -e "publishes nodes on a message ingest" )
|
||||
```
|
||||
**Expected:** pass. A `nodes`/`positions`/`telemetry` ping flashes the affected
|
||||
node's **node-table row** (`[data-node-id]`) and **map marker**. A `messages` ping
|
||||
flashes the **message row** and the **channel tab header**; and because
|
||||
`POST /api/messages` **also publishes `nodes`** (extends PS4 — verified by the rspec
|
||||
example: a single message POST publishes both `messages` and `nodes`), the author
|
||||
node's row + marker flash too. `neighbors` / `traces` pings flash **nothing** (the
|
||||
documented out-of-scope boundary). Detection is by id/collection and identical for
|
||||
both protocols (Invariant IV).
|
||||
|
||||
### VF-A4 — Flash is applied after render, never to an unrendered element — VF4
|
||||
```bash
|
||||
( cd web && node --test public/assets/js/app/__tests__/main-flash.test.js )
|
||||
```
|
||||
**Expected:** pass. The flash is applied in a post-render step: a ping for a node
|
||||
not yet present in the DOM first renders/positions the row + marker (and a message
|
||||
renders its row + tab), and only then is the highlight applied — asserted by
|
||||
checking the flashed element exists and is the final rendered node at flash time
|
||||
(the render call precedes the flash call within the tick).
|
||||
|
||||
### VF-A5 — Brief (<100 ms), white, reduced-motion-aware highlight — VF5
|
||||
```bash
|
||||
( cd web && node --test public/assets/js/app/main/__tests__/flash.test.js )
|
||||
grep -nE '@media \(prefers-reduced-motion: reduce\)' web/public/assets/styles/base.css
|
||||
grep -nE '(animation|transition)[^;]*\b(9[0-9]|[1-9][0-9]?)ms' web/public/assets/styles/base.css
|
||||
```
|
||||
**Expected:** pass / non-empty. The flash helper applies a one-shot highlight class
|
||||
and clears it (or relies on a self-completing CSS animation) with **no layout
|
||||
shift**. `base.css` carries the highlight keyframe/rule with a duration **< 100 ms**
|
||||
and a `@media (prefers-reduced-motion: reduce)` guard that suppresses the animation
|
||||
(data still updates; only the visual is withheld). The white color and sub-100 ms
|
||||
duration are confirmed by reading the rule.
|
||||
|
||||
### VF-A6 — Render & cache invariants preserved; #822 holds — VF6
|
||||
```bash
|
||||
( cd web && node --test public/assets/js/app/__tests__/main-chat-render-incremental.test.js )
|
||||
( cd web && bundle exec rspec spec/app_spec.rb -e "updates node last_heard for plaintext messages" )
|
||||
```
|
||||
**Expected:** pass. With the flash code present, an **idle** re-render still
|
||||
materialises **0** entries and issues **0** per-node `/api/nodes/:id` requests
|
||||
(**CR-A1** unchanged — the flash touches only already-rendered/cached DOM and never
|
||||
re-materialises). The existing #822 example confirms a message ingest still bumps
|
||||
the author node's `last_heard` (also covered at the unit level by
|
||||
`data_processing_spec.rb` "advances the reconciled real node's last_heard when a
|
||||
chat message arrives"), which is what makes the message⇒node flash reflect real
|
||||
data. The seed-then-delta cache (FC-A2) is untouched.
|
||||
|
||||
### VF-A7 — Engineering bar — VF7
|
||||
```bash
|
||||
( cd web && bundle exec rspec ) && ( cd web && npm test )
|
||||
git ls-files 'web/public/assets/js/app/main/flash.js' \
|
||||
'web/public/assets/js/app/__tests__/main-flash.test.js' \
|
||||
| xargs grep -L 'Copyright © 2025-26 l5yth & contributors'
|
||||
```
|
||||
**Expected:** pass / no output. The Ruby and JS suites are green with new coverage
|
||||
for the flash trigger (changed-id selection, after-render ordering, ping-only
|
||||
gating, message⇒node fan-out), the flash helper, and the `nodes`-on-message publish.
|
||||
Every new source file carries the exact Apache header (B4a) and JSDoc (B3).
|
||||
|
||||
### VF-R1 — Regression: prior acceptance still holds
|
||||
```bash
|
||||
( cd web && npm test ) && ( cd web && bundle exec rspec )
|
||||
( . .venv/bin/activate && pytest -q tests/ )
|
||||
```
|
||||
**Expected:** every prior check still passes. **At risk and explicitly required to
|
||||
remain green:**
|
||||
- **CR-A1 / CR-A2 / CR-A3** (incremental render + map-only hydration — the flash
|
||||
never re-materialises or fetches per node).
|
||||
- **PS-A5 / PS-A7** (SSE targeted fetch + cache delta) and **FC-A2** (seed-then-delta
|
||||
— flashing is gated to SSE-ping deltas, so warm-start/resync/poll never flash).
|
||||
- **PS-A6 / A2 / A2a** (privacy — `/api/messages` still 404s in `PRIVATE`, so the new
|
||||
`nodes`-on-message publish is moot there; node events are not privacy-gated).
|
||||
- **PL-A1 / PL-A2** (progressive load), **A4c** (chat parity — same render path), and
|
||||
the **autorefresh/pause** specs (the toggle still pauses live + poll after the
|
||||
Refresh/status controls are removed).
|
||||
- **B1** (all suites). The only contract change is the additive `nodes` publish on
|
||||
message ingest (a new SSE event, documented in `CONTRACTS.md`); no POST/GET shape
|
||||
changes, so **C2** and the Python suite are unaffected.
|
||||
|
||||
@@ -436,3 +436,45 @@ untouched.
|
||||
| **PS6** | **Privacy: no `messages` events when PRIVATE (Invariant II).** Under `PRIVATE=1` the server emits **no `messages` change events** and the `GET /api/events` stream carries none, mirroring the `/api/messages` 404 (A2a). Because events are thin (no row data) and the client re-fetches through the already-filtered `/api` endpoints, opt-out / `CLIENT_HIDDEN` rows never traverse the push. Defense-in-depth; Invariant II is preserved. | interview + Invariant II |
|
||||
| **PS7** | **Amends FC2/FC-A2/FC-R1 cadence wording (named, not silent).** The seed-then-delta cache *mechanism* is unchanged (still `since=<high-water>`, only-misses-fetch, merge-by-id). Only the **trigger** changes: from a fixed 60 s cadence to event-driven (SSE ping / reconnect resync / slow safety poll). FC-A2 and FC-R1's "auto-refresh cadence is unchanged" clause is **explicitly amended** to "the fetch trigger is event-driven with a slow safety-poll fallback; the delta/merge/cache contract is unchanged." | interview (FC2 amendment) |
|
||||
| **PS8** | **Graceful degradation & engineering bar (D9).** If `EventSource` is unavailable, the stream errors, or a config flag disables it, the client silently falls back to the safety poll (today's network-only behavior) — the push is **never load-bearing**. All new code (the pub/sub registry, the `GET /api/events` route, the frontend SSE client) ships with 100% unit tests, full API docs (RDoc/JSDoc), the exact Apache header, and `rufo`-clean formatting; all existing suites stay green. | D9 + proposed |
|
||||
|
||||
---
|
||||
|
||||
## Feature: Live-update visual feedback (flash + control cleanup)
|
||||
|
||||
Builds on the SSE pub/sub feature (PS1–PS8) to make live updates *visible*: when a
|
||||
change arrives over SSE the affected element briefly flashes white, so a watching
|
||||
operator sees **where** an update landed without scanning. The poll-era controls
|
||||
(the "Refresh" button and the "last updated" timestamp) are removed, since push
|
||||
makes them redundant; the play/pause toggle stays. Frontend-only (vanilla JS +
|
||||
`base.css`) except one additive server change: `POST /api/messages` also publishes
|
||||
a `nodes` change event (because a message ingest already touches the author node's
|
||||
`last_heard`, #822). Integrates with `views/layouts/app.erb` (control removal),
|
||||
`web/public/assets/js/app/main.js` (`refresh`/`runLiveRefresh` wiring, node-table +
|
||||
map-marker + chat render paths), `chat-tabs.js` (tab header), `node-rendering.js`
|
||||
(`data-node-id` rows), a new flash helper under `web/public/assets/js/app/main/`
|
||||
(+ `__tests__`), `web/public/assets/styles/base.css` (the highlight keyframe), and
|
||||
`web/lib/potato_mesh/application/routes/ingest.rb` (the `nodes`-on-message publish).
|
||||
|
||||
**Conflict check against existing decisions.** *PS5 (push replaced poll)* —
|
||||
**realizes**: removing the Refresh button + "last updated" field is PS5's UI
|
||||
consequence. *PS4 (publish-on-change)* — **extends**: the messages route now also
|
||||
publishes `nodes` (the table it already writes via #822). *PS6 / Invariant II
|
||||
(privacy)* — **consistent**: in `PRIVATE` the message route 404s before publish, so
|
||||
no `messages`/`nodes` event fires from a message; node events are not privacy-gated.
|
||||
*CR-A1/CR-A2/CR-A3 (incremental render)* — **consistent (guarded)**: the flash
|
||||
touches only already-rendered/cached DOM after render, never re-materializing, so an
|
||||
idle tick still materializes 0 entries. *FC-A2 / PS-A7 (seed-then-delta)* —
|
||||
**consistent**: flashing is gated to SSE-ping deltas, so cold/warm seed, resync, and
|
||||
the safety poll never flash (no strobe). *Apex I / §3.3 / Invariant IV / A4c / D1* —
|
||||
**consistent**: read-side visual + one in-process publish call; protocol-neutral; no
|
||||
ingest path, no `/version` change. No invariant is contradicted.
|
||||
|
||||
| # | Decision | Source |
|
||||
| --- | --- | --- |
|
||||
| **VF1** | **Remove poll-era controls.** The `#refreshBtn` button and the `#status` "last updated" field are removed from the dashboard (along with their `main.js` handlers, the `refreshing…`/`updated <time>` status writes, and the `.refresh-timestamp` style). The `#autorefreshToggle` play/pause control is **kept** — it still pauses both the live stream and the safety poll (PS5). Manual refresh and a timestamp are redundant once push delivers updates. | interview |
|
||||
| **VF2** | **Flash only on SSE-ping deltas.** The white highlight fires **only** for rows delivered by an SSE-ping-driven targeted refresh (`runLiveRefresh`). The initial load (cold **or** warm-cache catch-up), the reconnect resync, and the slow safety poll do **not** flash — so the screen never strobes on load and a flash unambiguously means "this changed live while you were watching." Accepted limit: a change recovered *only* by resync or the safety poll updates without a flash. | interview |
|
||||
| **VF3** | **What flashes, per collection.** A `nodes` / `positions` / `telemetry` ping flashes the affected node's **map marker and node-table row** (the changed node ids are read from the ping's delta rows by `node_id`). A `messages` ping flashes the **message row** (the whole row) and the **channel tab header**; because the message ingest also touches the author node (#822), `POST /api/messages` **additionally publishes `nodes`** (extends PS4), so the author node's marker + table row flash too, with a freshly-updated "last seen". Detection is by id/collection, identical for both protocols (Invariant IV). **Scope:** `neighbors` / `traces` updates do **not** flash (they update silently) — a deliberate, documented boundary, deferrable to a follow-up. | interview |
|
||||
| **VF4** | **Render before flash.** The highlight is applied **after** the affected DOM is rendered and positioned in the same update tick — the node-table row + map marker, and the chat message row + channel tab, are materialized/placed first, then flashed (a post-render `requestAnimationFrame`/microtask step). This guarantees the flash lands on the final, placed element and is never applied to a not-yet-rendered element or an element still at its old position/offscreen. | interview |
|
||||
| **VF5** | **Brief, accessible highlight.** The flash is **white**, lasts **<100 ms**, and is a one-shot CSS highlight with **no layout shift** (e.g. background/overlay only). It **respects `prefers-reduced-motion`**: when the user has reduce-motion enabled the highlight is suppressed via an `@media (prefers-reduced-motion: reduce)` guard — the data still updates live, only the animation is withheld. | interview |
|
||||
| **VF6** | **Preserve render & cache invariants (read-side only).** The flash is applied to **already-rendered/cached** DOM nodes (the chat entry cache, existing table rows, existing markers); it **never** re-materializes chat entries or issues per-node fetches, so an idle tick still materializes **0** entries (CR-A1) and the seed-then-delta cache (FC-A2) is untouched. The only non-frontend change is the additive `nodes` publish on message ingest, which is moot under `PRIVATE` (the message route 404s first), preserving Invariant II / PS6. | proposed |
|
||||
| **VF7** | **Engineering bar (D9).** The flash-trigger logic (changed-id selection, after-render ordering, SSE-ping-only gating, message⇒node fan-out) ships with 100% unit tests, JSDoc, and the exact Apache header; the `ingest.rb` `nodes`-on-message publish is covered by a Ruby spec; existing view/app specs that assert the removed controls are **updated** to assert their absence (not deleted). CSS keyframes have no gating command, so the criterion is the trigger logic + a view assertion; all existing suites stay green. | D9 + proposed |
|
||||
|
||||
@@ -291,6 +291,11 @@ data: {"collection":"messages","hint":1700000000}
|
||||
`neighbors`, `traces` — exactly the dashboard ingest collections. The client
|
||||
reacts by re-running its existing delta fetch (`GET /api/<collection>?since=…`)
|
||||
and merging by id; no row data is delivered over the stream.
|
||||
- **A `POST /api/messages` ingest publishes *two* events — `messages` and
|
||||
`nodes`** — because a message also touches the author node's `last_heard`
|
||||
(#822). One ingest route may therefore emit more than one collection event; a
|
||||
client must handle each event independently and must not assume a 1:1
|
||||
route→event mapping.
|
||||
- **`hint`** (optional integer) is the newest `rx_time`/`last_heard` seen for the
|
||||
collection — a skip hint; the client may ignore it and use its own high-water
|
||||
mark. It is currently not emitted by the server (reserved).
|
||||
|
||||
@@ -78,8 +78,13 @@ module PotatoMesh
|
||||
messages.each do |msg|
|
||||
insert_message(db, msg, protocol_cache: protocol_cache)
|
||||
end
|
||||
PotatoMesh::App::ApiCache.invalidate_prefix("api:messages:", "api:stats:")
|
||||
# A message ingest also touches the author node's last_heard (#822),
|
||||
# so invalidate the nodes cache and publish a nodes change in addition
|
||||
# to messages — the dashboard then refreshes (and flashes) that node.
|
||||
# Mirrors how the positions route invalidates api:nodes:.
|
||||
PotatoMesh::App::ApiCache.invalidate_prefix("api:messages:", "api:nodes:", "api:stats:")
|
||||
PotatoMesh::App::PubSub.publish("messages", private_mode: private_mode?)
|
||||
PotatoMesh::App::PubSub.publish("nodes", private_mode: private_mode?)
|
||||
status 201
|
||||
{ status: "ok" }.to_json
|
||||
ensure
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
/*
|
||||
* Copyright © 2025-26 l5yth & contributors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* VF-A2/VF-A3 — live-update flash is driven *only* by SSE-ping deltas, and a
|
||||
* node/position/telemetry ping flashes the affected node. Verified via the flash
|
||||
* counter + the ids handed to the flash (the row/marker DOM application itself is
|
||||
* unit-tested in main/__tests__/flash.test.js).
|
||||
*/
|
||||
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { runLiveApp, DEFAULT_RESPONSES } from './sse-app-harness.js';
|
||||
|
||||
const NOW = Math.floor(Date.now() / 1000);
|
||||
|
||||
function ping(FakeEventSource, collection) {
|
||||
FakeEventSource.instances[0].dispatch('change', { data: JSON.stringify({ collection }) });
|
||||
}
|
||||
|
||||
test('the initial load does not flash (no strobe on paint)', async () => {
|
||||
await runLiveApp({}, async ({ testUtils }) => {
|
||||
assert.equal(testUtils.getLiveFlashCount(), 0);
|
||||
});
|
||||
});
|
||||
|
||||
test('a nodes ping flashes the changed node', async () => {
|
||||
await runLiveApp({}, async ({ testUtils, FakeEventSource }) => {
|
||||
ping(FakeEventSource, 'nodes');
|
||||
await testUtils.flushLiveRefresh();
|
||||
assert.equal(testUtils.getLiveFlashCount(), 1);
|
||||
assert.deepEqual(testUtils.getLastFlashedNodeIds(), ['!a']);
|
||||
});
|
||||
});
|
||||
|
||||
test('a positions ping flashes the position\'s node', async () => {
|
||||
const responses = {
|
||||
...DEFAULT_RESPONSES,
|
||||
'/api/positions': [{ id: 1, node_id: '!p', rx_time: NOW, latitude: 1, longitude: 2 }],
|
||||
};
|
||||
await runLiveApp({ responses }, async ({ testUtils, FakeEventSource }) => {
|
||||
ping(FakeEventSource, 'positions');
|
||||
await testUtils.flushLiveRefresh();
|
||||
assert.deepEqual(testUtils.getLastFlashedNodeIds(), ['!p']);
|
||||
});
|
||||
});
|
||||
|
||||
test('a telemetry ping flashes the telemetry node', async () => {
|
||||
const responses = {
|
||||
...DEFAULT_RESPONSES,
|
||||
'/api/telemetry': [{ id: 1, node_id: '!t', rx_time: NOW, battery_level: 80 }],
|
||||
};
|
||||
await runLiveApp({ responses }, async ({ testUtils, FakeEventSource }) => {
|
||||
ping(FakeEventSource, 'telemetry');
|
||||
await testUtils.flushLiveRefresh();
|
||||
assert.deepEqual(testUtils.getLastFlashedNodeIds(), ['!t']);
|
||||
});
|
||||
});
|
||||
|
||||
test('a messages ping flashes the message', async () => {
|
||||
await runLiveApp({}, async ({ testUtils, FakeEventSource }) => {
|
||||
ping(FakeEventSource, 'messages');
|
||||
await testUtils.flushLiveRefresh();
|
||||
assert.deepEqual(testUtils.getLastFlashedMessageIds(), ['1']);
|
||||
});
|
||||
});
|
||||
|
||||
test('a message (server publishes messages + nodes) flashes both the message and its author node', async () => {
|
||||
await runLiveApp({}, async ({ testUtils, FakeEventSource }) => {
|
||||
const es = FakeEventSource.instances[0];
|
||||
// The server publishes BOTH on a message ingest (#822 / PS4 extension).
|
||||
es.dispatch('change', { data: JSON.stringify({ collection: 'messages' }) });
|
||||
es.dispatch('change', { data: JSON.stringify({ collection: 'nodes' }) });
|
||||
await testUtils.flushLiveRefresh();
|
||||
assert.deepEqual(testUtils.getLastFlashedMessageIds(), ['1']);
|
||||
assert.deepEqual(testUtils.getLastFlashedNodeIds(), ['!a']);
|
||||
});
|
||||
});
|
||||
|
||||
test('a reconnect resync does not flash (VF2 — SSE pings only)', async () => {
|
||||
await runLiveApp({}, async ({ testUtils, FakeEventSource }) => {
|
||||
FakeEventSource.instances[0].dispatch('open', {}); // resync = full refresh, no flash
|
||||
await testUtils.flushLiveRefresh();
|
||||
assert.equal(testUtils.getLiveFlashCount(), 0);
|
||||
});
|
||||
});
|
||||
|
||||
test('a neighbors ping flashes nothing (out of scope, VF3)', async () => {
|
||||
const responses = {
|
||||
...DEFAULT_RESPONSES,
|
||||
'/api/neighbors': [{ node_id: '!n', neighbor_id: '!m', rx_time: NOW }],
|
||||
};
|
||||
await runLiveApp({ responses }, async ({ testUtils, FakeEventSource }) => {
|
||||
ping(FakeEventSource, 'neighbors');
|
||||
await testUtils.flushLiveRefresh();
|
||||
assert.equal(testUtils.getLiveFlashCount(), 0);
|
||||
});
|
||||
});
|
||||
@@ -23,117 +23,7 @@
|
||||
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createDomEnvironment } from './dom-environment.js';
|
||||
import { initializeApp } from '../main.js';
|
||||
|
||||
const NOW = Math.floor(Date.now() / 1000);
|
||||
|
||||
const BASE_CONFIG = Object.freeze({
|
||||
channel: 'Primary',
|
||||
frequency: '915MHz',
|
||||
refreshMs: 60_000,
|
||||
refreshIntervalSeconds: 60,
|
||||
safetyPollMs: 300_000,
|
||||
liveUpdatesEnabled: true,
|
||||
liveUpdatesPath: '/api/events',
|
||||
chatEnabled: true,
|
||||
mapCenter: { lat: 0, lon: 0 },
|
||||
mapZoom: null,
|
||||
maxDistanceKm: 0,
|
||||
tileFilters: { light: '', dark: '' },
|
||||
instancesFeatureEnabled: false,
|
||||
instanceDomain: null,
|
||||
snapshotWindowSeconds: 3600,
|
||||
});
|
||||
|
||||
const NODES = [
|
||||
{ node_id: '!a', short_name: 'A', long_name: 'Node A', last_heard: NOW, protocol: 'meshtastic' },
|
||||
];
|
||||
const MESSAGES = [
|
||||
{ id: 1, channel: 0, from_id: '!a', to_id: '^all', text: 'hello', rx_time: NOW, protocol: 'meshtastic' },
|
||||
];
|
||||
|
||||
/** Build a recording stub fetch answering from a URL-substring map. */
|
||||
function buildStubFetch(responses) {
|
||||
const calls = [];
|
||||
return {
|
||||
calls,
|
||||
fetch(url) {
|
||||
calls.push({ url });
|
||||
for (const [prefix, body] of Object.entries(responses)) {
|
||||
if (url.includes(prefix)) {
|
||||
return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve(body) });
|
||||
}
|
||||
}
|
||||
return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve([]) });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** A fresh fake EventSource class recording listeners + instances. */
|
||||
function makeFakeEventSource() {
|
||||
class FakeEventSource {
|
||||
constructor(url) {
|
||||
this.url = url;
|
||||
this.listeners = {};
|
||||
FakeEventSource.instances.push(this);
|
||||
}
|
||||
|
||||
addEventListener(type, fn) {
|
||||
(this.listeners[type] = this.listeners[type] || []).push(fn);
|
||||
}
|
||||
|
||||
close() {
|
||||
this.closed = true;
|
||||
}
|
||||
|
||||
dispatch(type, event) {
|
||||
(this.listeners[type] || []).forEach((fn) => fn(event));
|
||||
}
|
||||
}
|
||||
FakeEventSource.instances = [];
|
||||
return FakeEventSource;
|
||||
}
|
||||
|
||||
/**
|
||||
* Boot one app instance with a fake EventSource + stub fetch (cache disabled),
|
||||
* await the initial load, run the body, then tear the timer/stream down.
|
||||
*/
|
||||
async function runLiveApp({ configOverrides = {}, withEventSource = true, responses } = {}, fn) {
|
||||
const env = createDomEnvironment({ includeBody: true });
|
||||
env.registerElement('chat', env.createElement('div', 'chat'));
|
||||
const originalFetch = globalThis.fetch;
|
||||
const originalES = globalThis.EventSource;
|
||||
const originalIdb = globalThis.indexedDB;
|
||||
const { fetch: stubFetch, calls } = buildStubFetch(
|
||||
responses || { '/api/nodes': NODES, '/api/messages': MESSAGES },
|
||||
);
|
||||
globalThis.fetch = stubFetch;
|
||||
globalThis.indexedDB = undefined; // disable the persistent cache for a clean cold path
|
||||
const FakeEventSource = makeFakeEventSource();
|
||||
if (withEventSource) globalThis.EventSource = FakeEventSource;
|
||||
else delete globalThis.EventSource;
|
||||
let testUtils = null;
|
||||
try {
|
||||
({ _testUtils: testUtils } = initializeApp({ ...BASE_CONFIG, ...configOverrides }));
|
||||
await testUtils.initialLoad;
|
||||
await testUtils.flushBackfill();
|
||||
await fn({ calls, testUtils, FakeEventSource });
|
||||
} finally {
|
||||
// Let fire-and-forget refresh tails (the stats footer update at main.js
|
||||
// `void fetchActiveNodeStats(...).then(...)`, cache write-back) settle
|
||||
// against the live DOM before teardown, so no async work touches a
|
||||
// torn-down document.
|
||||
if (testUtils) await testUtils.flushCacheWrites();
|
||||
for (let i = 0; i < 3; i += 1) await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
if (testUtils) testUtils.stopAutoRefresh(); // clear the armed interval so node can exit
|
||||
globalThis.fetch = originalFetch;
|
||||
if (originalES === undefined) delete globalThis.EventSource;
|
||||
else globalThis.EventSource = originalES;
|
||||
globalThis.indexedDB = originalIdb;
|
||||
env.cleanup();
|
||||
}
|
||||
}
|
||||
import { runLiveApp } from './sse-app-harness.js';
|
||||
|
||||
test('live updates active: SSE stream opens and the cadence is the slow safety poll', async () => {
|
||||
await runLiveApp({}, async ({ testUtils, FakeEventSource }) => {
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
/*
|
||||
* Copyright © 2025-26 l5yth & contributors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Shared harness for driving `initializeApp` over a fake `EventSource` + stub
|
||||
* `fetch` (cache disabled), used by the SSE live-update and flash test suites.
|
||||
*
|
||||
* @module __tests__/sse-app-harness
|
||||
*/
|
||||
|
||||
import { createDomEnvironment } from './dom-environment.js';
|
||||
import { initializeApp } from '../main.js';
|
||||
|
||||
const NOW = Math.floor(Date.now() / 1000);
|
||||
|
||||
/** App config with live updates enabled and a slow safety poll. */
|
||||
export const SSE_BASE_CONFIG = Object.freeze({
|
||||
channel: 'Primary',
|
||||
frequency: '915MHz',
|
||||
refreshMs: 60_000,
|
||||
refreshIntervalSeconds: 60,
|
||||
safetyPollMs: 300_000,
|
||||
liveUpdatesEnabled: true,
|
||||
liveUpdatesPath: '/api/events',
|
||||
chatEnabled: true,
|
||||
mapCenter: { lat: 0, lon: 0 },
|
||||
mapZoom: null,
|
||||
maxDistanceKm: 0,
|
||||
tileFilters: { light: '', dark: '' },
|
||||
instancesFeatureEnabled: false,
|
||||
instanceDomain: null,
|
||||
snapshotWindowSeconds: 3600,
|
||||
});
|
||||
|
||||
/** Default stub-fetch payloads keyed by URL substring. */
|
||||
export const DEFAULT_RESPONSES = Object.freeze({
|
||||
'/api/nodes': [
|
||||
{ node_id: '!a', short_name: 'A', long_name: 'Node A', last_heard: NOW, protocol: 'meshtastic' },
|
||||
],
|
||||
'/api/messages': [
|
||||
{ id: 1, channel: 0, from_id: '!a', to_id: '^all', text: 'hello', rx_time: NOW, protocol: 'meshtastic' },
|
||||
],
|
||||
});
|
||||
|
||||
/**
|
||||
* Build a recording stub fetch answering from a URL-substring map.
|
||||
*
|
||||
* @param {Object<string, *>} responses URL-substring → JSON body.
|
||||
* @returns {{ fetch: Function, calls: Array<{ url: string }> }}
|
||||
*/
|
||||
export function buildStubFetch(responses) {
|
||||
const calls = [];
|
||||
return {
|
||||
calls,
|
||||
fetch(url) {
|
||||
calls.push({ url });
|
||||
for (const [prefix, body] of Object.entries(responses)) {
|
||||
if (url.includes(prefix)) {
|
||||
return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve(body) });
|
||||
}
|
||||
}
|
||||
return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve([]) });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a fresh fake EventSource class recording listeners + instances so a
|
||||
* test can dispatch `open` / `change` / `error` events.
|
||||
*
|
||||
* @returns {Function} the FakeEventSource constructor (with `.instances`).
|
||||
*/
|
||||
export function makeFakeEventSource() {
|
||||
class FakeEventSource {
|
||||
constructor(url) {
|
||||
this.url = url;
|
||||
this.listeners = {};
|
||||
FakeEventSource.instances.push(this);
|
||||
}
|
||||
|
||||
addEventListener(type, fn) {
|
||||
(this.listeners[type] = this.listeners[type] || []).push(fn);
|
||||
}
|
||||
|
||||
close() {
|
||||
this.closed = true;
|
||||
}
|
||||
|
||||
dispatch(type, event) {
|
||||
(this.listeners[type] || []).forEach((fn) => fn(event));
|
||||
}
|
||||
}
|
||||
FakeEventSource.instances = [];
|
||||
return FakeEventSource;
|
||||
}
|
||||
|
||||
/**
|
||||
* Boot one app instance with a fake EventSource + stub fetch (cache disabled),
|
||||
* await the initial load, run the body, then tear the timer/stream down so the
|
||||
* Node test runner can exit cleanly.
|
||||
*
|
||||
* @param {{ configOverrides?: Object, withEventSource?: boolean, responses?: Object }} [opts]
|
||||
* @param {(ctx: { calls: Array, testUtils: Object, FakeEventSource: Function }) => Promise<void>} fn Body.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function runLiveApp({ configOverrides = {}, withEventSource = true, responses } = {}, fn) {
|
||||
const env = createDomEnvironment({ includeBody: true });
|
||||
env.registerElement('chat', env.createElement('div', 'chat'));
|
||||
const originalFetch = globalThis.fetch;
|
||||
const originalES = globalThis.EventSource;
|
||||
const originalIdb = globalThis.indexedDB;
|
||||
const { fetch: stubFetch, calls } = buildStubFetch(responses || { ...DEFAULT_RESPONSES });
|
||||
globalThis.fetch = stubFetch;
|
||||
globalThis.indexedDB = undefined; // disable the persistent cache for a clean cold path
|
||||
const FakeEventSource = makeFakeEventSource();
|
||||
if (withEventSource) globalThis.EventSource = FakeEventSource;
|
||||
else delete globalThis.EventSource;
|
||||
let testUtils = null;
|
||||
try {
|
||||
({ _testUtils: testUtils } = initializeApp({ ...SSE_BASE_CONFIG, ...configOverrides }));
|
||||
await testUtils.initialLoad;
|
||||
await testUtils.flushBackfill();
|
||||
await fn({ calls, testUtils, FakeEventSource });
|
||||
} finally {
|
||||
// Let fire-and-forget refresh tails (the stats footer update, cache write)
|
||||
// settle against the live DOM before teardown.
|
||||
if (testUtils) await testUtils.flushCacheWrites();
|
||||
for (let i = 0; i < 3; i += 1) await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
if (testUtils) testUtils.stopAutoRefresh();
|
||||
globalThis.fetch = originalFetch;
|
||||
if (originalES === undefined) delete globalThis.EventSource;
|
||||
else globalThis.EventSource = originalES;
|
||||
globalThis.indexedDB = originalIdb;
|
||||
env.cleanup();
|
||||
}
|
||||
}
|
||||
@@ -193,6 +193,8 @@ import { buildNeighborTooltipHtml, buildTraceTooltipHtml } from './main/tooltip-
|
||||
import { createOfflineTileLayer as createOfflineTileLayerImpl } from './main/offline-tile-layer.js';
|
||||
import { getActiveFullscreenElement, legendClickHandler } from './main/fullscreen-helpers.js';
|
||||
import { createEventStream } from './main/event-stream.js';
|
||||
import { flashNodeTargets, flashMessageTargets } from './main/flash.js';
|
||||
import { collectNodeIds, collectMessageIds, entryMessageId } from './main/flash-targets.js';
|
||||
|
||||
/**
|
||||
* Entry point for the interactive dashboard. Wires up event listeners,
|
||||
@@ -213,9 +215,7 @@ import { createEventStream } from './main/event-stream.js';
|
||||
* inner closures for unit tests. Production callers may ignore this.
|
||||
*/
|
||||
export function initializeApp(config) {
|
||||
const statusEl = document.getElementById('status');
|
||||
const footerActiveNodes = document.getElementById('footerActiveNodes');
|
||||
const refreshBtn = document.getElementById('refreshBtn');
|
||||
const autorefreshToggle = document.getElementById('autorefreshToggle');
|
||||
const protocolToggleMeshcore = document.getElementById('protocolToggleMeshcore');
|
||||
const protocolToggleMeshtastic = document.getElementById('protocolToggleMeshtastic');
|
||||
@@ -568,6 +568,14 @@ export function initializeApp(config) {
|
||||
let liveRefreshTimer = null;
|
||||
/** Promise of the most recent live-driven refresh (test hook). */
|
||||
let liveRefreshPromise = Promise.resolve();
|
||||
/** Count of flash rounds triggered by SSE pings (VF2 gating; test hook). */
|
||||
let liveFlashCount = 0;
|
||||
/** Node ids flashed by the most recent SSE-ping refresh (test hook). */
|
||||
let lastFlashedNodeIds = [];
|
||||
/** Message ids flashed by the most recent SSE-ping refresh (test hook). */
|
||||
let lastFlashedMessageIds = [];
|
||||
/** Per-render map of message id → its channel tab id, for the tab-header flash. */
|
||||
let messageTabId = new Map();
|
||||
|
||||
/**
|
||||
* Close any open short-info overlays that do not contain the provided anchor.
|
||||
@@ -707,10 +715,44 @@ export function initializeApp(config) {
|
||||
liveRefreshTimer = null;
|
||||
const collections = new Set(dirtyCollections);
|
||||
dirtyCollections.clear();
|
||||
liveRefreshPromise = refresh({ collections });
|
||||
// flash: true marks this as the SSE-ping path, the only refresh that
|
||||
// flashes changed rows (SPEC VF2). Resync / safety poll / initial load
|
||||
// call refresh() without it, so they never flash.
|
||||
liveRefreshPromise = refresh({ collections, flash: true });
|
||||
return liveRefreshPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flash each changed node's table row(s) and map marker white (SPEC VF3).
|
||||
* Called after the table + map have rendered so the highlight lands on the
|
||||
* final element. Targets already-rendered DOM only — it never re-materialises
|
||||
* rows or fetches, so the incremental-render invariants (CR-A1) are preserved.
|
||||
*
|
||||
* @param {Set<string>} nodeIds Canonical node ids to flash.
|
||||
* @returns {void}
|
||||
*/
|
||||
function flashChangedNodes(nodeIds) {
|
||||
if (!nodeIds || nodeIds.size === 0) return;
|
||||
// Record that a flash round was triggered (VF2 gating — test hook).
|
||||
liveFlashCount += 1;
|
||||
lastFlashedNodeIds = [...nodeIds];
|
||||
flashNodeTargets(nodeIds, { documentRef: document, markerByNodeId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Flash each changed message's chat row(s) and its channel tab header (SPEC
|
||||
* VF3). Called after the chat has rendered (so the rows + tabs exist and the
|
||||
* message→tab map is populated). Targets already-rendered DOM only.
|
||||
*
|
||||
* @param {Set<string>} messageIds Message ids to flash.
|
||||
* @returns {void}
|
||||
*/
|
||||
function flashChangedMessages(messageIds) {
|
||||
if (!messageIds || messageIds.size === 0) return;
|
||||
lastFlashedMessageIds = [...messageIds];
|
||||
flashMessageTargets(messageIds, { documentRef: document, messageTabId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Flag a collection dirty in response to an SSE change ping and arm the
|
||||
* debounce timer so a burst of pings collapses into one delta fetch.
|
||||
@@ -853,6 +895,9 @@ export function initializeApp(config) {
|
||||
let traceLinesToggleButton = null;
|
||||
let markersLayer = null;
|
||||
let spiderLinesLayer = null;
|
||||
// Per-render map of canonical node id → its Leaflet marker, so a live update
|
||||
// can flash the marker for a changed node (SPEC VF3). Rebuilt every renderMap.
|
||||
let markerByNodeId = new Map();
|
||||
// Per-render record of the offset markers we created so the zoom event
|
||||
// handlers can re-project them and keep the on-screen pixel gap constant
|
||||
// regardless of zoom level. Each entry is
|
||||
@@ -3258,6 +3303,9 @@ export function initializeApp(config) {
|
||||
filterQuery = ''
|
||||
}) {
|
||||
if (!CHAT_ENABLED || !chatEl) return;
|
||||
// Reset the message→tab map for this render; buildChatFragment repopulates it
|
||||
// as it materialises each channel tab's entries (SPEC VF3 tab flash).
|
||||
messageTabId = new Map();
|
||||
const combinedMessages = Array.isArray(messages) ? [...messages] : [];
|
||||
if (Array.isArray(encryptedMessages) && encryptedMessages.length > 0) {
|
||||
combinedMessages.push(...encryptedMessages);
|
||||
@@ -3414,6 +3462,14 @@ export function initializeApp(config) {
|
||||
continue;
|
||||
}
|
||||
const node = chatEntryCache.materialize(namespace, keyOf(entry), parts.className, parts.html);
|
||||
// Tag message rows so a live update can flash them (SPEC VF3); for channel
|
||||
// tabs (namespace is the tab id, not 'log') record the message→tab id so
|
||||
// the channel's tab header can flash too.
|
||||
const messageId = entryMessageId(entry);
|
||||
if (messageId) {
|
||||
node.dataset.messageId = messageId;
|
||||
if (namespace !== 'log') messageTabId.set(messageId, namespace);
|
||||
}
|
||||
const divider = getDivider(entry.ts);
|
||||
if (divider) fragment.appendChild(divider);
|
||||
fragment.appendChild(node);
|
||||
@@ -3560,6 +3616,11 @@ export function initializeApp(config) {
|
||||
const frag = document.createDocumentFragment();
|
||||
for (const n of nodes) {
|
||||
const tr = document.createElement('tr');
|
||||
// Row-level node id hook for live-update flashes (SPEC VF3); kept distinct
|
||||
// from the inner link's data-node-id so it never affects click handling.
|
||||
if (typeof n.node_id === 'string' && n.node_id) {
|
||||
tr.dataset.nodeRow = n.node_id;
|
||||
}
|
||||
const lastPositionTime = toFiniteNumber(n.position_time ?? n.positionTime);
|
||||
const lastPositionCell = lastPositionTime != null ? timeAgo(lastPositionTime, nowSec) : '';
|
||||
const latitudeDisplay = fmtCoords(n.latitude);
|
||||
@@ -3837,6 +3898,9 @@ export function initializeApp(config) {
|
||||
// handler can detect threshold crossings on the next zoom event.
|
||||
lastRenderedZoomBucket = currentZoomBucket();
|
||||
markersLayer.clearLayers();
|
||||
// Reset the node→marker map for this render so live-update flashes target
|
||||
// the current markers (SPEC VF3).
|
||||
markerByNodeId = new Map();
|
||||
const pts = [];
|
||||
const nodesById = new Map();
|
||||
for (const node of nodes) {
|
||||
@@ -4151,6 +4215,10 @@ export function initializeApp(config) {
|
||||
const fallbackOverlayProvider = () => mergeOverlayDetails(null, n);
|
||||
let markerToken = 0;
|
||||
marker.addTo(markersLayer);
|
||||
// Remember this node's marker so a live update can flash it (SPEC VF3).
|
||||
if (n && typeof n.node_id === 'string' && n.node_id) {
|
||||
markerByNodeId.set(n.node_id, marker);
|
||||
}
|
||||
// Track every offset marker so the zoomend handler can reposition the
|
||||
// marker + leader line in lock-step. Markers rendered at the shared
|
||||
// centre (singletons / low-zoom overlap / collapsed-group fallback)
|
||||
@@ -4416,9 +4484,6 @@ export function initializeApp(config) {
|
||||
*/
|
||||
async function refresh(refreshOptions = {}) {
|
||||
try {
|
||||
if (statusEl) {
|
||||
statusEl.textContent = 'refreshing…';
|
||||
}
|
||||
// On the first load fetch the full dataset; subsequent refreshes pass
|
||||
// the ``since`` timestamp so only new/changed rows are transferred.
|
||||
// A 1-second overlap avoids missing rows that arrive at the boundary.
|
||||
@@ -4574,6 +4639,14 @@ export function initializeApp(config) {
|
||||
allTraces = Array.isArray(traceEntries) ? traceEntries : [];
|
||||
initialFetchDone = true;
|
||||
applyFilter();
|
||||
// SPEC VF2/VF3/VF4: only an SSE-ping refresh flashes (refreshOptions.flash),
|
||||
// and only after the table + map have rendered (applyFilter above), so the
|
||||
// highlight lands on the final, placed element. useSince excludes the
|
||||
// initial fill. A node/position/telemetry delta flashes the changed node.
|
||||
if (refreshOptions.flash && useSince) {
|
||||
flashChangedNodes(collectNodeIds(incomingNodes, incomingPositions, incomingTelemetry));
|
||||
flashChangedMessages(collectMessageIds(incomingMessages, incomingEncryptedMessages));
|
||||
}
|
||||
// Persist the freshly-merged state for the next reload/revisit (SPEC FC2),
|
||||
// throttled and fire-and-forget so it never blocks the paint.
|
||||
writeBackCache();
|
||||
@@ -4587,13 +4660,7 @@ export function initializeApp(config) {
|
||||
backfillPromise = backfillChatHistory();
|
||||
void backfillPromise;
|
||||
}
|
||||
if (statusEl) {
|
||||
statusEl.textContent = 'updated ' + new Date().toLocaleTimeString();
|
||||
}
|
||||
} catch (e) {
|
||||
if (statusEl) {
|
||||
statusEl.textContent = 'error: ' + e.message;
|
||||
}
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
@@ -4608,11 +4675,6 @@ export function initializeApp(config) {
|
||||
void initialLoadPromise;
|
||||
restartAutoRefresh();
|
||||
|
||||
if (refreshBtn) {
|
||||
// Manual refresh button bypasses the interval and runs a fetch right away.
|
||||
refreshBtn.addEventListener('click', refresh);
|
||||
}
|
||||
|
||||
// --- Auto-refresh play/pause toggle ---
|
||||
if (autorefreshToggle) {
|
||||
autorefreshToggle.addEventListener('click', () => {
|
||||
@@ -4627,7 +4689,6 @@ export function initializeApp(config) {
|
||||
autorefreshToggle.textContent = '\u25B6';
|
||||
autorefreshToggle.setAttribute('aria-label', 'Resume auto-refresh');
|
||||
autorefreshToggle.setAttribute('aria-pressed', 'true');
|
||||
if (statusEl) statusEl.textContent = 'Refresh paused.';
|
||||
} else {
|
||||
autorefreshToggle.textContent = '\u23F8';
|
||||
autorefreshToggle.setAttribute('aria-label', 'Pause auto-refresh');
|
||||
@@ -4773,6 +4834,12 @@ export function initializeApp(config) {
|
||||
isLiveActive: () => liveActive,
|
||||
/** The auto-refresh cadence last armed, in ms (test hook). */
|
||||
getAutoRefreshIntervalMs: () => autoRefreshIntervalMs,
|
||||
/** Count of flash rounds triggered by SSE pings (VF2 gating; test hook). */
|
||||
getLiveFlashCount: () => liveFlashCount,
|
||||
/** Node ids flashed by the most recent SSE-ping refresh (test hook). */
|
||||
getLastFlashedNodeIds: () => lastFlashedNodeIds,
|
||||
/** Message ids flashed by the most recent SSE-ping refresh (test hook). */
|
||||
getLastFlashedMessageIds: () => lastFlashedMessageIds,
|
||||
/**
|
||||
* Flush any pending debounced live refresh and await the latest
|
||||
* live-driven refresh (test hook).
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
/*
|
||||
* Copyright © 2025-26 l5yth & contributors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { collectNodeIds, collectMessageIds, entryMessageId } from '../flash-targets.js';
|
||||
|
||||
test('collectNodeIds flattens and de-duplicates node_id across arrays', () => {
|
||||
const nodes = [{ node_id: '!a' }, { node_id: '!b' }];
|
||||
const positions = [{ node_id: '!b' }, { node_id: '!c' }];
|
||||
const telemetry = [{ node_id: '!a' }];
|
||||
const ids = collectNodeIds(nodes, positions, telemetry);
|
||||
assert.deepEqual([...ids].sort(), ['!a', '!b', '!c']);
|
||||
});
|
||||
|
||||
test('collectNodeIds ignores non-array inputs and rows without a string node_id', () => {
|
||||
const ids = collectNodeIds(
|
||||
null,
|
||||
undefined,
|
||||
'not-an-array',
|
||||
[{ node_id: '!a' }, { node_id: 42 }, {}, null, { node_id: '' }],
|
||||
);
|
||||
assert.deepEqual([...ids], ['!a']);
|
||||
});
|
||||
|
||||
test('collectNodeIds returns an empty set when nothing matches', () => {
|
||||
assert.equal(collectNodeIds().size, 0);
|
||||
assert.equal(collectNodeIds([], [{}]).size, 0);
|
||||
});
|
||||
|
||||
test('collectMessageIds collects string ids from plaintext + encrypted deltas', () => {
|
||||
const plain = [{ id: 1 }, { id: 2 }];
|
||||
const encrypted = [{ id: 2 }, { id: 3 }];
|
||||
assert.deepEqual([...collectMessageIds(plain, encrypted)].sort(), ['1', '2', '3']);
|
||||
});
|
||||
|
||||
test('collectMessageIds ignores non-arrays and rows without an id', () => {
|
||||
assert.deepEqual([...collectMessageIds(null, [{ id: '' }, {}, { id: 7 }])], ['7']);
|
||||
assert.equal(collectMessageIds().size, 0);
|
||||
});
|
||||
|
||||
test('collectMessageIds + entryMessageId honor message_id / messageId fallbacks', () => {
|
||||
assert.deepEqual([...collectMessageIds([{ message_id: 5 }, { messageId: 6 }])], ['5', '6']);
|
||||
assert.equal(entryMessageId({ item: { message_id: 7 } }), '7');
|
||||
assert.equal(entryMessageId({ message: { messageId: 8 } }), '8');
|
||||
});
|
||||
|
||||
test('entryMessageId reads a channel-tab item, a Log-tab message, or neither', () => {
|
||||
assert.equal(entryMessageId({ item: { id: 5 } }), '5'); // channel tab
|
||||
assert.equal(entryMessageId({ message: { id: 9 } }), '9'); // Log tab
|
||||
assert.equal(entryMessageId({ type: 'position', nodeId: '!a' }), null); // non-message
|
||||
assert.equal(entryMessageId(null), null);
|
||||
assert.equal(entryMessageId({ item: { id: '' } }), null);
|
||||
});
|
||||
@@ -0,0 +1,221 @@
|
||||
/*
|
||||
* Copyright © 2025-26 l5yth & contributors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
FLASH_CLASS,
|
||||
FLASH_DURATION_MS,
|
||||
flashElement,
|
||||
flashElements,
|
||||
flashMarker,
|
||||
flashNodeTargets,
|
||||
flashMessageTargets,
|
||||
} from '../flash.js';
|
||||
|
||||
/** A minimal element exposing a tracked classList. */
|
||||
function fakeElement() {
|
||||
const classes = new Set();
|
||||
return {
|
||||
classes,
|
||||
classList: {
|
||||
add: (c) => classes.add(c),
|
||||
remove: (c) => classes.delete(c),
|
||||
contains: (c) => classes.has(c),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('FLASH_DURATION_MS is below 100ms (VF5)', () => {
|
||||
assert.ok(FLASH_DURATION_MS < 100);
|
||||
});
|
||||
|
||||
test('flashElement adds the class immediately, then removes it when the timer fires', () => {
|
||||
const el = fakeElement();
|
||||
let captured = null;
|
||||
const schedule = (cb) => {
|
||||
captured = cb;
|
||||
};
|
||||
|
||||
assert.equal(flashElement(el, { schedule }), true);
|
||||
assert.equal(el.classList.contains(FLASH_CLASS), true, 'class added on flash');
|
||||
|
||||
captured(); // fire the scheduled removal
|
||||
assert.equal(el.classList.contains(FLASH_CLASS), false, 'class removed after duration');
|
||||
});
|
||||
|
||||
test('flashElement restarts an in-flight flash (remove then re-add)', () => {
|
||||
const el = fakeElement();
|
||||
const ops = [];
|
||||
el.classList.add = (c) => ops.push(`add:${c}`);
|
||||
el.classList.remove = (c) => ops.push(`remove:${c}`);
|
||||
|
||||
flashElement(el, { schedule: () => {} });
|
||||
// The class is cleared before being re-added so the CSS animation restarts.
|
||||
assert.deepEqual(ops, [`remove:${FLASH_CLASS}`, `add:${FLASH_CLASS}`]);
|
||||
});
|
||||
|
||||
test('flashElement passes the duration to the scheduler', () => {
|
||||
const el = fakeElement();
|
||||
let seenDelay = null;
|
||||
flashElement(el, { duration: 42, schedule: (_cb, delay) => {
|
||||
seenDelay = delay;
|
||||
} });
|
||||
assert.equal(seenDelay, 42);
|
||||
});
|
||||
|
||||
test('flashElement uses the real timer by default', async () => {
|
||||
const el = fakeElement();
|
||||
flashElement(el, { duration: 1 }); // default schedule = setTimeout
|
||||
assert.equal(el.classList.contains(FLASH_CLASS), true);
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
assert.equal(el.classList.contains(FLASH_CLASS), false, 'real timer removed the class');
|
||||
});
|
||||
|
||||
test('flashElement is a safe no-op for missing or non-element arguments', () => {
|
||||
assert.equal(flashElement(null), false);
|
||||
assert.equal(flashElement(undefined), false);
|
||||
assert.equal(flashElement({}), false); // no classList
|
||||
assert.equal(flashElement({ classList: {} }), false); // classList without add()
|
||||
});
|
||||
|
||||
test('flashElements flashes each valid element and counts them', () => {
|
||||
const a = fakeElement();
|
||||
const b = fakeElement();
|
||||
const count = flashElements([a, null, b, {}], { schedule: () => {} });
|
||||
assert.equal(count, 2);
|
||||
assert.equal(a.classList.contains(FLASH_CLASS), true);
|
||||
assert.equal(b.classList.contains(FLASH_CLASS), true);
|
||||
});
|
||||
|
||||
test('flashElements returns 0 for nullish or non-iterable input', () => {
|
||||
assert.equal(flashElements(null), 0);
|
||||
assert.equal(flashElements(undefined), 0);
|
||||
assert.equal(flashElements(123), 0);
|
||||
});
|
||||
|
||||
test('flashMarker overrides the marker fill to white, then restores it', () => {
|
||||
const styles = [];
|
||||
const marker = {
|
||||
options: { fillColor: '#abcdef', fillOpacity: 0.7 },
|
||||
setStyle: (s) => styles.push(s),
|
||||
};
|
||||
let captured = null;
|
||||
assert.equal(flashMarker(marker, { schedule: (cb) => {
|
||||
captured = cb;
|
||||
} }), true);
|
||||
assert.deepEqual(styles[0], { fillColor: '#ffffff', fillOpacity: 1 });
|
||||
|
||||
captured();
|
||||
assert.deepEqual(styles[1], { fillColor: '#abcdef', fillOpacity: 0.7 });
|
||||
});
|
||||
|
||||
test('flashMarker tolerates a marker without options', () => {
|
||||
const styles = [];
|
||||
const marker = { setStyle: (s) => styles.push(s) };
|
||||
assert.equal(flashMarker(marker, { schedule: (cb) => cb() }), true);
|
||||
assert.deepEqual(styles[1], { fillColor: undefined, fillOpacity: undefined });
|
||||
});
|
||||
|
||||
test('flashMarker uses the real timer by default', async () => {
|
||||
const styles = [];
|
||||
const marker = { options: { fillColor: '#111', fillOpacity: 0.5 }, setStyle: (s) => styles.push(s) };
|
||||
flashMarker(marker, { duration: 1 }); // default schedule = setTimeout
|
||||
assert.deepEqual(styles[0], { fillColor: '#ffffff', fillOpacity: 1 });
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
assert.deepEqual(styles[1], { fillColor: '#111', fillOpacity: 0.5 });
|
||||
});
|
||||
|
||||
test('flashMarker is a no-op without setStyle', () => {
|
||||
assert.equal(flashMarker(null), false);
|
||||
assert.equal(flashMarker({}), false);
|
||||
});
|
||||
|
||||
test('flashNodeTargets flashes each node row and marker', () => {
|
||||
const rowA = fakeElement();
|
||||
let markerFlashed = false;
|
||||
const markerA = {
|
||||
options: { fillColor: '#123', fillOpacity: 0.7 },
|
||||
setStyle: () => {
|
||||
markerFlashed = true;
|
||||
},
|
||||
};
|
||||
const documentRef = { querySelectorAll: (sel) => (sel.includes('"!a"') ? [rowA] : []) };
|
||||
const markerByNodeId = new Map([['!a', markerA]]);
|
||||
|
||||
const count = flashNodeTargets(['!a'], {
|
||||
documentRef,
|
||||
markerByNodeId,
|
||||
flashOptions: { schedule: () => {} },
|
||||
});
|
||||
|
||||
assert.equal(rowA.classList.contains(FLASH_CLASS), true, 'row flashed');
|
||||
assert.equal(markerFlashed, true, 'marker flashed');
|
||||
assert.equal(count, 2); // one row + one marker
|
||||
});
|
||||
|
||||
test('flashNodeTargets skips a missing document and unmapped markers', () => {
|
||||
// No documentRef → no row flash; marker not in the map → no marker flash.
|
||||
assert.equal(flashNodeTargets(['!x'], { markerByNodeId: new Map() }), 0);
|
||||
// documentRef present but no matching row; no markerByNodeId at all.
|
||||
assert.equal(flashNodeTargets(['!x'], { documentRef: { querySelectorAll: () => [] } }), 0);
|
||||
});
|
||||
|
||||
test('flashNodeTargets returns 0 for nullish or non-iterable ids', () => {
|
||||
assert.equal(flashNodeTargets(null), 0);
|
||||
assert.equal(flashNodeTargets(123), 0);
|
||||
});
|
||||
|
||||
test('flashMessageTargets flashes message rows and each channel tab header once', () => {
|
||||
// Two messages on the same tab → the tab header is flashed only once.
|
||||
const rows = { 1: [fakeElement()], 2: [fakeElement()] };
|
||||
const tab = fakeElement();
|
||||
const documentRef = {
|
||||
querySelectorAll: (sel) => {
|
||||
const msg = sel.match(/data-message-id="([^"]+)"/);
|
||||
if (msg) return rows[msg[1]] || [];
|
||||
return sel.includes('data-tab-id="t-pub"') ? [tab] : [];
|
||||
},
|
||||
};
|
||||
const messageTabId = new Map([['1', 't-pub'], ['2', 't-pub']]);
|
||||
|
||||
const count = flashMessageTargets(['1', '2'], {
|
||||
documentRef,
|
||||
messageTabId,
|
||||
flashOptions: { schedule: () => {} },
|
||||
});
|
||||
|
||||
assert.equal(rows[1][0].classList.contains(FLASH_CLASS), true, 'row 1 flashed');
|
||||
assert.equal(rows[2][0].classList.contains(FLASH_CLASS), true, 'row 2 flashed');
|
||||
assert.equal(tab.classList.contains(FLASH_CLASS), true, 'tab header flashed');
|
||||
assert.equal(count, 3); // two rows + one tab (deduped)
|
||||
});
|
||||
|
||||
test('flashMessageTargets skips tab flashing without a tab map or document', () => {
|
||||
// No messageTabId → no tab flash; rows still attempted via the document.
|
||||
const row = fakeElement();
|
||||
const documentRef = { querySelectorAll: (sel) => (sel.includes('"1"') ? [row] : []) };
|
||||
assert.equal(flashMessageTargets(['1'], { documentRef, flashOptions: { schedule: () => {} } }), 1);
|
||||
// No documentRef at all → nothing queried, but a mapped tab is still collected harmlessly.
|
||||
assert.equal(flashMessageTargets(['1'], { messageTabId: new Map([['1', 't']]) }), 0);
|
||||
});
|
||||
|
||||
test('flashMessageTargets returns 0 for nullish or non-iterable ids', () => {
|
||||
assert.equal(flashMessageTargets(null), 0);
|
||||
assert.equal(flashMessageTargets(undefined), 0);
|
||||
assert.equal(flashMessageTargets(123), 0);
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
/*
|
||||
* Copyright © 2025-26 l5yth & contributors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Pure helpers that derive *what to flash* from the rows an SSE-ping refresh
|
||||
* fetched (SPEC VF3). Kept free of DOM/Leaflet so they unit-test trivially; the
|
||||
* actual highlighting lives in {@link module:main/flash}.
|
||||
*
|
||||
* @module main/flash-targets
|
||||
*/
|
||||
|
||||
/**
|
||||
* Collect the canonical node ids touched by one or more delta row arrays.
|
||||
*
|
||||
* Each `nodes` / `positions` / `telemetry` row carries a `node_id`; this
|
||||
* flattens them into a de-duplicated set so the caller can flash each affected
|
||||
* node's table row and map marker exactly once. Non-array inputs and rows
|
||||
* without a string `node_id` are ignored.
|
||||
*
|
||||
* @param {...(Array<Object>|*)} rowArrays One or more delta row arrays.
|
||||
* @returns {Set<string>} de-duplicated canonical node ids.
|
||||
*/
|
||||
export function collectNodeIds(...rowArrays) {
|
||||
const ids = new Set();
|
||||
for (const rows of rowArrays) {
|
||||
if (!Array.isArray(rows)) continue;
|
||||
for (const row of rows) {
|
||||
const id = row && typeof row.node_id === 'string' ? row.node_id : null;
|
||||
if (id) ids.add(id);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect message ids touched by one or more delta row arrays (the plaintext
|
||||
* and encrypted message deltas), as strings so they match `data-message-id`.
|
||||
*
|
||||
* @param {...(Array<Object>|*)} rowArrays One or more message delta arrays.
|
||||
* @returns {Set<string>} de-duplicated message ids.
|
||||
*/
|
||||
export function collectMessageIds(...rowArrays) {
|
||||
const ids = new Set();
|
||||
for (const rows of rowArrays) {
|
||||
if (!Array.isArray(rows)) continue;
|
||||
for (const row of rows) {
|
||||
const id = row && (row.id ?? row.message_id ?? row.messageId);
|
||||
if (id != null && id !== '') ids.add(String(id));
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the message id carried by a rendered chat-log entry, as a string.
|
||||
*
|
||||
* Channel-tab entries wrap the message as `entry.item`; Log-tab message entries
|
||||
* carry it as `entry.message`. Non-message entries (node/position/telemetry/…)
|
||||
* have neither and yield null, so only message rows get tagged for flashing.
|
||||
*
|
||||
* @param {?Object} entry A chat-render entry.
|
||||
* @returns {?string} the message id, or null when the entry is not a message.
|
||||
*/
|
||||
export function entryMessageId(entry) {
|
||||
const message = entry && (entry.item || entry.message);
|
||||
if (!message || typeof message !== 'object') return null;
|
||||
const id = message.id ?? message.message_id ?? message.messageId;
|
||||
return id != null && id !== '' ? String(id) : null;
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
/*
|
||||
* Copyright © 2025-26 l5yth & contributors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Live-update flash helper.
|
||||
*
|
||||
* Applies a brief (<100 ms) white highlight to a DOM element when a live SSE
|
||||
* update lands on it (SPEC VF5). The visual itself — and its suppression under
|
||||
* `prefers-reduced-motion` — lives entirely in CSS (`.live-flash` in
|
||||
* `base.css`); this module only toggles the class, so it has no dependency on a
|
||||
* real layout engine and is fully unit-testable. The class is removed after the
|
||||
* highlight so a subsequent update can re-trigger it.
|
||||
*
|
||||
* @module main/flash
|
||||
*/
|
||||
|
||||
/** CSS class that drives the one-shot highlight animation. */
|
||||
export const FLASH_CLASS = 'live-flash';
|
||||
|
||||
/** Highlight lifetime in ms; kept below 100 ms per VF5. Matches the keyframe. */
|
||||
export const FLASH_DURATION_MS = 90;
|
||||
|
||||
/**
|
||||
* Default removal scheduler (real timer). Injectable so tests stay deterministic.
|
||||
*
|
||||
* @param {Function} callback Removal callback.
|
||||
* @param {number} delay Delay in ms.
|
||||
* @returns {*} The timer handle.
|
||||
*/
|
||||
function defaultSchedule(callback, delay) {
|
||||
return setTimeout(callback, delay);
|
||||
}
|
||||
|
||||
/**
|
||||
* Flash a single element white.
|
||||
*
|
||||
* Removes then re-adds {@link FLASH_CLASS} so the animation restarts even when a
|
||||
* previous flash is still mid-flight, then schedules class removal so a future
|
||||
* update can flash the same element again. A missing or non-element argument is
|
||||
* a safe no-op.
|
||||
*
|
||||
* @param {?Element} element Target element (must expose `classList`).
|
||||
* @param {{ duration?: number, schedule?: Function }} [options] Overrides for
|
||||
* the highlight lifetime and the removal scheduler (tests inject `schedule`).
|
||||
* @returns {boolean} true when the element was flashed; false when skipped.
|
||||
*/
|
||||
export function flashElement(element, options = {}) {
|
||||
if (!element || !element.classList || typeof element.classList.add !== 'function') {
|
||||
return false;
|
||||
}
|
||||
const duration = typeof options.duration === 'number' ? options.duration : FLASH_DURATION_MS;
|
||||
const schedule = typeof options.schedule === 'function' ? options.schedule : defaultSchedule;
|
||||
// Restart the animation by clearing any in-flight highlight before re-adding.
|
||||
element.classList.remove(FLASH_CLASS);
|
||||
element.classList.add(FLASH_CLASS);
|
||||
schedule(() => element.classList.remove(FLASH_CLASS), duration);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flash a Leaflet vector marker (e.g. a node's `circleMarker`) white.
|
||||
*
|
||||
* SVG markers can't use the `.live-flash` box-shadow, so this briefly overrides
|
||||
* the marker's fill to white via Leaflet's `setStyle`, then restores the
|
||||
* original fill after {@link FLASH_DURATION_MS}. A marker without `setStyle` is
|
||||
* a safe no-op.
|
||||
*
|
||||
* @param {?{setStyle: Function, options?: Object}} marker Leaflet vector marker.
|
||||
* @param {{ duration?: number, schedule?: Function }} [options] See {@link flashElement}.
|
||||
* @returns {boolean} true when the marker was flashed; false when skipped.
|
||||
*/
|
||||
export function flashMarker(marker, options = {}) {
|
||||
if (!marker || typeof marker.setStyle !== 'function') return false;
|
||||
const duration = typeof options.duration === 'number' ? options.duration : FLASH_DURATION_MS;
|
||||
const schedule = typeof options.schedule === 'function' ? options.schedule : defaultSchedule;
|
||||
const current = marker.options || {};
|
||||
const original = { fillColor: current.fillColor, fillOpacity: current.fillOpacity };
|
||||
marker.setStyle({ fillColor: '#ffffff', fillOpacity: 1 });
|
||||
schedule(() => marker.setStyle(original), duration);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flash every UI target for a set of changed nodes: each node's table row(s)
|
||||
* (`[data-node-row="<id>"]`) and its map marker (SPEC VF3).
|
||||
*
|
||||
* DOM and marker lookups are injected so this is unit-testable without a real
|
||||
* document or Leaflet. Each is optional — a caller on a page without the node
|
||||
* table (or without a map) simply passes one of them.
|
||||
*
|
||||
* @param {?Iterable<string>} nodeIds Canonical node ids to flash.
|
||||
* @param {Object} [options] Lookups + flash overrides.
|
||||
* @param {?{querySelectorAll: Function}} [options.documentRef] Document to query rows in.
|
||||
* @param {?{get: Function}} [options.markerByNodeId] Map of node id → Leaflet marker.
|
||||
* @param {{ duration?: number, schedule?: Function }} [options.flashOptions] Passed to the flash primitives.
|
||||
* @returns {number} count of row + marker targets flashed.
|
||||
*/
|
||||
export function flashNodeTargets(nodeIds, options = {}) {
|
||||
if (!nodeIds || typeof nodeIds[Symbol.iterator] !== 'function') return 0;
|
||||
const { documentRef = null, markerByNodeId = null, flashOptions = {} } = options;
|
||||
let count = 0;
|
||||
for (const id of nodeIds) {
|
||||
if (documentRef && typeof documentRef.querySelectorAll === 'function') {
|
||||
count += flashElements(documentRef.querySelectorAll(`[data-node-row="${id}"]`), flashOptions);
|
||||
}
|
||||
const marker = markerByNodeId && typeof markerByNodeId.get === 'function'
|
||||
? markerByNodeId.get(id)
|
||||
: null;
|
||||
if (marker && flashMarker(marker, flashOptions)) count += 1;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flash every UI target for a set of changed messages: each message's chat
|
||||
* row(s) (`[data-message-id="<id>"]`, present in the Log tab and the channel
|
||||
* tab) and the header of each affected channel tab (`[data-tab-id="<id>"]`)
|
||||
* once (SPEC VF3).
|
||||
*
|
||||
* @param {?Iterable<string>} messageIds Message ids that changed.
|
||||
* @param {Object} [options] Lookups + flash overrides.
|
||||
* @param {?{querySelectorAll: Function}} [options.documentRef] Document to query in.
|
||||
* @param {?{get: Function}} [options.messageTabId] Map of message id → channel tab id.
|
||||
* @param {{ duration?: number, schedule?: Function }} [options.flashOptions] Passed to the flash primitives.
|
||||
* @returns {number} count of row + tab-header targets flashed.
|
||||
*/
|
||||
export function flashMessageTargets(messageIds, options = {}) {
|
||||
if (!messageIds || typeof messageIds[Symbol.iterator] !== 'function') return 0;
|
||||
const { documentRef = null, messageTabId = null, flashOptions = {} } = options;
|
||||
const canQuery = Boolean(documentRef) && typeof documentRef.querySelectorAll === 'function';
|
||||
let count = 0;
|
||||
const tabIds = new Set();
|
||||
for (const id of messageIds) {
|
||||
if (canQuery) {
|
||||
count += flashElements(documentRef.querySelectorAll(`[data-message-id="${id}"]`), flashOptions);
|
||||
}
|
||||
const tabId = messageTabId && typeof messageTabId.get === 'function' ? messageTabId.get(id) : null;
|
||||
if (tabId) tabIds.add(tabId);
|
||||
}
|
||||
// Flash each affected channel tab header exactly once.
|
||||
if (canQuery) {
|
||||
for (const tabId of tabIds) {
|
||||
count += flashElements(documentRef.querySelectorAll(`[data-tab-id="${tabId}"]`), flashOptions);
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flash several elements, skipping any that are missing/invalid.
|
||||
*
|
||||
* @param {?Iterable<Element>} elements Elements to flash.
|
||||
* @param {{ duration?: number, schedule?: Function }} [options] See {@link flashElement}.
|
||||
* @returns {number} count of elements actually flashed.
|
||||
*/
|
||||
export function flashElements(elements, options = {}) {
|
||||
if (!elements || typeof elements[Symbol.iterator] !== 'function') return 0;
|
||||
let count = 0;
|
||||
for (const element of elements) {
|
||||
if (flashElement(element, options)) count += 1;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
@@ -1594,12 +1594,6 @@ body.dark .node-detail__chart-grid-line {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.refresh-timestamp {
|
||||
font-size: 0.85em;
|
||||
color: var(--muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.filter-input input[type="text"] {
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
@@ -2559,3 +2553,23 @@ body.dark #map .leaflet-tile.map-tiles {
|
||||
.markdown-body h1 { font-size: 1.3em; }
|
||||
.markdown-body h2 { font-size: 1.15em; }
|
||||
}
|
||||
|
||||
/* Live-update flash (SPEC VF5): a brief (<100ms) white highlight applied to a
|
||||
node row / map marker / chat message / channel tab when a live SSE update
|
||||
lands on it. Uses an inset box-shadow overlay so there is no layout shift, and
|
||||
self-completes (no fill) so the element reverts to its normal appearance. */
|
||||
@keyframes live-flash-highlight {
|
||||
from { box-shadow: inset 0 0 0 100vmax rgba(255, 255, 255, 0.85); }
|
||||
to { box-shadow: inset 0 0 0 100vmax rgba(255, 255, 255, 0); }
|
||||
}
|
||||
|
||||
.live-flash {
|
||||
animation: live-flash-highlight 90ms ease-out;
|
||||
}
|
||||
|
||||
/* Honor reduced-motion: data still updates live, only the flash is withheld. */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.live-flash {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
+21
-6
@@ -1371,6 +1371,18 @@ RSpec.describe "Potato Mesh Sinatra app" do
|
||||
expect(last_response).to be_ok
|
||||
end
|
||||
|
||||
it "does not render the Refresh button or last-updated field" do
|
||||
get "/"
|
||||
|
||||
expect(last_response).to be_ok
|
||||
# Live SSE updates replace manual refresh + a timestamp (VF1).
|
||||
expect(last_response.body).not_to include('id="refreshBtn"')
|
||||
expect(last_response.body).not_to include('id="status"')
|
||||
expect(last_response.body).not_to include('class="refresh-timestamp"')
|
||||
# The play/pause toggle is kept.
|
||||
expect(last_response.body).to include('id="autorefreshToggle"')
|
||||
end
|
||||
|
||||
it "includes the application version in the footer" do
|
||||
get "/"
|
||||
expected = APP_VERSION.to_s.start_with?("v") ? APP_VERSION : "v#{APP_VERSION}"
|
||||
@@ -1452,8 +1464,9 @@ RSpec.describe "Potato Mesh Sinatra app" do
|
||||
expect(last_response.body).to include('id="map"')
|
||||
expect(last_response.body).to include('id="filterInput"')
|
||||
expect(last_response.body).not_to include('id="autoRefresh"')
|
||||
expect(last_response.body).to include('id="refreshBtn"')
|
||||
expect(last_response.body).to include('id="status"')
|
||||
expect(last_response.body).not_to include('id="refreshBtn"')
|
||||
expect(last_response.body).not_to include('id="status"')
|
||||
expect(last_response.body).to include('id="autorefreshToggle"')
|
||||
expect(last_response.body).not_to include('id="fitBounds"')
|
||||
expect(last_response.body).not_to include('<footer class="app-footer">')
|
||||
end
|
||||
@@ -1509,8 +1522,9 @@ RSpec.describe "Potato Mesh Sinatra app" do
|
||||
expect(last_response.body).to include('class="chat-panel chat-panel--full"')
|
||||
expect(last_response.body).to include('id="filterInput"')
|
||||
expect(last_response.body).not_to include('id="autoRefresh"')
|
||||
expect(last_response.body).to include('id="refreshBtn"')
|
||||
expect(last_response.body).to include('id="status"')
|
||||
expect(last_response.body).not_to include('id="refreshBtn"')
|
||||
expect(last_response.body).not_to include('id="status"')
|
||||
expect(last_response.body).to include('id="autorefreshToggle"')
|
||||
expect(last_response.body).not_to include('<footer class="app-footer">')
|
||||
end
|
||||
|
||||
@@ -1533,8 +1547,9 @@ RSpec.describe "Potato Mesh Sinatra app" do
|
||||
expect(last_response.body).to include('id="nodes"')
|
||||
expect(last_response.body).to include('id="filterInput"')
|
||||
expect(last_response.body).not_to include('id="autoRefresh"')
|
||||
expect(last_response.body).to include('id="refreshBtn"')
|
||||
expect(last_response.body).to include('id="status"')
|
||||
expect(last_response.body).not_to include('id="refreshBtn"')
|
||||
expect(last_response.body).not_to include('id="status"')
|
||||
expect(last_response.body).to include('id="autorefreshToggle"')
|
||||
expect(last_response.body).not_to include('<footer class="app-footer">')
|
||||
end
|
||||
end
|
||||
|
||||
+16
-4
@@ -225,9 +225,19 @@ RSpec.describe PotatoMesh::App::PubSub do
|
||||
|
||||
it "publishes a thin per-collection event (PS3)" do
|
||||
subscriber = PotatoMesh::App::PubSub.subscribe
|
||||
# Use positions: it publishes exactly one collection. (The messages route
|
||||
# additionally publishes nodes — covered by the message-ingest example.)
|
||||
post "/api/positions", "[]", auth
|
||||
expect(last_response.status).to eq(201)
|
||||
expect(subscriber.drain(timeout: 0.1)).to eq([{ collection: "positions", hint: nil }])
|
||||
end
|
||||
|
||||
it "publishes nodes on a message ingest (#822 touches the author node)" do
|
||||
allow(PotatoMesh::App::PubSub).to receive(:publish).and_call_original
|
||||
post "/api/messages", "[]", auth
|
||||
expect(last_response.status).to eq(201)
|
||||
expect(subscriber.drain(timeout: 0.1)).to eq([{ collection: "messages", hint: nil }])
|
||||
expect(PotatoMesh::App::PubSub).to have_received(:publish).with("messages", private_mode: false)
|
||||
expect(PotatoMesh::App::PubSub).to have_received(:publish).with("nodes", private_mode: false)
|
||||
end
|
||||
|
||||
it "publishes on every ingest route" do
|
||||
@@ -239,19 +249,21 @@ RSpec.describe PotatoMesh::App::PubSub do
|
||||
end
|
||||
|
||||
ingest_routes.each_key do |collection|
|
||||
# `nodes` is published by both the nodes route and the messages route
|
||||
# (#822), so assert at-least-once rather than exactly-once.
|
||||
expect(PotatoMesh::App::PubSub).to have_received(:publish)
|
||||
.with(collection, private_mode: false)
|
||||
.with(collection, private_mode: false).at_least(:once)
|
||||
end
|
||||
end
|
||||
|
||||
it "coalesces bursts of one collection into a single pending event" do
|
||||
subscriber = PotatoMesh::App::PubSub.subscribe
|
||||
5.times do
|
||||
post "/api/messages", "[]", auth
|
||||
post "/api/positions", "[]", auth
|
||||
expect(last_response.status).to eq(201)
|
||||
end
|
||||
expect(subscriber.pending_count).to eq(1)
|
||||
expect(subscriber.drain(timeout: 0.1)).to eq([{ collection: "messages", hint: nil }])
|
||||
expect(subscriber.drain(timeout: 0.1)).to eq([{ collection: "positions", hint: nil }])
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -211,9 +211,7 @@
|
||||
<img src="/assets/img/meshtastic.svg" alt="" width="20" height="20" aria-hidden="true" class="protocol-toggle-icon" loading="lazy" decoding="async" />
|
||||
</button>
|
||||
<span id="footerActiveNodes" class="meta-active-nodes"></span>
|
||||
<button id="refreshBtn" type="button" title="Fetch latest data now">Refresh</button>
|
||||
<button id="autorefreshToggle" type="button" aria-label="Pause auto-refresh" aria-pressed="false" title="Pause or resume automatic data refresh">⏸</button>
|
||||
<span id="status" class="refresh-timestamp" aria-live="polite"></span>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user