spaxel/dashboard/js/troubleshoot.test.js
jedarden 570e5eec41 feat(dashboard): guided troubleshooting and first-time UX
Add troubleshooting infrastructure for non-technical users:

- TroubleshootManager: node offline cards with stepped recovery
  guidance, factory reset modal, and detection quality banners
- TooltipManager: first-time feature tooltips with localStorage
  persistence, auto-dismiss, and sequential tour
- Onboarding failure guidance: human-readable error messages for
  browser compatibility, USB connection, WiFi provisioning, and
  node detection failures
- Post-calibration reinforcement card with summary and next steps
- Client-side link health check with auto-recovery
- CSS for offline cards, tooltips, quality banners, and modals
- Script tags wired in index.html for troubleshoot.js and tooltips.js
- 30 tests covering all troubleshooting and tooltip functionality

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-28 04:19:06 -04:00

479 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Tests for troubleshoot.js and tooltips.js
*
* Covers:
* - Node offline card rendering and dismissal
* - Node online event dismissing offline card
* - Low packet rate quality banner
* - Tooltip localStorage persistence
* - Tooltip auto-dismiss and sequential tour
* - Issue state machine
*/
// Set up minimal DOM before IIFEs execute
document.body.innerHTML =
'<div id="node-panel">' +
'<div id="node-list"></div>' +
'<div class="link-section"></div>' +
'</div>' +
'<div id="chart-panel"></div>' +
'<div id="scene-container"></div>' +
'<div id="presence-indicator" class="clear">CLEAR</div>';
// Load modules (IIFEs execute immediately, setting window.SpaxelTroubleshoot / window.SpaxelTooltips)
require('./troubleshoot.js');
require('./tooltips.js');
var TS = window.SpaxelTroubleshoot;
var TT = window.SpaxelTooltips;
function setupDOM() {
document.body.innerHTML =
'<div id="node-panel">' +
'<div id="node-list"></div>' +
'<div class="link-section"></div>' +
'</div>' +
'<div id="chart-panel"></div>' +
'<div id="scene-container"></div>' +
'<div id="presence-indicator" class="clear">CLEAR</div>';
}
beforeEach(function () {
// Clear any real timers from IIFE auto-init
if (TS._state.checkTimer) {
clearInterval(TS._state.checkTimer);
TS._state.checkTimer = null;
}
if (TT._state.dismissTimer) {
clearTimeout(TT._state.dismissTimer);
TT._state.dismissTimer = null;
}
if (TT._state.sequenceTimer) {
clearTimeout(TT._state.sequenceTimer);
TT._state.sequenceTimer = null;
}
TT._state.activeTooltip = null;
TT._state.sequenceIndex = 0;
jest.useFakeTimers();
localStorage.clear();
setupDOM();
// Reset troubleshoot issues
TS._state.issues = {};
// Re-init troubleshoot with fresh DOM (uses fake setInterval)
TS.init();
});
afterEach(function () {
jest.useRealTimers();
});
// ============================================
// Troubleshoot Tests
// ============================================
describe('SpaxelTroubleshoot', function () {
describe('node offline card', function () {
test('renders offline card with correct node label when node_disconnected fires', function () {
TS.handleEvent('node_disconnected', { mac: 'AA:BB:CC:DD:EE:FF' });
var section = document.getElementById('troubleshoot-section');
expect(section).toBeTruthy();
var card = section.querySelector('.troubleshoot-offline-card');
expect(card).toBeTruthy();
expect(card.textContent).toContain('AA:BB:CC:DD:EE:FF');
expect(card.textContent).toContain('went offline');
});
test('offline card includes actionable troubleshooting steps', function () {
TS.handleEvent('node_disconnected', { mac: 'AA:BB:CC:DD:EE:FF' });
var card = document.querySelector('.troubleshoot-offline-card');
expect(card.textContent).toContain('power LED');
expect(card.textContent).toContain('WiFi router');
});
test('offline card shows captive portal AP SSID with last 4 MAC chars', function () {
TS.handleEvent('node_disconnected', { mac: 'AA:BB:CC:DD:EE:FF' });
var card = document.querySelector('.troubleshoot-offline-card');
// mac.slice(-5) = 'EE:FF', .replace(':','') = 'EEFF'
expect(card.innerHTML).toContain('spaxel-EEFF');
});
test('offline card can be dismissed via X button', function () {
TS.handleEvent('node_disconnected', { mac: 'AA:BB:CC:DD:EE:FF' });
var card = document.querySelector('.troubleshoot-offline-card');
expect(card).toBeTruthy();
card.querySelector('.troubleshoot-dismiss').click();
expect(document.querySelector('.troubleshoot-offline-card')).toBeNull();
expect(TS._state.issues['offline_AA:BB:CC:DD:EE:FF']).toBeUndefined();
});
test('node_online event dismisses the offline card', function () {
TS.handleEvent('node_disconnected', { mac: 'AA:BB:CC:DD:EE:FF' });
expect(document.querySelector('.troubleshoot-offline-card')).toBeTruthy();
TS.handleEvent('node_connected', { mac: 'AA:BB:CC:DD:EE:FF' });
expect(document.querySelector('.troubleshoot-offline-card')).toBeNull();
expect(TS._state.issues['offline_AA:BB:CC:DD:EE:FF']).toBeUndefined();
});
test('does not create duplicate offline cards for the same node', function () {
TS.handleEvent('node_disconnected', { mac: 'AA:BB:CC:DD:EE:FF' });
TS.handleEvent('node_disconnected', { mac: 'AA:BB:CC:DD:EE:FF' });
var cards = document.querySelectorAll('.troubleshoot-offline-card');
expect(cards.length).toBe(1);
});
test('"More options" expander reveals additional steps', function () {
TS.handleEvent('node_disconnected', { mac: 'AA:BB:CC:DD:EE:FF' });
var card = document.querySelector('.troubleshoot-offline-card');
var more = card.querySelector('.troubleshoot-more');
var steps = more.querySelectorAll('.troubleshoot-step');
expect(steps.length).toBe(3);
expect(more.textContent).toContain('factory defaults');
});
});
describe('factory reset modal', function () {
test('shows modal with reset instructions when button clicked', function () {
TS.handleEvent('node_disconnected', { mac: 'AA:BB:CC:DD:EE:FF' });
document.querySelector('.troubleshoot-reset-btn').click();
var modal = document.querySelector('.troubleshoot-modal-overlay');
expect(modal).toBeTruthy();
expect(modal.textContent).toContain('BOOT');
expect(modal.textContent).toContain('Spaxel-Setup');
});
test('modal can be closed with Got it button', function () {
TS.handleEvent('node_disconnected', { mac: 'AA:BB:CC:DD:EE:FF' });
document.querySelector('.troubleshoot-reset-btn').click();
expect(document.querySelector('.troubleshoot-modal-overlay')).toBeTruthy();
document.querySelector('.troubleshoot-modal-close').click();
expect(document.querySelector('.troubleshoot-modal-overlay')).toBeNull();
});
test('modal can be closed by clicking overlay background', function () {
TS.handleEvent('node_disconnected', { mac: 'AA:BB:CC:DD:EE:FF' });
document.querySelector('.troubleshoot-reset-btn').click();
document.querySelector('.troubleshoot-modal-overlay').click();
expect(document.querySelector('.troubleshoot-modal-overlay')).toBeNull();
});
});
describe('detection quality banner', function () {
test('renders quality banner when low_packet_rate event fires', function () {
TS.handleEvent('low_packet_rate', { link_id: 'AA:BB:CC:DD:EE:FF:11:22:33:44:55:66' });
var banner = document.querySelector('.troubleshoot-quality-banner');
expect(banner).toBeTruthy();
expect(banner.textContent).toContain('having trouble communicating');
});
test('quality banner can be dismissed', function () {
TS.handleEvent('low_packet_rate', { link_id: 'test-link' });
var banner = document.querySelector('.troubleshoot-quality-banner');
banner.querySelector('.troubleshoot-dismiss').click();
expect(document.querySelector('.troubleshoot-quality-banner')).toBeNull();
});
test('does not create duplicate quality banners for the same link', function () {
TS.handleEvent('low_packet_rate', { link_id: 'test-link' });
TS.handleEvent('low_packet_rate', { link_id: 'test-link' });
var banners = document.querySelectorAll('.troubleshoot-quality-banner');
expect(banners.length).toBe(1);
});
test('node_online clears quality issues for links involving that node', function () {
TS.handleEvent('low_packet_rate', {
link_id: 'AA:BB:CC:DD:EE:FF:11:22:33:44:55:66',
node_mac: 'AA:BB:CC:DD:EE:FF',
peer_mac: '11:22:33:44:55:66',
});
expect(document.querySelector('.troubleshoot-quality-banner')).toBeTruthy();
TS.handleEvent('node_connected', { mac: 'AA:BB:CC:DD:EE:FF' });
expect(document.querySelector('.troubleshoot-quality-banner')).toBeNull();
});
});
describe('client-side link health check', function () {
test('flags quality issue when no frames received for 60+ seconds', function () {
window.SpaxelApp = {
getLinks: function () {
var links = new Map();
links.set('test-link', {
nodeMAC: 'AA:BB:CC:DD:EE:FF',
peerMAC: '11:22:33:44:55:66',
lastFrame: Date.now() - 65000,
});
return links;
},
};
jest.advanceTimersByTime(5000);
var banner = document.querySelector('.troubleshoot-quality-banner');
expect(banner).toBeTruthy();
delete window.SpaxelApp;
});
test('auto-resolves quality issue when frames resume', function () {
window.SpaxelApp = {
getLinks: function () {
var links = new Map();
links.set('test-link', {
nodeMAC: 'AA:BB:CC:DD:EE:FF',
peerMAC: '11:22:33:44:55:66',
lastFrame: Date.now() - 65000,
});
return links;
},
};
jest.advanceTimersByTime(5000);
expect(document.querySelector('.troubleshoot-quality-banner')).toBeTruthy();
// Frames resume
window.SpaxelApp = {
getLinks: function () {
var links = new Map();
links.set('test-link', {
nodeMAC: 'AA:BB:CC:DD:EE:FF',
peerMAC: '11:22:33:44:55:66',
lastFrame: Date.now(),
});
return links;
},
};
jest.advanceTimersByTime(5000);
expect(document.querySelector('.troubleshoot-quality-banner')).toBeNull();
delete window.SpaxelApp;
});
});
describe('calibration_complete handler', function () {
test('does not throw on calibration_complete event', function () {
expect(function () {
TS.handleEvent('calibration_complete', {
node_mac: 'AA:BB:CC:DD:EE:FF',
link_count: 2,
});
}).not.toThrow();
});
});
describe('issue state machine', function () {
test('issue transitions to NOTIFIED when created', function () {
TS.handleEvent('node_disconnected', { mac: 'AA:BB:CC:DD:EE:FF' });
var issue = TS._state.issues['offline_AA:BB:CC:DD:EE:FF'];
expect(issue).toBeTruthy();
expect(issue.state).toBe(TS._STATES.NOTIFIED);
});
test('issue is removed from state when resolved', function () {
TS.handleEvent('node_disconnected', { mac: 'AA:BB:CC:DD:EE:FF' });
expect(TS._state.issues['offline_AA:BB:CC:DD:EE:FF']).toBeTruthy();
document.querySelector('.troubleshoot-offline-card .troubleshoot-dismiss').click();
expect(TS._state.issues['offline_AA:BB:CC:DD:EE:FF']).toBeUndefined();
});
});
});
// ============================================
// Tooltip Tests
// ============================================
describe('SpaxelTooltips', function () {
describe('localStorage persistence', function () {
test('show() returns false if tooltip was previously shown', function () {
localStorage.setItem('spaxel_tooltip_csi-chart_shown', 'true');
var result = TT.show('csi-chart', '#chart-panel', 'Test tooltip', 'left');
expect(result).toBe(false);
});
test('show() returns true and renders tooltip on first display', function () {
var result = TT.show('csi-chart', '#chart-panel', 'Test tooltip text', 'left');
expect(result).toBe(true);
var tooltip = document.getElementById('spaxel-tooltip-csi-chart');
expect(tooltip).toBeTruthy();
expect(tooltip.textContent).toContain('Test tooltip text');
});
test('tooltip sets localStorage flag on auto-dismiss after 8 seconds', function () {
TT.show('csi-chart', '#chart-panel', 'Test', 'left');
expect(localStorage.getItem('spaxel_tooltip_csi-chart_shown')).toBeNull();
jest.advanceTimersByTime(8000);
expect(localStorage.getItem('spaxel_tooltip_csi-chart_shown')).toBe('true');
expect(document.getElementById('spaxel-tooltip-csi-chart')).toBeNull();
});
test('tooltip does not re-appear after localStorage flag is set', function () {
TT.show('csi-chart', '#chart-panel', 'First show', 'left');
jest.advanceTimersByTime(8000);
var result = TT.show('csi-chart', '#chart-panel', 'Second show', 'left');
expect(result).toBe(false);
expect(document.getElementById('spaxel-tooltip-csi-chart')).toBeNull();
});
});
describe('dismiss and dismissAll', function () {
test('dismiss() removes the active tooltip from DOM', function () {
TT.show('csi-chart', '#chart-panel', 'Test', 'left');
expect(document.getElementById('spaxel-tooltip-csi-chart')).toBeTruthy();
TT.dismiss();
expect(document.getElementById('spaxel-tooltip-csi-chart')).toBeNull();
});
test('dismissAll() sets spaxel_tooltips_shown flag', function () {
expect(localStorage.getItem('spaxel_tooltips_shown')).toBeNull();
TT.dismissAll();
expect(localStorage.getItem('spaxel_tooltips_shown')).toBe('true');
});
test('dismissAll() removes dismiss-all button', function () {
TT.showSequence();
expect(document.getElementById('spaxel-dismiss-all-tooltips')).toBeTruthy();
document.getElementById('spaxel-dismiss-all-tooltips').click();
expect(document.getElementById('spaxel-dismiss-all-tooltips')).toBeNull();
});
});
describe('sequential tour (showSequence)', function () {
test('showSequence() does nothing if spaxel_tooltips_shown is already set', function () {
localStorage.setItem('spaxel_tooltips_shown', 'true');
TT.showSequence();
expect(document.querySelector('.spaxel-tooltip')).toBeNull();
});
test('showSequence() shows first tooltip and dismiss-all button', function () {
TT.showSequence();
var tooltip = document.querySelector('.spaxel-tooltip');
expect(tooltip).toBeTruthy();
var dismissAllBtn = document.getElementById('spaxel-dismiss-all-tooltips');
expect(dismissAllBtn).toBeTruthy();
expect(dismissAllBtn.textContent).toBe('Dismiss all tips');
});
test('showSequence() advances through tooltips after auto-dismiss + gap', function () {
TT.showSequence();
var firstId = document.querySelector('.spaxel-tooltip').id;
// Advance past auto-dismiss (8s) + gap (2s) = 10s
jest.advanceTimersByTime(10000);
var secondTooltip = document.querySelector('.spaxel-tooltip');
expect(secondTooltip).toBeTruthy();
expect(secondTooltip.id).not.toBe(firstId);
});
test('dismiss-all button stops the tour and sets localStorage', function () {
TT.showSequence();
expect(document.querySelector('.spaxel-tooltip')).toBeTruthy();
document.getElementById('spaxel-dismiss-all-tooltips').click();
expect(document.querySelector('.spaxel-tooltip')).toBeNull();
expect(localStorage.getItem('spaxel_tooltips_shown')).toBe('true');
});
test('marks all tooltips shown after completing the full sequence', function () {
// 4 tooltips × (8s dismiss + 2s gap) = 32s total
// Last scheduleNext fires at 30s and immediately calls markAllShown
TT.showSequence();
jest.advanceTimersByTime(31000);
expect(localStorage.getItem('spaxel_tooltips_shown')).toBe('true');
expect(document.getElementById('spaxel-dismiss-all-tooltips')).toBeNull();
});
});
describe('tooltip manifest', function () {
test('manifest has 4 tooltips with required properties', function () {
var manifest = TT._TOOLTIP_MANIFEST;
expect(manifest.length).toBe(4);
manifest.forEach(function (t) {
expect(t.id).toBeTruthy();
expect(t.target).toBeTruthy();
expect(t.text).toBeTruthy();
expect(t.direction).toBeTruthy();
});
});
test('manifest covers CSI chart, 3D view, presence indicator, and link list', function () {
var ids = TT._TOOLTIP_MANIFEST.map(function (t) { return t.id; });
expect(ids).toContain('csi-chart');
expect(ids).toContain('3d-view');
expect(ids).toContain('presence-indicator');
expect(ids).toContain('link-list');
});
});
describe('tooltip positioning', function () {
test('tooltip is positioned with fixed positioning via CSS class', function () {
TT.show('csi-chart', '#chart-panel', 'Test', 'left');
var tooltip = document.querySelector('.spaxel-tooltip');
expect(tooltip.className).toContain('spaxel-tooltip');
expect(tooltip.style.top).toBeTruthy();
expect(tooltip.style.left).toBeTruthy();
});
test('tooltip has arrow element matching direction', function () {
TT.show('csi-chart', '#chart-panel', 'Test', 'left');
var tooltip = document.querySelector('.spaxel-tooltip');
expect(tooltip.querySelector('.spaxel-tooltip-arrow-left')).toBeTruthy();
});
test('show() returns false if target element does not exist', function () {
var result = TT.show('nonexistent', '#nonexistent-element', 'Test', 'top');
expect(result).toBe(false);
});
});
});