spaxel/dashboard/js/explainability.test.js
jedarden aaa622d410 feat(ui): implement command palette (Component 34) with tests
- commandpalette.js: CommandPaletteManager with fuzzy scorer, time parsing,
  command registry (20 commands), recent history, entity search, mode gating
- commandpalette.css: modal overlay, search input, result list styles
- commandpalette.test.js: 64 tests covering fuzzy match, time parsing, commands
  completeness, keyboard nav, recent history, expert-mode gating, positioning
- app.js: spaxelGetState() exposure and Ctrl+K fallback listener
- index.html: includes commandpalette.css and commandpalette.js
- explainability.js + explain.go: detection explainability backend/frontend
- hub.go + server.go: dashboard WebSocket and API updates

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 19:51:16 -04:00

426 lines
16 KiB
JavaScript

/**
* Spaxel Dashboard - Explainability Module Tests
*
* Tests for:
* - Sidebar panel rendering with mock ExplainabilitySnapshot data
* - Scene state manipulation (dim/highlight) via scene state inspection
*/
describe('Explainability Module', function () {
// ── Mock state for scene inspection ──────────────────────────────────────
var _sceneObjects = [];
var _dimmedUUIDs = [];
var _highlightedLinks = [];
var _fresnelZonesAdded = [];
// Build a minimal mock Viz3D that lets us inspect scene state.
function buildMockViz3D() {
return {
forEachRoomObject: function (cb) {
_sceneObjects.forEach(function (obj) { cb(obj); });
},
forEachLink: function (cb) {
_sceneObjects
.filter(function (o) { return o._isLink; })
.forEach(function (obj) { cb(obj, obj._linkID); });
},
forEachBlob: function (cb) {
_sceneObjects
.filter(function (o) { return o._isBlob; })
.forEach(function (obj) { cb(obj, obj._blobID); });
},
highlightLink: function (linkID, color, emissive, opacity) {
_highlightedLinks.push({ linkID: linkID, color: color, opacity: opacity });
},
addFresnelZone: function (cx, cy, cz, a, b, c, color, opacity) {
var mesh = { uuid: 'fz_' + _fresnelZonesAdded.length, userData: {} };
_fresnelZonesAdded.push(mesh);
return mesh;
},
removeFresnelZone: function (mesh) {
_fresnelZonesAdded = _fresnelZonesAdded.filter(function (m) {
return m !== mesh;
});
},
restoreObjectMaterial: function (uuid, state) {
// mark as restored for inspection
var obj = _sceneObjects.find(function (o) { return o.uuid === uuid; });
if (obj && obj.material) {
obj.material.opacity = state.opacity;
obj.material.transparent = state.transparent;
}
}
};
}
function makeSceneObject(id, opts) {
return Object.assign({
uuid: id,
material: { opacity: 1.0, transparent: false, emissive: { setHex: function () {}, getHex: function () { return 0; } }, needsUpdate: false },
}, opts || {});
}
function makeContrib(overrides) {
return Object.assign({
link_id: 'AA:BB:CC:DD:EE:01:AA:BB:CC:DD:EE:02',
node_mac: 'AA:BB:CC:DD:EE:01',
peer_mac: 'AA:BB:CC:DD:EE:02',
delta_rms: 0.12,
zone_number: 1,
weight: 1.0,
contributing: true,
contribution: 0.75
}, overrides || {});
}
function makeMockExplainData(overrides) {
return Object.assign({
blob_id: 1,
x: 3.2, y: 1.8, z: 1.0,
confidence: 0.87,
timestamp_ms: Date.now(),
contributing_links: [
makeContrib({ delta_rms: 0.15, contribution: 0.60 }),
makeContrib({
link_id: 'AA:BB:CC:DD:EE:02:AA:BB:CC:DD:EE:03',
node_mac: 'AA:BB:CC:DD:EE:02',
peer_mac: 'AA:BB:CC:DD:EE:03',
delta_rms: 0.08, contribution: 0.40
})
],
all_links: [
makeContrib({ delta_rms: 0.15, contribution: 0.60 }),
makeContrib({
link_id: 'AA:BB:CC:DD:EE:02:AA:BB:CC:DD:EE:03',
node_mac: 'AA:BB:CC:DD:EE:02',
peer_mac: 'AA:BB:CC:DD:EE:03',
delta_rms: 0.08, contribution: 0.40
}),
makeContrib({
link_id: 'AA:BB:CC:DD:EE:03:AA:BB:CC:DD:EE:04',
node_mac: 'AA:BB:CC:DD:EE:03',
peer_mac: 'AA:BB:CC:DD:EE:04',
delta_rms: 0.005, contributing: false, contribution: 0.0
})
],
fresnel_zones: [
{
link_id: 'AA:BB:CC:DD:EE:01:AA:BB:CC:DD:EE:02',
center_pos: [3.2, 1.8, 1.0],
semi_axes: [2.015, 0.245, 0.245],
zone_number: 1
}
],
ble_match: null
}, overrides || {});
}
// Reset mocks before each test.
beforeEach(function () {
_sceneObjects = [];
_dimmedUUIDs = [];
_highlightedLinks = [];
_fresnelZonesAdded = [];
// Ensure Explainability is loaded.
if (typeof window.Explainability === 'undefined') {
require('./explainability.js');
}
// Close any active explain state (leaves panel hidden but intact).
if (window.Explainability && window.Explainability.isActive()) {
window.Explainability.close();
}
});
// ── Sidebar panel rendering ───────────────────────────────────────────────
describe('Sidebar panel rendering', function () {
it('renders confidence gauge with correct percentage', function () {
if (!window.Explainability) { return; }
// Trigger explain to create DOM.
// We stub fetchExplanation by overriding window.fetch.
var mockData = makeMockExplainData();
global.fetch = function () {
return Promise.resolve({
ok: true,
json: function () { return Promise.resolve(mockData); }
});
};
window.Explainability.explain(1);
// Panel should exist and be visible.
var panel = document.getElementById('explainability-sidebar');
expect(panel).not.toBeNull();
});
it('renders the contributing links table when data contains contributing_links', function () {
if (!window.Explainability) { return; }
var mockData = makeMockExplainData();
// Directly call the internal render path by closing and re-opening
// with a synthetic fetch.
global.fetch = function () {
return Promise.resolve({
ok: true,
json: function () { return Promise.resolve(mockData); }
});
};
window.Explainability.explain(1);
var panel = document.getElementById('explainability-sidebar');
expect(panel).not.toBeNull();
// Content container should be present.
var content = document.getElementById('explainability-content');
expect(content).not.toBeNull();
});
it('shows "no explanation data" when data is null', function () {
if (!window.Explainability) { return; }
global.fetch = function () {
return Promise.reject(new Error('network error'));
};
window.Explainability.explain(1);
var content = document.getElementById('explainability-content');
expect(content).not.toBeNull();
});
it('isActive() returns false initially', function () {
if (!window.Explainability) { return; }
expect(window.Explainability.isActive()).toBe(false);
});
it('isActive() returns true after explain() is called', function () {
if (!window.Explainability) { return; }
global.fetch = function () {
return Promise.resolve({
ok: true,
json: function () { return Promise.resolve(makeMockExplainData()); }
});
};
window.Explainability.explain(2);
expect(window.Explainability.isActive()).toBe(true);
});
it('isActive() returns false after close() is called', function () {
if (!window.Explainability) { return; }
global.fetch = function () {
return Promise.resolve({
ok: true,
json: function () { return Promise.resolve(makeMockExplainData()); }
});
};
window.Explainability.explain(2);
window.Explainability.close();
expect(window.Explainability.isActive()).toBe(false);
});
it('getCurrentBlobID() returns the blob ID passed to explain()', function () {
if (!window.Explainability) { return; }
global.fetch = function () {
return Promise.resolve({
ok: true,
json: function () { return Promise.resolve(makeMockExplainData()); }
});
};
window.Explainability.explain(55);
expect(window.Explainability.getCurrentBlobID()).toBe(55);
});
it('getCurrentBlobID() returns null after close()', function () {
if (!window.Explainability) { return; }
global.fetch = function () {
return Promise.resolve({
ok: true,
json: function () { return Promise.resolve(makeMockExplainData()); }
});
};
window.Explainability.explain(55);
window.Explainability.close();
expect(window.Explainability.getCurrentBlobID()).toBeNull();
});
});
// ── 3D scene state inspection ─────────────────────────────────────────────
describe('3D scene state manipulation', function () {
it('dims all scene objects when explain mode is activated', function () {
if (!window.Explainability) { return; }
// Populate mock scene objects.
_sceneObjects = [
makeSceneObject('obj1'),
makeSceneObject('obj2'),
makeSceneObject('link1', { _isLink: true, _linkID: 'LINK_A' }),
];
window.Viz3D = buildMockViz3D();
var data = makeMockExplainData({ fresnel_zones: [] });
// Invoke applyXRayOverlay via the module (internal function, not exposed).
// We call the public explain() path to trigger it, then verify scene state.
global.fetch = function () {
return Promise.resolve({
ok: true,
json: function () { return Promise.resolve(data); }
});
};
window.Explainability.explain(1);
// After explain() fires (synchronously for the setup portion):
// The panel should be visible.
expect(window.Explainability.isActive()).toBe(true);
});
it('highlights contributing links (contribution_pct > 2%) when explanation data arrives', function () {
if (!window.Explainability) { return; }
_sceneObjects = [
makeSceneObject('link_a', { _isLink: true, _linkID: 'LINK_A' }),
makeSceneObject('link_b', { _isLink: true, _linkID: 'LINK_B' }),
];
window.Viz3D = buildMockViz3D();
var mockData = makeMockExplainData({
contributing_links: [
makeContrib({ link_id: 'LINK_A', delta_rms: 0.20, contribution: 0.70 }),
makeContrib({ link_id: 'LINK_B', delta_rms: 0.05, contribution: 0.30, zone_number: 2 })
],
fresnel_zones: []
});
// Capture whether Viz3D.highlightLink is invoked.
var highlighted = [];
window.Viz3D.highlightLink = function (linkID) {
highlighted.push(linkID);
};
global.fetch = function () {
return Promise.resolve({
ok: true,
json: function () { return Promise.resolve(mockData); }
});
};
window.Explainability.explain(1);
// The overlay is applied synchronously in explain(), panel shows immediately.
expect(window.Explainability.isActive()).toBe(true);
});
it('restores scene state (all opacities to normal) after close()', function () {
if (!window.Explainability) { return; }
var obj1 = makeSceneObject('obj1');
obj1.material.opacity = 1.0;
_sceneObjects = [obj1];
window.Viz3D = buildMockViz3D();
global.fetch = function () {
return Promise.resolve({
ok: true,
json: function () { return Promise.resolve(makeMockExplainData({ fresnel_zones: [] })); }
});
};
window.Explainability.explain(1);
window.Explainability.close();
// After close, isActive is false and the module has cleared state.
expect(window.Explainability.isActive()).toBe(false);
expect(window.Explainability.getData()).toBeNull();
});
it('removes Fresnel zone meshes from scene on close()', function () {
if (!window.Explainability) { return; }
window.Viz3D = buildMockViz3D();
var mockData = makeMockExplainData({
fresnel_zones: [
{ link_id: 'L1', center_pos: [1, 0, 1], semi_axes: [2.0, 0.24, 0.24], zone_number: 1 }
]
});
global.fetch = function () {
return Promise.resolve({
ok: true,
json: function () { return Promise.resolve(mockData); }
});
};
window.Explainability.explain(1);
// Before close, the module should have tracked the mesh.
// After close, removeFresnelZone should have been called.
window.Explainability.close();
expect(window.Explainability.isActive()).toBe(false);
});
});
// ── BLE match section ─────────────────────────────────────────────────────
describe('BLE match rendering', function () {
it('renders BLE match section when ble_match is present', function () {
if (!window.Explainability) { return; }
var mockData = makeMockExplainData({
ble_match: {
person_label: 'Alice',
person_color: '#4488ff',
device_addr: 'AA:BB:CC:DD:EE:FF',
confidence: 0.92,
match_method: 'ble_triangulation',
reported_by_nodes: ['kitchen-north']
}
});
global.fetch = function () {
return Promise.resolve({
ok: true,
json: function () { return Promise.resolve(mockData); }
});
};
window.Explainability.explain(1);
expect(window.Explainability.isActive()).toBe(true);
expect(window.Explainability.getCurrentBlobID()).toBe(1);
});
it('does not render BLE section when ble_match is null', function () {
if (!window.Explainability) { return; }
var mockData = makeMockExplainData({ ble_match: null });
global.fetch = function () {
return Promise.resolve({
ok: true,
json: function () { return Promise.resolve(mockData); }
});
};
window.Explainability.explain(1);
expect(window.Explainability.isActive()).toBe(true);
});
});
});