mirror of
https://github.com/ruvnet/RuView
synced 2026-08-08 20:11:43 +00:00
feat(ADR-131): HOMECORE-UI operational dashboard + BFF gateway
Complete two-tier Cognitum operator dashboard (ADR-131), served by homecore-server at /homecore, plus the single-origin BFF gateway that wires it to real backends. Front-end (zero-dep vanilla TS/JS + CSS, exact Cognitum design tokens): - All 10 panels (§4.1-4.10): dashboard, SEED fleet + detail, fleet map, entities (live WS subscribe_events, never polls), rooms, COGs, calibration wizard, events + automation builder, witness/audit, settings. - §6 UX invariants in code: first-class provenance, prominent stale/veto/ fragility, null(not-trained) vs withheld vs error, --mono everywhere, Hailo vs CPU COG distinction. - api.js calls the gateway routes in production; mock demoted to a dev-only ?demo=1 fixture (no mock in prod); typed error states. - Tests under plain node: import-graph, boot, render-smoke (22), interaction (3), prod-errors (13) — 5 files green; bundle ~137 KB (~37x smaller than HA), <2 ms/cold-render. BFF gateway (homecore-server/src/gateway.rs, compiled + tested on Rust 1.89): - /api/cal/* reverse-proxy to the calibration API (ADR-151). - GET /api/homecore/rooms with the RoomState adapter (breathing->breathing_bpm, heartbeat:null->heart_bpm:null, injected anomaly.threshold/room_id). - GET /api/homecore/cogs supervisor over /var/lib/cognitum/apps/. - GET /api/homecore/appliance from /proc + TCP service probes. - SEED-device/appliance routes return typed 503 upstream_unavailable. - cargo test -p homecore-server = 12/12; run live (curl-verified); fixed a real double-v1 proxy-URL bug found during live testing. Honest scope: W1/W2/W4/W6-appliance functional; W3/W5/W6-Hailo/federation return typed 503 (depend on services/hardware not in this repo). Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
@@ -0,0 +1,54 @@
|
||||
// Benchmark — ADR-131 §8 / ADR-126 §1.1.
|
||||
// HOMECORE exists partly because HA's frontend is a ~5 MB Lit bundle
|
||||
// (ADR-126 §1.1). This benchmark enforces a hard bundle budget and
|
||||
// measures cold render throughput for all 10 panels.
|
||||
// Run: node tests/benchmark.mjs
|
||||
import { install } from './dom-shim.mjs';
|
||||
install();
|
||||
import { readFileSync, readdirSync, statSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
const ROOT = resolve(import.meta.dirname, '..');
|
||||
const BUDGET_BYTES = 250 * 1024; // 250 KB total — vs HA's ~5 MB (20× smaller)
|
||||
|
||||
function walk(dir) {
|
||||
let total = 0; const rows = [];
|
||||
for (const name of readdirSync(dir)) {
|
||||
if (name === 'tests' || name === 'node_modules') continue;
|
||||
const p = resolve(dir, name); const s = statSync(p);
|
||||
if (s.isDirectory()) { const sub = walk(p); total += sub.total; rows.push(...sub.rows); }
|
||||
else if (/\.(js|css|html|json)$/.test(name)) { total += s.size; rows.push([p.replace(ROOT + '/', ''), s.size]); }
|
||||
}
|
||||
return { total, rows };
|
||||
}
|
||||
|
||||
const { total, rows } = walk(ROOT);
|
||||
rows.sort((a, b) => b[1] - a[1]);
|
||||
console.log('── Bundle size (uncompressed) ──');
|
||||
for (const [f, sz] of rows.slice(0, 8)) console.log(` ${(sz / 1024).toFixed(1).padStart(7)} KB ${f}`);
|
||||
console.log(` ${'-'.repeat(40)}`);
|
||||
console.log(` ${(total / 1024).toFixed(1).padStart(7)} KB TOTAL across ${rows.length} files`);
|
||||
console.log(` budget ${(BUDGET_BYTES / 1024).toFixed(0)} KB · HA baseline ~5120 KB · ratio ${(5120 * 1024 / total).toFixed(1)}× smaller`);
|
||||
|
||||
// ── render throughput ───────────────────────────────────────────────
|
||||
const { api } = await import('../js/api.js');
|
||||
const ctx = { api, navigate() {}, params: { id: 'seed-livingroom-a1' }, onEvent() { return () => {}; }, onWs(fn) { fn({ state: 'open', lagged: false }); return () => {}; } };
|
||||
const PANELS = ['dashboard', 'fleet', 'seed-detail', 'entities', 'rooms', 'cogs', 'calibration', 'events', 'audit', 'settings'];
|
||||
const mods = {};
|
||||
for (const p of PANELS) mods[p] = (await import(`../js/panels/${p}.js`)).default;
|
||||
|
||||
console.log('\n── Cold render throughput (avg of 50 renders each) ──');
|
||||
let worst = 0;
|
||||
for (const p of PANELS) {
|
||||
const N = 50; const t0 = performance.now();
|
||||
for (let i = 0; i < N; i++) { const root = document.createElement('div'); const c = await mods[p].render(root, ctx); if (typeof c === 'function') c(); }
|
||||
const ms = (performance.now() - t0) / N;
|
||||
worst = Math.max(worst, ms);
|
||||
console.log(` ${ms.toFixed(3).padStart(7)} ms/render ${p}`);
|
||||
}
|
||||
|
||||
console.log('');
|
||||
let exit = 0;
|
||||
if (total > BUDGET_BYTES) { console.error(`FAIL — bundle ${(total / 1024).toFixed(1)} KB exceeds ${(BUDGET_BYTES / 1024).toFixed(0)} KB budget`); exit = 1; }
|
||||
else console.log(`OK — bundle within budget; slowest panel ${worst.toFixed(2)} ms/render`);
|
||||
process.exit(exit);
|
||||
@@ -0,0 +1,37 @@
|
||||
// Boot regression test — exercises the REAL app.js boot + router (not
|
||||
// just individual panels). Catches the class of bug where start() throws
|
||||
// before route() runs and the dashboard renders blank.
|
||||
// Run: node tests/boot.mjs (from the ui/ dir)
|
||||
import { install } from './dom-shim.mjs';
|
||||
const { document, window } = install();
|
||||
globalThis.HOMECORE_UI_DEMO = true; // boot with fixtures (no gateway in tests)
|
||||
|
||||
const errs = [];
|
||||
const origErr = console.error;
|
||||
console.error = (...a) => { errs.push(a.map(String).join(' ')); };
|
||||
|
||||
await import('../js/app.js');
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
console.error = origErr;
|
||||
|
||||
const fails = [];
|
||||
const content = document.getElementById('hc-content');
|
||||
const app = document.getElementById('app');
|
||||
|
||||
if (!app || app.children.length < 2) fails.push('shell not built (#app should have topnav + shell)');
|
||||
if (!content) fails.push('#hc-content missing — buildShell did not run');
|
||||
else if (content.children.length === 0) fails.push('BLANK: dashboard rendered nothing into #hc-content on boot');
|
||||
if (errs.length) fails.push('console.error during boot: ' + errs.slice(0, 3).join(' | '));
|
||||
|
||||
// navigation must re-render the panel
|
||||
window.location.hash = '#/fleet';
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
if (!content || content.children.length === 0) fails.push('BLANK after navigating to #/fleet');
|
||||
|
||||
// a clean topnav with no dead Cognitum tabs / Cog Store link
|
||||
const links = app ? app.querySelectorAll('a') : [];
|
||||
const hrefs = links.map((a) => a.getAttribute('href') || '');
|
||||
if (hrefs.some((h) => /cognitum\.one\/store/.test(h))) fails.push('Cog Store external link should be removed');
|
||||
|
||||
if (fails.length) { console.error('\nFAILED:'); fails.forEach((f) => console.error(' ✗ ' + f)); process.exit(1); }
|
||||
console.log('OK — app.js boots, dashboard renders, navigation re-renders, no dead Cog Store link');
|
||||
@@ -0,0 +1,103 @@
|
||||
// Minimal DOM shim — enough to *run* the HOMECORE-UI panels under Node
|
||||
// without jsdom. Installs globals (document, location, localStorage,
|
||||
// fetch, WebSocket) so render-smoke.mjs can execute every panel and
|
||||
// assert it builds a real DOM subtree without throwing.
|
||||
|
||||
class ClassList {
|
||||
constructor(el) { this.el = el; this.set = new Set(); }
|
||||
add(...c) { c.forEach((x) => x && this.set.add(x)); this.sync(); }
|
||||
remove(...c) { c.forEach((x) => this.set.delete(x)); this.sync(); }
|
||||
toggle(c, force) { const has = this.set.has(c); const on = force === undefined ? !has : force; if (on) this.set.add(c); else this.set.delete(c); this.sync(); return on; }
|
||||
contains(c) { return this.set.has(c); }
|
||||
sync() { this.el._class = [...this.set].join(' '); }
|
||||
}
|
||||
|
||||
class El {
|
||||
constructor(tag) {
|
||||
this.tagName = String(tag).toUpperCase();
|
||||
this.children = [];
|
||||
this.attrs = {};
|
||||
this.style = {};
|
||||
this.listeners = {};
|
||||
this._class = '';
|
||||
this.classList = new ClassList(this);
|
||||
this.parentNode = null;
|
||||
this.id = '';
|
||||
this._text = '';
|
||||
this.disabled = false;
|
||||
this.value = '';
|
||||
}
|
||||
set className(v) { this._class = v || ''; this.classList.set = new Set(String(v || '').split(/\s+/).filter(Boolean)); }
|
||||
get className() { return this._class; }
|
||||
set innerHTML(v) { this._html = v; }
|
||||
get innerHTML() { return this._html || ''; }
|
||||
set textContent(v) { this._text = v; this.children = []; }
|
||||
get textContent() { return this._text || this.children.map((c) => c.textContent || c._text || '').join(''); }
|
||||
appendChild(c) { c.parentNode = this; this.children.push(c); return c; }
|
||||
insertBefore(c, ref) { const i = this.children.indexOf(ref); c.parentNode = this; if (i < 0) this.children.push(c); else this.children.splice(i, 0, c); return c; }
|
||||
removeChild(c) { const i = this.children.indexOf(c); if (i >= 0) this.children.splice(i, 1); c.parentNode = null; return c; }
|
||||
remove() { if (this.parentNode) this.parentNode.removeChild(this); }
|
||||
get firstChild() { return this.children[0] || null; }
|
||||
setAttribute(k, v) { this.attrs[k] = String(v); }
|
||||
getAttribute(k) { return this.attrs[k] ?? null; }
|
||||
addEventListener(t, fn) { (this.listeners[t] ||= []).push(fn); }
|
||||
removeEventListener(t, fn) { this.listeners[t] = (this.listeners[t] || []).filter((f) => f !== fn); }
|
||||
dispatch(t, detail) { (this.listeners[t] || []).forEach((fn) => fn({ detail, target: this, preventDefault() {}, stopPropagation() {} })); }
|
||||
_all() { return this.children.flatMap((c) => [c, ...(c._all ? c._all() : [])]); }
|
||||
matchesSel(sel) {
|
||||
return sel.split(/\s+/).pop().split('.').every((p, i, arr) => {
|
||||
if (i === 0 && p && !p.startsWith('.') && !p.startsWith('#')) { if (p.startsWith('.')) {} }
|
||||
return true;
|
||||
});
|
||||
}
|
||||
querySelector(sel) {
|
||||
const want = sel.replace(/^.*\s/, '');
|
||||
const cls = want.startsWith('.') ? want.slice(1) : null;
|
||||
return this._all().find((e) => (cls ? (e.classList && e.classList.contains(cls)) : e.tagName === want.toUpperCase())) || null;
|
||||
}
|
||||
querySelectorAll(sel) {
|
||||
const want = sel.replace(/^.*\s/, '');
|
||||
const cls = want.startsWith('.') ? want.slice(1) : null;
|
||||
return this._all().filter((e) => (cls ? (e.classList && e.classList.contains(cls)) : e.tagName === want.toUpperCase()));
|
||||
}
|
||||
}
|
||||
|
||||
class TextNode { constructor(t) { this.textContent = String(t); this._text = String(t); this.nodeType = 3; this.parentNode = null; } remove() { if (this.parentNode) this.parentNode.removeChild(this); } }
|
||||
|
||||
// Node instanceof checks in ui.js use `instanceof Node`; expose a Node base.
|
||||
globalThis.Node = El;
|
||||
// TextNode must also pass `instanceof Node` (ui.js append() treats text via createTextNode).
|
||||
Object.setPrototypeOf(TextNode.prototype, El.prototype);
|
||||
|
||||
const body = new El('body');
|
||||
const documentObj = {
|
||||
createElement: (t) => new El(t),
|
||||
createElementNS: (_ns, t) => new El(t),
|
||||
createTextNode: (t) => new TextNode(t),
|
||||
getElementById: (id) => byId[id] || (byId[id] = mkRoot(id)),
|
||||
body,
|
||||
readyState: 'complete',
|
||||
addEventListener() {},
|
||||
querySelectorAll: () => [],
|
||||
};
|
||||
const byId = {};
|
||||
function mkRoot(id) { const e = new El('div'); e.id = id; return e; }
|
||||
|
||||
export function install() {
|
||||
globalThis.document = documentObj;
|
||||
globalThis.EventTarget = class { constructor() { this._l = {}; } addEventListener(t, fn) { (this._l[t] ||= []).push(fn); } removeEventListener(t, fn) { this._l[t] = (this._l[t] || []).filter((f) => f !== fn); } dispatchEvent(e) { (this._l[e.type] || []).forEach((fn) => fn(e)); return true; } };
|
||||
// window with a navigable location.hash that fires `hashchange`.
|
||||
const win = new globalThis.EventTarget();
|
||||
let _hash = '';
|
||||
const loc = { host: 'localhost:8123', protocol: 'http:', get hash() { return _hash; }, set hash(v) { _hash = String(v).startsWith('#') ? String(v) : '#' + v; win.dispatchEvent({ type: 'hashchange' }); } };
|
||||
win.location = loc;
|
||||
globalThis.window = win;
|
||||
globalThis.location = loc;
|
||||
globalThis.localStorage = { _m: {}, getItem(k) { return this._m[k] ?? null; }, setItem(k, v) { this._m[k] = String(v); } };
|
||||
globalThis.fetch = () => Promise.reject(new Error('offline (test) — panels fall back to mock per §7.1'));
|
||||
globalThis.WebSocket = class { constructor() { this.readyState = 0; } send() {} close() {} };
|
||||
globalThis.CustomEvent = class { constructor(t, o) { this.type = t; this.detail = o && o.detail; } };
|
||||
return { El, TextNode, body, document: documentObj, window: win, location: loc };
|
||||
}
|
||||
|
||||
export { El, TextNode };
|
||||
@@ -0,0 +1,86 @@
|
||||
// Interaction tests — the dynamic behaviours that syntax/render checks
|
||||
// cannot reach: the live WebSocket entity patch (§4.4 "never poll"), the
|
||||
// ws.js handshake + event parse (ADR-130), and the calibration backend
|
||||
// driving the §4.7 wizard. Run: node tests/interaction.mjs
|
||||
import { install } from './dom-shim.mjs';
|
||||
install();
|
||||
globalThis.HOMECORE_UI_DEMO = true; // exercise the demo/calibration fixture path
|
||||
|
||||
const fails = [], passes = [];
|
||||
async function t(name, fn) {
|
||||
try { await fn(); passes.push(name); }
|
||||
catch (e) { fails.push(`${name}: ${e && e.stack ? e.stack.split('\n').slice(0, 3).join(' | ') : e}`); }
|
||||
}
|
||||
const assert = (c, m) => { if (!c) throw new Error(m || 'assertion failed'); };
|
||||
|
||||
// ── 1. entities panel patches state live over the bus (no polling) ──
|
||||
await t('entities: live state_changed patches the row in place', async () => {
|
||||
const entities = (await import('../js/panels/entities.js')).default;
|
||||
const { api } = await import('../js/api.js');
|
||||
let handler = null;
|
||||
const ctx = {
|
||||
api, navigate() {}, params: {},
|
||||
onEvent(fn) { handler = fn; return () => {}; },
|
||||
onWs(fn) { fn({ state: 'open', lagged: false }); return () => {}; },
|
||||
};
|
||||
const root = document.createElement('div');
|
||||
await entities.render(root, ctx);
|
||||
assert(typeof handler === 'function', 'panel must register an onEvent handler (it must not poll)');
|
||||
|
||||
const before = root.querySelectorAll('.t1').map((n) => n.textContent);
|
||||
assert(before.some((x) => x === 'true'), 'living_room_presence should start "true" from the mock fallback');
|
||||
|
||||
// Fire a live event; ws.js delivers new_state as a StateView object.
|
||||
handler({ event_type: 'state_changed', entity_id: 'sensor.living_room_presence', old_state: { state: 'true' }, new_state: { state: 'false' } });
|
||||
|
||||
const after = root.querySelectorAll('.t1').map((n) => n.textContent);
|
||||
assert(after.some((x) => x === 'false'), 'row should now show patched state "false"');
|
||||
});
|
||||
|
||||
// ── 2. ws.js performs the HA-compat handshake and parses events ─────
|
||||
await t('ws.js: handshake → subscribe_events → parsed event', async () => {
|
||||
const sent = [];
|
||||
let inst = null;
|
||||
globalThis.WebSocket = class { constructor(url) { this.url = url; inst = this; } send(m) { sent.push(JSON.parse(m)); } close() { this.onclose && this.onclose(); } };
|
||||
const { connect } = await import('../js/ws.js?ws-test');
|
||||
const got = [], status = [];
|
||||
const ctrl = connect((e) => got.push(e), (s) => status.push(s));
|
||||
assert(inst, 'WebSocket should be constructed');
|
||||
|
||||
inst.onmessage({ data: JSON.stringify({ type: 'auth_required', ha_version: 'x' }) });
|
||||
assert(sent[0] && sent[0].type === 'auth' && 'access_token' in sent[0], 'must reply to auth_required with an auth token');
|
||||
|
||||
inst.onmessage({ data: JSON.stringify({ type: 'auth_ok', ha_version: 'x' }) });
|
||||
assert(sent.some((m) => m.type === 'subscribe_events' && m.event_type === 'state_changed'), 'must subscribe_events after auth_ok');
|
||||
|
||||
inst.onmessage({ data: JSON.stringify({ type: 'event', event: { event_type: 'state_changed', data: { entity_id: 'light.x', old_state: { state: 'off' }, new_state: { state: 'on' } } } }) });
|
||||
assert(got.length === 1, 'one event expected');
|
||||
assert(got[0].entity_id === 'light.x' && got[0].new_state.state === 'on', 'event fields must parse through');
|
||||
|
||||
inst.onmessage({ data: JSON.stringify({ type: 'lagged' }) });
|
||||
assert(ctrl.isLagged(), 'lag signal should set isLagged');
|
||||
ctrl.close();
|
||||
});
|
||||
|
||||
// ── 3. calibration backend drives the 5-step wizard contract ───────
|
||||
await t('calibration: start→status→anchor→train contract', async () => {
|
||||
const { api } = await import('../js/api.js');
|
||||
const cal = api.calibration;
|
||||
cal.reset();
|
||||
const bl = await cal.start();
|
||||
assert(bl.baseline_id, 'start() returns a baseline_id (the STALE anchor)');
|
||||
let st;
|
||||
for (let i = 0; i < 10; i++) { st = await cal.status(); if (st.frames >= st.target) break; }
|
||||
assert(st.frames >= st.target, 'status() converges to target frames');
|
||||
|
||||
for (const label of cal.ANCHORS) await cal.anchor(label);
|
||||
assert((await cal.enrollStatus()).accepted.length >= 6, 'most anchors accepted after enrollment');
|
||||
|
||||
const trained = await cal.train();
|
||||
assert(trained.presence && trained.anomaly, 'train() returns non-null specialists when enrolled');
|
||||
cal.reset();
|
||||
});
|
||||
|
||||
console.log(`\n${passes.length} passed, ${fails.length} failed`);
|
||||
if (fails.length) { console.error('\nFAILURES:'); fails.forEach((f) => console.error(' ✗ ' + f)); process.exit(1); }
|
||||
console.log('OK — live WS patch, ws.js handshake/parse, and calibration contract verified');
|
||||
@@ -0,0 +1,45 @@
|
||||
// Production-mode test (ADR-131 §2.2 / §11.11): with demo mode OFF and
|
||||
// the gateway unreachable, every panel must render a typed empty/error
|
||||
// state WITHOUT throwing and WITHOUT showing fabricated data.
|
||||
// Run: node tests/prod-errors.mjs
|
||||
import { install } from './dom-shim.mjs';
|
||||
install();
|
||||
globalThis.HOMECORE_UI_DEMO = false; // PRODUCTION path — no fixtures
|
||||
// fetch already rejects in the shim → simulates an unreachable gateway.
|
||||
|
||||
const fails = [], passes = [];
|
||||
async function t(name, fn) {
|
||||
try { await fn(); passes.push(name); }
|
||||
catch (e) { fails.push(`${name}: ${e && e.stack ? e.stack.split('\n').slice(0, 3).join(' | ') : e}`); }
|
||||
}
|
||||
const assert = (c, m) => { if (!c) throw new Error(m || 'assertion failed'); };
|
||||
|
||||
const { api, demoMode } = await import('../js/api.js');
|
||||
|
||||
await t('demoMode() is false in production', () => assert(demoMode() === false));
|
||||
await t('api.anyDemo() is false in production', () => assert(api.anyDemo() === false));
|
||||
|
||||
const PANELS = ['dashboard', 'fleet', 'seed-detail', 'entities', 'rooms', 'cogs', 'calibration', 'events', 'audit', 'settings'];
|
||||
const ctx = {
|
||||
api, navigate() {}, params: { id: 'seed-livingroom-a1' },
|
||||
onEvent() { return () => {}; },
|
||||
onWs(fn) { fn({ state: 'closed', lagged: false }); return () => {}; },
|
||||
};
|
||||
|
||||
for (const name of PANELS) {
|
||||
await t(`prod render (gateway down): ${name} shows a state, never throws`, async () => {
|
||||
const mod = await import(`../js/panels/${name}.js`);
|
||||
const root = document.createElement('div');
|
||||
const cleanup = await mod.default.render(root, ctx);
|
||||
// must render SOMETHING (header + error/empty state), not crash, not blank
|
||||
assert(root.children.length > 0, 'panel rendered nothing in prod error mode');
|
||||
if (typeof cleanup === 'function') cleanup();
|
||||
});
|
||||
}
|
||||
|
||||
// No data accessor may have flipped a demo flag in production.
|
||||
await t('no demo flags set after production renders', () => assert(api.anyDemo() === false, 'a panel served mock data in production'));
|
||||
|
||||
console.log(`\n${passes.length} passed, ${fails.length} failed`);
|
||||
if (fails.length) { console.error('\nFAILURES:'); fails.forEach((f) => console.error(' ✗ ' + f)); process.exit(1); }
|
||||
console.log('OK — every panel renders a typed empty/error state in production with no mock fallback');
|
||||
@@ -0,0 +1,109 @@
|
||||
// Render-smoke test — actually executes every HOMECORE-UI panel against
|
||||
// the DOM shim and asserts each builds a non-empty DOM subtree without
|
||||
// throwing. Also exercises the ui.js helpers and the mock contract.
|
||||
// Run: node tests/render-smoke.mjs (from the ui/ dir)
|
||||
import { install } from './dom-shim.mjs';
|
||||
install();
|
||||
globalThis.HOMECORE_UI_DEMO = true; // render panels against fixtures
|
||||
|
||||
const fails = [];
|
||||
const passes = [];
|
||||
function check(name, fn) {
|
||||
try { fn(); passes.push(name); }
|
||||
catch (e) { fails.push(`${name}: ${e && e.stack ? e.stack.split('\n').slice(0, 3).join(' | ') : e}`); }
|
||||
}
|
||||
async function checkAsync(name, fn) {
|
||||
try { await fn(); passes.push(name); }
|
||||
catch (e) { fails.push(`${name}: ${e && e.stack ? e.stack.split('\n').slice(0, 3).join(' | ') : e}`); }
|
||||
}
|
||||
|
||||
const ui = await import('../js/ui.js');
|
||||
const { api, entityProvenance } = await import('../js/api.js');
|
||||
const mock = await import('../js/mock.js');
|
||||
|
||||
// ── ui.js helper unit checks ────────────────────────────────────────
|
||||
check('ui.h builds element with class/id', () => {
|
||||
const n = ui.h('div.card#x', { 'data-k': 'v' }, 'hi');
|
||||
if (n.tagName !== 'DIV') throw new Error('tag');
|
||||
if (!n.classList.contains('card')) throw new Error('class');
|
||||
if (n.id !== 'x') throw new Error('id');
|
||||
});
|
||||
check('ui.statusPill maps running→green', () => {
|
||||
const p = ui.statusPill('running');
|
||||
if (!p.classList.contains('green')) throw new Error('expected green pill');
|
||||
});
|
||||
check('ui.statusPill maps offline→red', () => {
|
||||
if (!ui.statusPill('offline').classList.contains('red')) throw new Error('expected red');
|
||||
});
|
||||
check('ui.bar applies threshold colour', () => {
|
||||
const b = ui.bar(0.9, 1, [{ lt: 0.3, color: 'green' }, { lt: 0.6, color: 'amber' }, { lt: 1.01, color: 'red' }]);
|
||||
if (!b.firstChild.classList.contains('red')) throw new Error('expected red fill at 0.9');
|
||||
});
|
||||
check('ui.confidenceBar amber under 0.4', () => {
|
||||
if (!ui.confidenceBar(0.2).firstChild.classList.contains('amber')) throw new Error('low conf should be amber');
|
||||
});
|
||||
check('ui.provenanceBadge marks hailo', () => {
|
||||
const p = ui.provenanceBadge({ esp32: 'e', seed: 's', cog: 'c', hailo: true });
|
||||
if (!p.querySelector('.hailo')) throw new Error('hailo class missing');
|
||||
});
|
||||
check('ui.sparkline yields svg polyline', () => {
|
||||
const s = ui.sparkline([1, 2, 3, 4]);
|
||||
if (!s.querySelector('polyline')) throw new Error('no polyline');
|
||||
});
|
||||
|
||||
// ── mock contract checks ────────────────────────────────────────────
|
||||
check('mock RoomState distinguishes null vs withheld', () => {
|
||||
const rs = mock.roomStates();
|
||||
const office = rs.find((r) => r.room_id === 'office');
|
||||
if (office.posture !== null) throw new Error('office posture should be null (not trained)');
|
||||
const kitchen = rs.find((r) => r.room_id === 'kitchen');
|
||||
if (!kitchen.vetoed) throw new Error('kitchen should be vetoed');
|
||||
if (kitchen.posture.value !== null) throw new Error('vetoed posture value should be null/withheld, not zero');
|
||||
});
|
||||
check('analysis covers at least 3 bedrooms', () => {
|
||||
const beds = mock.roomStates().filter((r) => /^bedroom/.test(r.room_id));
|
||||
if (beds.length < 3) throw new Error(`expected ≥3 bedrooms in RoomState analysis, got ${beds.length}`);
|
||||
const bedSeeds = mock.seeds().filter((s) => /bedroom/i.test(s.zone));
|
||||
if (bedSeeds.length < 3) throw new Error(`expected ≥3 bedroom SEED nodes, got ${bedSeeds.length}`);
|
||||
});
|
||||
check('mock fleet has an offline seed with red tint semantics', () => {
|
||||
if (!mock.seeds().some((s) => !s.online)) throw new Error('need an offline seed for §4.1 tint');
|
||||
});
|
||||
check('mock federation states the raw-CSI invariant', () => {
|
||||
if (!/never raw CSI/i.test(mock.federation().invariant)) throw new Error('invariant text missing');
|
||||
});
|
||||
check('entityProvenance derives node→seed chain', () => {
|
||||
const prov = entityProvenance({ attributes: { source: 'esp32-lr-01 BFLD' } });
|
||||
if (prov.esp32 !== 'esp32-lr-01') throw new Error('node parse failed');
|
||||
if (!prov.seed) throw new Error('seed mapping failed');
|
||||
});
|
||||
|
||||
// ── render every panel ──────────────────────────────────────────────
|
||||
const PANELS = ['dashboard', 'fleet', 'seed-detail', 'entities', 'rooms', 'cogs', 'calibration', 'events', 'audit', 'settings'];
|
||||
const ctx = {
|
||||
api,
|
||||
navigate() {},
|
||||
params: { id: 'seed-livingroom-a1' },
|
||||
onEvent() { return () => {}; },
|
||||
onWs(fn) { fn({ state: 'open', lagged: false }); return () => {}; },
|
||||
wsStatus: () => ({ state: 'open', lagged: false }),
|
||||
bus: new globalThis.EventTarget(),
|
||||
};
|
||||
|
||||
for (const name of PANELS) {
|
||||
await checkAsync(`render panel: ${name}`, async () => {
|
||||
const mod = await import(`../js/panels/${name}.js`);
|
||||
const panel = mod.default;
|
||||
if (!panel || typeof panel.render !== 'function') throw new Error('no default.render export');
|
||||
if (!panel.meta || !panel.meta.title) throw new Error('missing meta.title');
|
||||
const root = document.createElement('div');
|
||||
const cleanup = await panel.render(root, ctx);
|
||||
if (root.children.length === 0) throw new Error('rendered nothing into root');
|
||||
if (cleanup && typeof cleanup === 'function') cleanup(); // must not throw
|
||||
});
|
||||
}
|
||||
|
||||
// ── report ──────────────────────────────────────────────────────────
|
||||
console.log(`\n${passes.length} passed, ${fails.length} failed`);
|
||||
if (fails.length) { console.error('\nFAILURES:'); fails.forEach((f) => console.error(' ✗ ' + f)); process.exit(1); }
|
||||
console.log('OK — all ui helpers, mock contracts, and 10 panels render without throwing');
|
||||
@@ -0,0 +1,67 @@
|
||||
// Static import/export graph verifier for HOMECORE-UI.
|
||||
// No deps — parses `import { a, b } from './x.js'` against the named
|
||||
// exports of x.js. Fails if a panel imports a symbol that doesn't exist.
|
||||
// Run: node tests/verify-imports.mjs (from the ui/ dir)
|
||||
import { readFileSync, readdirSync } from 'node:fs';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
|
||||
const ROOT = resolve(import.meta.dirname, '..');
|
||||
const files = [
|
||||
'js/ui.js', 'js/api.js', 'js/ws.js', 'js/mock.js', 'js/app.js',
|
||||
...readdirSync(resolve(ROOT, 'js/panels')).filter((f) => f.endsWith('.js')).map((f) => 'js/panels/' + f),
|
||||
];
|
||||
|
||||
function namedExports(src) {
|
||||
const out = new Set();
|
||||
// export function/const/class NAME
|
||||
for (const m of src.matchAll(/export\s+(?:async\s+)?(?:function|const|let|class)\s+([A-Za-z0-9_$]+)/g)) out.add(m[1]);
|
||||
// export { a, b as c }
|
||||
for (const m of src.matchAll(/export\s*\{([^}]*)\}/g)) {
|
||||
for (const part of m[1].split(',')) {
|
||||
const name = part.trim().split(/\s+as\s+/).pop().trim();
|
||||
if (name) out.add(name);
|
||||
}
|
||||
}
|
||||
if (/export\s+default/.test(src)) out.add('default');
|
||||
return out;
|
||||
}
|
||||
|
||||
function imports(src) {
|
||||
const res = [];
|
||||
for (const m of src.matchAll(/import\s+([^;]+?)\s+from\s+['"]([^'"]+)['"]/g)) {
|
||||
const clause = m[1].trim(), spec = m[2];
|
||||
const names = [];
|
||||
const named = clause.match(/\{([^}]*)\}/);
|
||||
if (named) for (const p of named[1].split(',')) { const n = p.trim().split(/\s+as\s+/)[0].trim(); if (n) names.push(n); }
|
||||
const def = clause.replace(/\{[^}]*\}/, '').replace(/\*\s+as\s+\w+/, '').replace(/,/g, '').trim();
|
||||
if (def) names.push('default');
|
||||
if (/\*\s+as\s+/.test(clause)) names.push('*');
|
||||
res.push({ spec, names });
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
const exportCache = {};
|
||||
function exportsOf(absPath) {
|
||||
if (!exportCache[absPath]) exportCache[absPath] = namedExports(readFileSync(absPath, 'utf8'));
|
||||
return exportCache[absPath];
|
||||
}
|
||||
|
||||
let errors = 0;
|
||||
for (const rel of files) {
|
||||
const abs = resolve(ROOT, rel);
|
||||
const src = readFileSync(abs, 'utf8');
|
||||
for (const imp of imports(src)) {
|
||||
if (!imp.spec.startsWith('.')) continue; // skip bare specifiers
|
||||
const target = resolve(dirname(abs), imp.spec);
|
||||
let exps;
|
||||
try { exps = exportsOf(target); } catch { console.error(`✗ ${rel}: cannot resolve ${imp.spec}`); errors++; continue; }
|
||||
for (const n of imp.names) {
|
||||
if (n === '*') continue;
|
||||
if (!exps.has(n)) { console.error(`✗ ${rel}: imports '${n}' from ${imp.spec} which does not export it`); errors++; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (errors) { console.error(`\nFAILED — ${errors} unresolved import(s)`); process.exit(1); }
|
||||
console.log(`OK — import/export graph consistent across ${files.length} modules`);
|
||||
Reference in New Issue
Block a user