mirror of
https://github.com/ruvnet/RuView.git
synced 2026-10-18 21:00:22 -05:00
feat(ruview-live): redesigned pane — LIVE badge, cards, trends, plausibility flags
- Header: RuView + LIVE/STALE badge (fresh within 2 refresh periods), "updated <time> (Ns ago)", a 5 s redraw tick keeps the age current. - Bordered cards for the radar and the CSI nodes, side by side from 100 columns, stacked below. - Radar: PRESENCE DETECTED (+ target count) in green; distance, heart and breathing with per-refresh sparklines; device-reported vitals outside resting ranges (heart 40-180, breathing 4-40 bpm) are flagged in yellow instead of shown as fact (the live kit reported breathing 1.0 bpm). - Nodes: per-node rate sparkline; "no packets" is a short hint in the card rather than a long top alert (status no longer counts it as an alert); an unconfigured radar says how to configure it. - Buttons show their hotkeys. Tests: 11 Node unit tests, 3 engine tests (mounted pane validates on the terminal surface), validate passes. Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
1 parent
bd7c506076
commit
c4d7ef8086
3 files changed
+224
-37
No files matched your search
@@ -10,6 +10,8 @@
|
||||
export const PANE_ID = 'ruview-live';
|
||||
export const COMMAND = 'ruview';
|
||||
const HOST_RE = /^[A-Za-z0-9][A-Za-z0-9.-]{0,252}$/;
|
||||
const HISTORY = 24;
|
||||
const TICK_MS = 5000;
|
||||
|
||||
/**
|
||||
* The harness CLI beside this mod: the plugin root is <pkg>/mod, so the CLI is
|
||||
@@ -56,15 +58,48 @@ export function resultOf(run) {
|
||||
|
||||
const fixed = (v, d = 1) => (typeof v === 'number' && Number.isFinite(v) ? v.toFixed(d) : '—');
|
||||
const pct = (v) => (typeof v === 'number' ? `${(v * 100).toFixed(1)}%` : '—');
|
||||
const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
|
||||
|
||||
/** Resting ranges outside which a device-reported vital is flagged, not trusted. */
|
||||
export const RANGES = Object.freeze({ heart: [40, 180], breathing: [4, 40] });
|
||||
|
||||
/** Why a device-reported vital looks implausible, or null. */
|
||||
export function plausibilityOf(kind, value) {
|
||||
if (value == null || !RANGES[kind]) return null;
|
||||
const [lo, hi] = RANGES[kind];
|
||||
return value < lo || value > hi ? `outside ${lo}–${hi} bpm` : null;
|
||||
}
|
||||
|
||||
const SPARK = '▁▂▃▄▅▆▇█';
|
||||
/** Unicode sparkline of the last `width` finite values ('' below two points). */
|
||||
export function sparkline(values, width = 16) {
|
||||
const v = (values || []).filter((x) => typeof x === 'number' && Number.isFinite(x)).slice(-width);
|
||||
if (v.length < 2) return '';
|
||||
const lo = Math.min(...v);
|
||||
const hi = Math.max(...v);
|
||||
return v.map((x) => SPARK[hi === lo ? 3 : Math.round(((x - lo) / (hi - lo)) * 7)]).join('');
|
||||
}
|
||||
|
||||
/** "12s ago" / "3m ago" from a millisecond age, or '' when unknown. */
|
||||
export function ageOf(ms) {
|
||||
if (!Number.isFinite(ms) || ms < 0) return '';
|
||||
const s = Math.round(ms / 1000);
|
||||
return s < 60 ? `${s}s ago` : `${Math.round(s / 60)}m ago`;
|
||||
}
|
||||
|
||||
/** The pane's model: plain data, drawn by viewOf and summarised by statusOf. */
|
||||
export function modelOf(capture, radar, at) {
|
||||
const alerts = [];
|
||||
if (capture && capture.ok === false) alerts.push({ level: capture.reason === 'no_packets' ? 'warn' : 'bad', text: `nodes: ${String(capture.reason).replace(/_/g, ' ')}${capture.remedy ? ` — ${capture.remedy}` : ''}` });
|
||||
// "No packets" is shown inside the nodes card; only real faults become alerts.
|
||||
if (capture && capture.ok === false && capture.reason !== 'no_packets') {
|
||||
alerts.push({ level: 'bad', text: `nodes: ${String(capture.reason).replace(/_/g, ' ')}${capture.remedy ? ` — ${capture.remedy}` : ''}` });
|
||||
}
|
||||
for (const hb of capture?.heartbeatOnlySenders || []) alerts.push({ level: 'warn', text: `${hb.address} sends heartbeats but no CSI (run \`ruview monitor --baud 1500000\` on it: "lack of csi buf" = buffer starvation)` });
|
||||
if (radar && radar.ok === false) alerts.push({ level: 'bad', text: `radar: ${String(radar.reason).replace(/_/g, ' ')}${radar.detail ? ` — ${radar.detail}` : ''}` });
|
||||
const nodes = (capture?.nodes || []).map((n) => ({
|
||||
key: `${n.source}:${n.nodeId}`,
|
||||
label: `${n.source === 'realtek' ? 'realtek' : 'esp32'} ${n.nodeId}`,
|
||||
rateHz: num(n.csiRateHz),
|
||||
rate: `${fixed(n.csiRateHz)} Hz`,
|
||||
loss: pct(n.csiLossFraction),
|
||||
rssi: `${fixed(n.rssiMean)} dBm`,
|
||||
@@ -75,11 +110,32 @@ export function modelOf(capture, radar, at) {
|
||||
const radarRow = radar && radar.ok !== false ? {
|
||||
name: radar.device?.name || radar.host || 'radar',
|
||||
present: radar.presentNow ?? (radar.presentFraction == null ? null : radar.presentFraction >= 0.5),
|
||||
targets: num(radar.targetsMax),
|
||||
distanceCm: num(radar.distanceCmMean),
|
||||
heartBpm: num(radar.heartBpmMean),
|
||||
breathingBpm: num(radar.breathingBpmMean),
|
||||
distance: radar.distanceCmMean == null ? '—' : `${fixed(radar.distanceCmMean)} cm`,
|
||||
heart: radar.heartBpmMean == null ? '—' : `${fixed(radar.heartBpmMean)} bpm`,
|
||||
breathing: radar.breathingBpmMean == null ? '—' : `${fixed(radar.breathingBpmMean)} bpm`,
|
||||
} : null;
|
||||
return { at, packets: capture?.packets ?? 0, decoded: capture?.decodedPackets ?? 0, nodes, radar: radarRow, alerts };
|
||||
return {
|
||||
at, packets: capture?.packets ?? 0, decoded: capture?.decodedPackets ?? 0,
|
||||
noPackets: capture?.reason === 'no_packets', nodes, radar: radarRow, alerts,
|
||||
};
|
||||
}
|
||||
|
||||
/** Append one model to a bounded history of trend values. */
|
||||
export function historyWith(history, model, max = HISTORY) {
|
||||
const h = history || { heart: [], breathing: [], distance: [], rates: {} };
|
||||
const cap = (arr, v) => [...arr, v].slice(-max);
|
||||
const rates = { ...h.rates };
|
||||
for (const n of model?.nodes || []) rates[n.key] = cap(rates[n.key] || [], n.rateHz);
|
||||
return {
|
||||
heart: cap(h.heart, model?.radar?.heartBpm ?? null),
|
||||
breathing: cap(h.breathing, model?.radar?.breathingBpm ?? null),
|
||||
distance: cap(h.distance, model?.radar?.distanceCm ?? null),
|
||||
rates,
|
||||
};
|
||||
}
|
||||
|
||||
/** One line for the status bar. */
|
||||
@@ -91,36 +147,101 @@ export function statusOf(model) {
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
/** Draw the pane with the surface's own elements ({ Box, Text, Button }). */
|
||||
export function viewOf(ui, model, { refreshMs, busy, onRefresh, onClose }) {
|
||||
/**
|
||||
* Draw the pane with the surface's own elements ({ Box, Text, Button }).
|
||||
* opts: { refreshMs, busy, onRefresh, onClose, columns, now, history, udpPort, radarConfigured }.
|
||||
*/
|
||||
export function viewOf(ui, model, opts) {
|
||||
const { Box, Text, Button } = ui;
|
||||
const line = (children, props = {}) => Text({ ...props, wrap: 'truncate-end', children });
|
||||
const rows = [];
|
||||
const when = model && Number.isFinite(model.at) ? new Date(model.at).toLocaleTimeString() : '—';
|
||||
rows.push(line(model ? `updated ${when} · every ${Math.round(refreshMs / 1000)}s · ${model.decoded}/${model.packets} packets decoded` : 'waiting for the first capture…', { dimColor: true }));
|
||||
for (const a of model?.alerts || []) rows.push(Text({ color: a.level === 'bad' ? 'red' : 'yellow', wrap: 'wrap', children: `! ${a.text}` }));
|
||||
if (model) {
|
||||
rows.push(line('NODES', { bold: true }));
|
||||
if (!model.nodes.length) rows.push(line(' none streaming to this machine', { dimColor: true }));
|
||||
for (const n of model.nodes) {
|
||||
rows.push(Box({ flexDirection: 'row', gap: 2, children: [
|
||||
line(n.label.padEnd(10)), line(n.rate.padStart(9)),
|
||||
line(`loss ${n.loss}`, n.lossy ? { color: 'yellow' } : {}), line(n.rssi), line(n.shape, { dimColor: true }),
|
||||
n.synthetic ? line('SYNTHETIC', { color: 'yellow' }) : null,
|
||||
].filter(Boolean) }));
|
||||
}
|
||||
if (model.radar) {
|
||||
const r = model.radar;
|
||||
rows.push(line(`RADAR ${r.name}`, { bold: true }));
|
||||
rows.push(line(` presence ${r.present == null ? '—' : r.present ? '● detected' : '○ none'} · distance ${r.distance} · heart ${r.heart} · breathing ${r.breathing}`, r.present ? { color: 'green' } : {}));
|
||||
rows.push(line(' device-reported values, not validated against a reference', { dimColor: true }));
|
||||
}
|
||||
const { refreshMs, busy, onRefresh, onClose } = opts;
|
||||
const columns = opts.columns ?? 80;
|
||||
const history = opts.history || historyWith(null, null, 0);
|
||||
const t = (children, props = {}) => Text({ wrap: 'truncate-end', ...props, children });
|
||||
const row = (children, props = {}) => Box({ flexDirection: 'row', gap: 1, ...props, children: children.filter(Boolean) });
|
||||
const every = `every ${Math.round(refreshMs / 1000)}s`;
|
||||
|
||||
// Header: freshness badge, update time and age.
|
||||
const finiteAt = model && Number.isFinite(model.at);
|
||||
const age = finiteAt && Number.isFinite(opts.now) ? opts.now - model.at : NaN;
|
||||
const live = Number.isFinite(age) && age <= refreshMs * 2 + 5000;
|
||||
const when = finiteAt ? new Date(model.at).toLocaleTimeString() : '—';
|
||||
const badge = !model ? t('○ STARTING', { dimColor: true, bold: true })
|
||||
: live ? t('● LIVE', { color: 'green', bold: true }) : t('○ STALE', { color: 'yellow', bold: true });
|
||||
const header = row([
|
||||
t('RuView', { color: 'cyan', bold: true }),
|
||||
badge,
|
||||
t(model ? `updated ${when}${ageOf(age) ? ` (${ageOf(age)})` : ''} · ${every}` : `waiting for the first capture… · ${every}`, { dimColor: true }),
|
||||
busy ? t('⟳ refreshing', { color: 'cyan' }) : null,
|
||||
], { justifyContent: 'flex-start' });
|
||||
|
||||
const alerts = (model?.alerts || []).map((a) => Text({ color: a.level === 'bad' ? 'red' : 'yellow', wrap: 'wrap', children: `! ${a.text}` }));
|
||||
|
||||
// Radar card.
|
||||
const vital = (label, value, raw, kind, series, unitWidth) => {
|
||||
const warn = kind ? plausibilityOf(kind, raw) : null;
|
||||
return row([
|
||||
t(label.padEnd(10), { dimColor: true }),
|
||||
t(value.padStart(unitWidth), { bold: raw != null && !warn, color: warn ? 'yellow' : undefined }),
|
||||
t(sparkline(series), { color: 'cyan' }),
|
||||
warn ? t(`⚠ ${warn}`, { color: 'yellow' }) : null,
|
||||
]);
|
||||
};
|
||||
let radarCard = null;
|
||||
if (model?.radar) {
|
||||
const r = model.radar;
|
||||
const presence = r.present == null ? t('? presence unknown', { dimColor: true })
|
||||
: r.present ? t(`● PRESENCE DETECTED${r.targets ? ` · ${r.targets} target${r.targets === 1 ? '' : 's'}` : ''}`, { color: 'green', bold: true })
|
||||
: t('○ no presence', { dimColor: true });
|
||||
radarCard = card('60 GHz RADAR', r.name, r.present ? 'green' : 'gray', [
|
||||
presence,
|
||||
vital('distance', r.distance, r.distanceCm, null, history.distance, 9),
|
||||
vital('heart', r.heart, r.heartBpm, 'heart', history.heart, 9),
|
||||
vital('breathing', r.breathing, r.breathingBpm, 'breathing', history.breathing, 9),
|
||||
t('device-reported values, not validated against a reference', { dimColor: true, italic: true }),
|
||||
]);
|
||||
} else if (model && !opts.radarConfigured) {
|
||||
radarCard = card('60 GHz RADAR', 'not configured', 'gray', [
|
||||
t('Set radarHost to an ESPHome radar kit:', { dimColor: true }),
|
||||
t('claude plugin configure ruview-live', { color: 'cyan' }),
|
||||
]);
|
||||
}
|
||||
rows.push(Box({ flexDirection: 'row', gap: 2, children: [
|
||||
Button({ key: 'refresh', label: busy ? 'Refreshing…' : 'Refresh', hotkey: 'r', onPress: onRefresh }),
|
||||
Button({ key: 'close', label: 'Close', hotkey: 'c', onPress: onClose }),
|
||||
] }));
|
||||
return Box({ flexDirection: 'column', gap: 0, padding: 1, children: rows });
|
||||
|
||||
// Nodes card.
|
||||
let nodesCard = null;
|
||||
if (model) {
|
||||
const lines = model.nodes.length
|
||||
? model.nodes.map((n) => row([
|
||||
t(n.label.padEnd(10), { bold: true }),
|
||||
t(n.rate.padStart(9)),
|
||||
t(sparkline(history.rates[n.key]), { color: 'cyan' }),
|
||||
t(`loss ${n.loss}`, n.lossy ? { color: 'yellow' } : { dimColor: true }),
|
||||
t(n.rssi, { dimColor: true }),
|
||||
t(n.shape, { dimColor: true }),
|
||||
n.synthetic ? t('SYNTHETIC', { color: 'yellow', bold: true }) : null,
|
||||
]))
|
||||
: [
|
||||
t('none streaming to this machine', { dimColor: true }),
|
||||
model.noPackets ? t(`check node target IP/port · firewall UDP ${opts.udpPort ?? 5005}`, { dimColor: true }) : null,
|
||||
].filter(Boolean);
|
||||
nodesCard = card('CSI NODES', `UDP ${opts.udpPort ?? 5005} · ${model.decoded}/${model.packets} decoded`, model.nodes.length ? 'cyan' : 'gray', lines);
|
||||
}
|
||||
|
||||
function card(title, subtitle, borderColor, children) {
|
||||
return Box({
|
||||
flexDirection: 'column', borderStyle: 'round', borderColor, paddingX: 1, flexGrow: 1,
|
||||
children: [row([t(title, { bold: true }), t(subtitle, { dimColor: true })]), ...children],
|
||||
});
|
||||
}
|
||||
|
||||
const wide = columns >= 100;
|
||||
const cards = [radarCard, nodesCard].filter(Boolean);
|
||||
const body = cards.length ? Box({ flexDirection: wide ? 'row' : 'column', gap: 1, children: cards }) : null;
|
||||
const footer = row([
|
||||
Button({ key: 'refresh', label: busy ? 'Refreshing…' : 'Refresh (r)', hotkey: 'r', onPress: onRefresh }),
|
||||
Button({ key: 'close', label: 'Close (c)', hotkey: 'c', onPress: onClose }),
|
||||
], { gap: 2 });
|
||||
|
||||
return Box({ flexDirection: 'column', gap: 1, paddingX: 1, children: [header, ...alerts, body, footer].filter(Boolean) });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -135,10 +256,15 @@ export function register(on, options = {}) {
|
||||
let isOpen = false;
|
||||
let busy = false;
|
||||
let model = null;
|
||||
let history = null;
|
||||
let stopTimer = null;
|
||||
let stopTick = null;
|
||||
let cliPath = null;
|
||||
|
||||
const stop = () => { if (stopTimer) { stopTimer(); stopTimer = null; } };
|
||||
const stop = () => {
|
||||
if (stopTimer) { stopTimer(); stopTimer = null; }
|
||||
if (stopTick) { stopTick(); stopTick = null; }
|
||||
};
|
||||
const cancelOf = (t) => (typeof t === 'function' ? t : () => t?.cancel?.());
|
||||
|
||||
async function runCli(args) {
|
||||
@@ -158,6 +284,7 @@ export function register(on, options = {}) {
|
||||
const [capture, radar] = await Promise.all([runCli(commands.capture), runCli(commands.radar)]);
|
||||
// $.clock.now() resolves a promise of epoch milliseconds.
|
||||
model = modelOf(resultOf(capture), resultOf(radar), await host.now());
|
||||
history = historyWith(history, model);
|
||||
host.status(statusOf(model));
|
||||
} finally {
|
||||
busy = false;
|
||||
@@ -165,11 +292,12 @@ export function register(on, options = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Start the refresh timer and fetch now, unless already polling. */
|
||||
/** Start the refresh timer (and the age tick) and fetch now, unless already polling. */
|
||||
function startPolling() {
|
||||
isOpen = true;
|
||||
if (stopTimer) return;
|
||||
stopTimer = cancelOf(host.every(settings.refreshMs, () => { void refresh(); }));
|
||||
stopTick = cancelOf(host.every(TICK_MS, () => host.invalidate()));
|
||||
void refresh();
|
||||
}
|
||||
|
||||
@@ -217,7 +345,13 @@ export function register(on, options = {}) {
|
||||
// open, so resume polling instead of waiting for the first capture forever.
|
||||
if (host && !stopTimer) startPolling();
|
||||
const ui = await $.ui.resolve(e);
|
||||
return viewOf(ui, model, { refreshMs: settings.refreshMs, busy, onRefresh: () => { void refresh(); }, onClose: () => { void close(); } });
|
||||
const now = await $.clock.now();
|
||||
return viewOf(ui, model, {
|
||||
refreshMs: settings.refreshMs, busy, history, now,
|
||||
columns: e.props?.bodyColumns ?? e.viewport?.columns ?? 80,
|
||||
udpPort: settings.udpPort, radarConfigured: Boolean(settings.radarHost),
|
||||
onRefresh: () => { void refresh(); }, onClose: () => { void close(); },
|
||||
});
|
||||
});
|
||||
|
||||
on('ui.close', { id: PANE_ID }, async ($, e, next) => {
|
||||
|
||||
@@ -130,7 +130,7 @@ describe('register', () => {
|
||||
await $.session.start({ surface: 'terminal', isInteractive: true, cwd: '/work' })
|
||||
const { text } = await $.command.run({ command: 'ruview', args: 'refresh', origin: { kind: 'composer' } })
|
||||
|
||||
expect(text).toBe('RuView · 0 nodes · 1 alert')
|
||||
expect(statuses.at(-1)).toBe('RuView · 0 nodes · 1 alert')
|
||||
expect(text).toBe('RuView · 0 nodes')
|
||||
expect(statuses.at(-1)).toBe('RuView · 0 nodes')
|
||||
})
|
||||
})
|
||||
@@ -3,7 +3,7 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { cliPathOf, commandsOf, modelOf, resultOf, settingsOf, statusOf, viewOf } from '../mod/hooks/register.mjs';
|
||||
import { ageOf, cliPathOf, commandsOf, historyWith, modelOf, plausibilityOf, resultOf, settingsOf, sparkline, statusOf, viewOf } from '../mod/hooks/register.mjs';
|
||||
|
||||
const capture = {
|
||||
ok: true, packets: 120, decodedPackets: 118,
|
||||
@@ -62,7 +62,7 @@ test('the pane draws with the surface elements and wires both buttons', () => {
|
||||
walk(tree);
|
||||
const texts = flat.filter((n) => n.type === 'Text').map((n) => String(n.props.children));
|
||||
assert.ok(texts.some((t) => t.includes('heartbeats but no CSI')));
|
||||
assert.ok(texts.some((t) => t.startsWith('RADAR')));
|
||||
assert.ok(texts.some((t) => t === '60 GHz RADAR'));
|
||||
assert.ok(texts.some((t) => t.includes('not validated')));
|
||||
assert.ok(texts.some((t) => t === 'SYNTHETIC'));
|
||||
const buttons = flat.filter((n) => n.type === 'Button');
|
||||
@@ -89,6 +89,59 @@ test('refresh awaits the clock before building the model', () => {
|
||||
assert.match(src, /model = modelOf\(.*await host\.now\(\)\);/);
|
||||
});
|
||||
|
||||
test('sparklines, plausibility, ages and history are bounded and honest', () => {
|
||||
assert.equal(sparkline([1, 2, 3, 4, 5, 6, 7, 8]), '▁▂▃▄▅▆▇█');
|
||||
assert.equal(sparkline([5]), '', 'one point is not a trend');
|
||||
assert.equal(sparkline([null, 3, NaN, 3]), '▄▄');
|
||||
assert.equal(plausibilityOf('breathing', 1.0), 'outside 4–40 bpm', 'the live 1.0 bpm reading is flagged');
|
||||
assert.equal(plausibilityOf('heart', 80.7), null);
|
||||
assert.equal(plausibilityOf('heart', 250), 'outside 40–180 bpm');
|
||||
assert.equal(plausibilityOf('breathing', null), null);
|
||||
assert.equal(ageOf(12_400), '12s ago');
|
||||
assert.equal(ageOf(185_000), '3m ago');
|
||||
assert.equal(ageOf(NaN), '');
|
||||
let h = null;
|
||||
for (let i = 0; i < 30; i++) h = historyWith(h, modelOf(capture, { ...radar, heartBpmMean: 70 + i }, i));
|
||||
assert.equal(h.heart.length, 24, 'history is bounded');
|
||||
assert.equal(h.heart.at(-1), 99);
|
||||
assert.equal(h.rates['realtek:3'].length, 24);
|
||||
});
|
||||
|
||||
test('the redesigned pane: badge, cards side by side when wide, flags implausible vitals', () => {
|
||||
const el = (type) => (props) => ({ type, props });
|
||||
const ui = { Box: el('Box'), Text: el('Text'), Button: el('Button') };
|
||||
const live = { ...radar, breathingBpmMean: 1.0, heartBpmMean: 80.7, distanceCmMean: 40.7, targetsMax: 1 };
|
||||
const model = modelOf(capture, live, 1_000_000);
|
||||
const base = { refreshMs: 15000, busy: false, onRefresh() {}, onClose() {}, udpPort: 5005, radarConfigured: true };
|
||||
const flat = (node, out = []) => { if (node && typeof node === 'object') { out.push(node); [].concat(node.props?.children ?? []).forEach((c) => flat(c, out)); } return out; };
|
||||
const wide = flat(viewOf(ui, model, { ...base, columns: 140, now: 1_012_000 }));
|
||||
const texts = wide.filter((n) => n.type === 'Text').map((n) => String(n.props.children));
|
||||
assert.ok(texts.includes('● LIVE'));
|
||||
assert.ok(texts.some((t) => t.includes('(12s ago)')));
|
||||
assert.ok(texts.some((t) => t.startsWith('● PRESENCE DETECTED · 1 target')));
|
||||
assert.ok(texts.includes('⚠ outside 4–40 bpm'), 'breathing 1.0 bpm is flagged');
|
||||
assert.ok(!texts.includes('⚠ outside 40–180 bpm'), 'heart 80.7 bpm is not flagged');
|
||||
const cardsRow = wide.find((n) => n.type === 'Box' && n.props.children?.some?.((c) => c?.props?.borderStyle === 'round'));
|
||||
assert.equal(cardsRow.props.flexDirection, 'row', 'cards sit side by side when wide');
|
||||
const narrow = flat(viewOf(ui, model, { ...base, columns: 80, now: 1_012_000 }));
|
||||
assert.equal(narrow.find((n) => n.type === 'Box' && n.props.children?.some?.((c) => c?.props?.borderStyle === 'round')).props.flexDirection, 'column');
|
||||
const stale = flat(viewOf(ui, model, { ...base, columns: 80, now: 1_000_000 + 120_000 }));
|
||||
assert.ok(stale.some((n) => n.type === 'Text' && n.props.children === '○ STALE'));
|
||||
for (const n of wide) assert.ok(!('flexBasis' in (n.props || {})), 'only props the terminal Box takes');
|
||||
});
|
||||
|
||||
test('no packets is a hint inside the nodes card, not a top alert', () => {
|
||||
const el = (type) => (props) => ({ type, props });
|
||||
const ui = { Box: el('Box'), Text: el('Text'), Button: el('Button') };
|
||||
const m = modelOf({ ok: false, reason: 'no_packets', packets: 0, remedy: 'long remedy text' }, null, 0);
|
||||
assert.equal(m.alerts.length, 0);
|
||||
assert.equal(statusOf(m), 'RuView · 0 nodes');
|
||||
const s = JSON.stringify(viewOf(ui, m, { refreshMs: 15000, busy: false, onRefresh() {}, onClose() {}, udpPort: 5005, radarConfigured: false, columns: 80, now: 0 }));
|
||||
assert.match(s, /check node target IP\/port · firewall UDP 5005/);
|
||||
assert.match(s, /claude plugin configure ruview-live/, 'an unconfigured radar says how to set it');
|
||||
assert.doesNotMatch(s, /long remedy text/);
|
||||
});
|
||||
|
||||
test('the mod manifest and hooks module are a valid plugin shape', () => {
|
||||
const plugin = JSON.parse(readFileSync(new URL('../mod/.claude-plugin/plugin.json', import.meta.url), 'utf8'));
|
||||
const hooks = JSON.parse(readFileSync(new URL('../mod/hooks/hooks.json', import.meta.url), 'utf8'));
|
||||
|
||||
Reference in new issue
Block a user