Files
RuView/ui/components/DashboardTab.test.mjs
Mathew Beaneandclaude-flow 8c6d29bf83 fix(sensing-server): report measured stream fps and real WebSocket client count
/api/v1/stream/status returned "fps": 10 whenever tick > 1, whatever the
real rate, and both it and /health counted every broadcast subscriber
(recorder, MQTT bridge) as a client. The dashboard Streaming card read
"IDLE, 0 client(s)" for up to 30 s while the page was streaming, because
its first /health poll lands before the page's socket connects; with auth
on, the ticket round-trip widens that gap.

- New stream_stats module: UpdateRateMeter (sliding 5 s window, bounded at
  1024 samples) and WsClientCounter with an RAII guard.
- AppStateInner::advance_tick() replaces the five `s.tick += 1` sites and
  records each sensing update in the meter.
- /api/v1/stream/status now returns the measured update rate as `fps`
  (0 when nothing flows), `active` from that rate, `clients` from the
  WebSocket counter, `nodes` with each fresh node's measured csi_fps (null
  until its EMA has 5 samples) and `csi_fps_total`.
- /health and the /health/health stream component count WebSocket clients
  on /ws/sensing and /api/v1/stream/pose; the stream component also carries
  the measured rate.
- DashboardTab re-reads health 500 ms after the sensing socket connects or
  drops, so the card follows the socket instead of waiting for the next
  30 s poll.

Fixes #2087

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-02 22:15:23 -04:00

71 lines
2.4 KiB
JavaScript

// Executed regression test for #2087: the dashboard Streaming card read
// "IDLE, 0 client(s)" while the page's own socket was streaming, because the
// card is fed by a 30 s /health poll whose first read lands before the socket
// connects.
//
// Run: node --test ui/components/DashboardTab.test.mjs
//
// This EXECUTES DashboardTab's refresh wiring in Node with fake timers and a
// stubbed health service. It does not render the card in a browser.
import { test, beforeEach, mock } from 'node:test';
import assert from 'node:assert/strict';
globalThis.localStorage = { getItem: () => null, setItem() {}, removeItem() {} };
globalThis.fetch = async () => ({ ok: true, status: 200, json: async () => ({}) });
const { DashboardTab } = await import('./DashboardTab.js');
const { healthService } = await import('../services/health.service.js');
let healthCalls;
let tab;
beforeEach(() => {
healthCalls = 0;
healthService.getSystemHealth = async () => { healthCalls++; return {}; };
mock.timers.enable({ apis: ['setTimeout'] });
tab = new DashboardTab({ querySelector: () => null });
});
const advance = () => mock.timers.tick(DashboardTab.STREAM_HEALTH_REFRESH_DELAY_MS);
test('health is re-read once the sensing socket connects', () => {
tab._refreshStreamHealthOn('connecting');
advance();
assert.equal(healthCalls, 0, 'nothing to report until the socket is up');
tab._refreshStreamHealthOn('connected');
assert.equal(healthCalls, 0, 'waits for the server to register the socket');
advance();
assert.equal(healthCalls, 1);
mock.timers.reset();
});
test('health is re-read when a connected socket drops', () => {
tab._refreshStreamHealthOn('connected');
advance();
tab._refreshStreamHealthOn('reconnecting');
advance();
assert.equal(healthCalls, 2);
mock.timers.reset();
});
test('repeated notifications of the same state do not refetch', () => {
// onStateChange also fires on dataSource changes with an unchanged state.
tab._refreshStreamHealthOn('connected');
tab._refreshStreamHealthOn('connected');
tab._refreshStreamHealthOn('connected');
advance();
assert.equal(healthCalls, 1);
mock.timers.reset();
});
test('retry churn while never connected does not poll health', () => {
for (const s of ['connecting', 'reconnecting', 'connecting', 'reconnecting']) {
tab._refreshStreamHealthOn(s);
advance();
}
assert.equal(healthCalls, 0);
mock.timers.reset();
});