- 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>
426 lines
16 KiB
JavaScript
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);
|
|
});
|
|
});
|
|
});
|