/** * 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); }); }); });