Files
meshcore-analyzer/test-panel-corner.js
T
Kpa-clawbot b74a64ccfa fix(ui): canonical payload label map across packets/live/packet-filter (#1799) (#1804)
## Summary

Replaces the three drifted per-surface payload-type label vocabularies
with a single canonical map keyed by firmware enum name.

Per the locked triage comment on #1799
([comment-4823975431](https://github.com/Kpa-clawbot/CoreScope/issues/1799#issuecomment-4823975431)):

> Create `public/payload-labels.js` exporting `{GRP_DATA: {short:'Group
Data', long:'Group data packet', enumId:6}, ...}`. Migrate `packets.js
typeMap`, `packet-filter.js FW_PAYLOAD_TYPES`, `live.js TYPE_COLORS
legend` to consume it. E2E that scrapes each surface and asserts label
equality.

## Changes

- **`public/payload-labels.js`** (new) — canonical map exposed as
`window.PayloadLabels` and `window.PayloadLabelsApi`. Keys are firmware
enum names; values carry `{short, long, enumId}` plus derived
`SHORT_BY_ID` / `FW_PAYLOAD_TYPES` / `TYPE_ALIASES` for legacy callers.
- **`public/packets.js`** — `TYPE_NAMES` + `typeMap` now read from
`PayloadLabelsApi.SHORT_BY_ID`. Literal kept only as a defensive
fallback for the case where the script tag fails to load.
- **`public/packet-filter.js`** — `FW_PAYLOAD_TYPES` + `TYPE_ALIASES`
now sourced from `PayloadLabelsApi`. Literal fallback retained so `node
test-packet-filter.js` still works headlessly.
- **`public/live.js`** — legend `<li>` rows are now generated from
`window.PayloadLabels` in stable order, killing the third-vocabulary
`Message — Group text` / `Direct — Direct message` drift the #1797
review surfaced.
- **`public/index.html`** — `<script src="payload-labels.js">` loaded
before `roles.js` / `packet-filter.js` / `packets.js`.
- **`test-issue-1799-label-vocab-e2e.js`** (new) — Playwright E2E.
Scrapes `#liveLegend` rows and the `/packets` type-filter checklist,
asserts each label matches `window.PayloadLabels[ENUM].short` for
`TXT_MSG`, `GRP_TXT`, `GRP_DATA`. Also verifies `window.PacketFilter`
still recognises the enum names.
- **`.github/workflows/deploy.yml`** — wired the new E2E into the
existing Playwright block.

## TDD trail

- Red commit `eb392d4` — adds the failing E2E only (asserts
`window.PayloadLabels` exists and labels match; both fail).
- Green commit `44e902a` — introduces the canonical map and migrates the
three surfaces.

## Verification

- `node test-packet-filter.js` — 92/92 pass with the new fallback
wiring.
- Preflight: `bash ~/.openclaw/skills/pr-preflight/scripts/run-all.sh
origin/master` — clean.

Browser verified: E2E `test-issue-1799-label-vocab-e2e.js` exercises
`/live` legend + `/packets` type filter against a Playwright headless
Chromium; CI's Playwright block runs it on every push.

E2E assertion added: `test-issue-1799-label-vocab-e2e.js:139` —
`assert(fromLegend === canon, ...)` and `assert(fromPackets === canon,
...)` per enum.

Fixes #1799

---------

Co-authored-by: mc-bot <bot@corescope>
Co-authored-by: openclaw-bot <bot@openclaw.local>
Co-authored-by: clawbot <clawbot@kpa.com>
Co-authored-by: clawbot <bot@clawbot.local>
2026-06-30 05:48:47 -07:00

335 lines
12 KiB
JavaScript

/**
* Tests for panel corner positioning (#608 M0)
* Tests the pure logic functions extracted from live.js
*/
'use strict';
const assert = require('assert');
const vm = require('vm');
const fs = require('fs');
const path = require('path');
// Minimal DOM/browser stubs
function createContext() {
const storage = {};
const elements = {};
const listeners = {};
const mockEl = () => ({
style: {}, textContent: '', innerHTML: '',
classList: { add(){}, remove(){}, toggle(){}, contains(){ return false; } },
appendChild(c){ return c; }, removeChild(){ }, insertBefore(c){ return c; },
setAttribute(){}, getAttribute(){ return null; }, removeAttribute(){},
addEventListener(){}, removeEventListener(){},
querySelector(){ return null; }, querySelectorAll(){ return []; },
getBoundingClientRect(){ return {top:0,left:0,right:0,bottom:0,width:0,height:0}; },
closest(){ return null; }, matches(){ return false; },
children: [], childNodes: [], parentNode: null, parentElement: null,
focus(){}, blur(){}, click(){}, scrollTo(){},
dataset: {}, offsetWidth: 0, offsetHeight: 0,
getContext(){ return { clearRect(){}, fillRect(){}, beginPath(){}, moveTo(){}, lineTo(){}, stroke(){}, fill(){}, arc(){}, save(){}, restore(){}, translate(){}, rotate(){}, scale(){}, drawImage(){}, measureText(){ return {width:0}; }, createLinearGradient(){ return {addColorStop(){}}; }, canvas: {width:0,height:0} }; },
width: 0, height: 0,
});
const ctx = {
window: {},
document: {
getElementById: (id) => elements[id] || null,
querySelectorAll: (sel) => {
const results = [];
for (const id in elements) {
const el = elements[id];
if (el._btns) results.push(...el._btns);
}
return results;
},
querySelector: () => null,
documentElement: { getAttribute: () => null, style: {} },
addEventListener: () => {},
createElement: () => mockEl(),
createElementNS: () => mockEl(),
createTextNode: (t) => ({ textContent: t }),
createDocumentFragment: () => ({ appendChild(){}, children: [] }),
body: { appendChild(){}, removeChild(){}, style: {}, classList: { add(){}, remove(){} } },
head: { appendChild(){} },
},
localStorage: {
getItem: (k) => storage[k] !== undefined ? storage[k] : null,
setItem: (k, v) => { storage[k] = String(v); },
removeItem: (k) => { delete storage[k]; }
},
_storage: storage,
_elements: elements,
_addElement: function(id) {
const attrs = {};
const btns = [];
elements[id] = {
setAttribute: (k, v) => { attrs[k] = v; },
getAttribute: (k) => attrs[k] || null,
querySelector: (sel) => {
if (sel === '.panel-corner-btn') return btns[0] || null;
return null;
},
_attrs: attrs,
_btns: btns,
_addBtn: function(panelId) {
const btnAttrs = { 'data-panel': panelId };
const btn = {
textContent: '',
setAttribute: (k, v) => { btnAttrs[k] = v; },
getAttribute: (k) => btnAttrs[k] || null,
addEventListener: () => {},
_attrs: btnAttrs
};
btns.push(btn);
return btn;
}
};
return elements[id];
}
};
// Self-references
ctx.window = ctx;
ctx.self = ctx;
return ctx;
}
function loadLiveModule(ctx) {
// Load the REAL live.js in a VM context and return window._panelCorner.
// This tests the actual code, not a copy (per AGENTS.md "test the real code, not copies").
const src = fs.readFileSync(path.join(__dirname, 'public', 'live.js'), 'utf8');
// Minimal stubs for live.js dependencies (only what's needed to avoid errors)
ctx.registerPage = () => {};
ctx.escapeHtml = (s) => String(s || '');
ctx.timeAgo = () => '—';
ctx.getParsedPath = () => [];
ctx.getParsedDecoded = () => ({});
ctx.TYPE_COLORS = { ADVERT: '#22c55e', GRP_TXT: '#3b82f6', TXT_MSG: '#f59e0b', ACK: '#6b7280', REQ: '#a855f7', RESPONSE: '#06b6d4', TRACE: '#ec4899', PATH: '#14b8a6' };
ctx.ROLE_COLORS = {};
ctx.ROLE_LABELS = {};
ctx.ROLE_STYLE = {};
ctx.ROLE_SORT = [];
ctx.formatTimestampWithTooltip = () => '';
ctx.getTimestampMode = () => 'relative';
ctx.console = console;
ctx.setTimeout = setTimeout;
ctx.clearTimeout = clearTimeout;
ctx.setInterval = setInterval;
ctx.clearInterval = clearInterval;
ctx.requestAnimationFrame = (cb) => setTimeout(cb, 0);
ctx.cancelAnimationFrame = clearTimeout;
ctx.matchMedia = () => ({ matches: false, addEventListener: () => {} });
ctx.navigator = { userAgent: '' };
ctx.performance = { now: () => Date.now() };
ctx.L = undefined;
ctx.MutationObserver = class { observe() {} disconnect() {} };
ctx.ResizeObserver = class { observe() {} disconnect() {} };
ctx.IntersectionObserver = class { observe() {} disconnect() {} };
ctx.Image = class {};
ctx.AudioContext = undefined;
ctx.HTMLElement = class {};
ctx.Event = class {};
ctx.fetch = () => Promise.resolve({ ok: true, json: () => Promise.resolve([]) });
ctx.Number = Number; ctx.String = String; ctx.Array = Array; ctx.Object = Object;
ctx.JSON = JSON; ctx.Math = Math; ctx.Date = Date; ctx.RegExp = RegExp;
ctx.Error = Error; ctx.Map = Map; ctx.Set = Set; ctx.WeakMap = WeakMap;
ctx.parseInt = parseInt; ctx.parseFloat = parseFloat;
ctx.isNaN = isNaN; ctx.isFinite = isFinite;
ctx.encodeURIComponent = encodeURIComponent;
ctx.decodeURIComponent = decodeURIComponent;
ctx.Promise = Promise; ctx.Symbol = Symbol;
ctx.queueMicrotask = queueMicrotask;
// Self-references needed for the IIFE
ctx.self = ctx;
ctx.globalThis = ctx;
vm.createContext(ctx);
vm.runInContext(src, ctx, { timeout: 3000 });
return ctx.window._panelCorner;
}
// ---- Tests ----
let passed = 0;
let failed = 0;
function test(name, fn) {
try {
fn();
passed++;
console.log(' ✓ ' + name);
} catch (e) {
failed++;
console.log(' ✗ ' + name);
console.log(' ' + e.message);
}
}
console.log('\nPanel Corner Positioning Tests (#608 M0)\n');
// --- nextAvailableCorner ---
console.log('nextAvailableCorner:');
test('returns desired corner when available', () => {
const ctx = createContext();
const pc = loadLiveModule(ctx);
const positions = { liveFeed: 'bl', liveLegend: 'br', liveNodeDetail: 'tr' };
assert.strictEqual(pc.nextAvailableCorner('liveFeed', 'tl', positions), 'tl');
});
test('skips occupied corner', () => {
const ctx = createContext();
const pc = loadLiveModule(ctx);
const positions = { liveFeed: 'bl', liveLegend: 'br', liveNodeDetail: 'tr' };
// liveFeed wants 'tr' but liveNodeDetail is there → should get 'br'? No, liveLegend is at br → skip to bl? No liveFeed is at bl → skip to tl
assert.strictEqual(pc.nextAvailableCorner('liveFeed', 'tr', positions), 'bl');
// Wait — liveFeed IS liveFeed, so bl is not occupied by "another" panel
// Actually liveFeed wants tr → tr occupied by nodeDetail → try br → occupied by legend → try bl → that's liveFeed itself (excluded from "occupied") → bl is free
});
test('skips multiple occupied corners', () => {
const ctx = createContext();
const pc = loadLiveModule(ctx);
const positions = { liveFeed: 'tl', liveLegend: 'tr', liveNodeDetail: 'br' };
// liveFeed wants 'tr' → occupied by legend → try 'br' → occupied by nodeDetail → try 'bl' → free
assert.strictEqual(pc.nextAvailableCorner('liveFeed', 'tr', positions), 'bl');
});
test('returns desired when only self occupies it', () => {
const ctx = createContext();
const pc = loadLiveModule(ctx);
const positions = { liveFeed: 'bl', liveLegend: 'br', liveNodeDetail: 'tr' };
// liveFeed wants bl — it's "occupied" by liveFeed itself, which is excluded
assert.strictEqual(pc.nextAvailableCorner('liveFeed', 'bl', positions), 'bl');
});
// --- getPanelPositions ---
console.log('\ngetPanelPositions:');
test('returns defaults when nothing in localStorage', () => {
const ctx = createContext();
const pc = loadLiveModule(ctx);
const pos = pc.getPanelPositions();
assert.strictEqual(pos.liveFeed, 'bl');
assert.strictEqual(pos.liveLegend, 'br');
assert.strictEqual(pos.liveNodeDetail, 'tr');
});
test('returns saved positions from localStorage', () => {
const ctx = createContext();
ctx.localStorage.setItem('panel-corner-liveFeed', 'tl');
ctx.localStorage.setItem('panel-corner-liveLegend', 'bl');
const pc = loadLiveModule(ctx);
const pos = pc.getPanelPositions();
assert.strictEqual(pos.liveFeed, 'tl');
assert.strictEqual(pos.liveLegend, 'bl');
assert.strictEqual(pos.liveNodeDetail, 'tr'); // still default
});
// --- applyPanelPosition ---
console.log('\napplyPanelPosition:');
test('sets data-position attribute on element', () => {
const ctx = createContext();
const el = ctx._addElement('liveFeed');
el._addBtn('liveFeed');
const pc = loadLiveModule(ctx);
pc.applyPanelPosition('liveFeed', 'tr');
assert.strictEqual(el._attrs['data-position'], 'tr');
});
test('updates button text and aria-label', () => {
const ctx = createContext();
const el = ctx._addElement('liveFeed');
const btn = el._addBtn('liveFeed');
const pc = loadLiveModule(ctx);
pc.applyPanelPosition('liveFeed', 'tr');
assert.strictEqual(btn.textContent, '↙');
assert.ok(btn._attrs['aria-label'].includes('top-right'));
});
test('handles missing element gracefully', () => {
const ctx = createContext();
const pc = loadLiveModule(ctx);
// Should not throw
pc.applyPanelPosition('nonexistent', 'tl');
});
// --- onCornerClick ---
console.log('\nonCornerClick:');
test('cycles from default bl to tl for feed', () => {
const ctx = createContext();
const el = ctx._addElement('liveFeed');
el._addBtn('liveFeed');
ctx._addElement('liveLegend');
ctx._addElement('liveNodeDetail');
ctx._addElement('panelPositionAnnounce');
ctx._elements.panelPositionAnnounce.textContent = '';
const pc = loadLiveModule(ctx);
// Feed defaults to bl, cycle: bl → tl (next in cycle after bl is tl)
pc.onCornerClick('liveFeed');
assert.strictEqual(ctx._storage['panel-corner-liveFeed'], 'tl');
assert.strictEqual(el._attrs['data-position'], 'tl');
});
test('collision avoidance: skips occupied corner', () => {
const ctx = createContext();
ctx._addElement('liveFeed');
const legendEl = ctx._addElement('liveLegend');
legendEl._addBtn('liveLegend');
ctx._addElement('liveNodeDetail');
ctx._addElement('panelPositionAnnounce');
ctx._elements.panelPositionAnnounce.textContent = '';
const pc = loadLiveModule(ctx);
// Legend defaults to br. Click → next is bl. But bl is occupied by feed → skip to tl
pc.onCornerClick('liveLegend');
assert.strictEqual(ctx._storage['panel-corner-liveLegend'], 'tl');
});
// --- resetPanelPositions ---
console.log('\nresetPanelPositions:');
test('clears localStorage and restores defaults', () => {
const ctx = createContext();
ctx.localStorage.setItem('panel-corner-liveFeed', 'tr');
ctx.localStorage.setItem('panel-corner-liveLegend', 'tl');
const feedEl = ctx._addElement('liveFeed');
feedEl._addBtn('liveFeed');
const legendEl = ctx._addElement('liveLegend');
legendEl._addBtn('liveLegend');
const detailEl = ctx._addElement('liveNodeDetail');
detailEl._addBtn('liveNodeDetail');
const pc = loadLiveModule(ctx);
pc.resetPanelPositions();
assert.strictEqual(ctx._storage['panel-corner-liveFeed'], undefined);
assert.strictEqual(feedEl._attrs['data-position'], 'bl');
assert.strictEqual(legendEl._attrs['data-position'], 'br');
assert.strictEqual(detailEl._attrs['data-position'], 'tr');
});
// --- Corner cycle order ---
console.log('\nCorner cycle order:');
test('full cycle: tl → tr → br → bl → tl', () => {
const ctx = createContext();
const pc = loadLiveModule(ctx);
const cycle = pc.CORNER_CYCLE;
assert.strictEqual(cycle.join(','), 'tl,tr,br,bl');
});
test('defaults match expected panel positions', () => {
const ctx = createContext();
const pc = loadLiveModule(ctx);
assert.strictEqual(pc.PANEL_DEFAULTS.liveFeed, 'bl');
assert.strictEqual(pc.PANEL_DEFAULTS.liveLegend, 'br');
assert.strictEqual(pc.PANEL_DEFAULTS.liveNodeDetail, 'tr');
});
// Summary
console.log('\n' + (passed + failed) + ' tests, ' + passed + ' passed, ' + failed + ' failed\n');
process.exit(failed > 0 ? 1 : 0);