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>
This commit is contained in:
parent
1b0e7ea2b4
commit
570e5eec41
7 changed files with 1498 additions and 6 deletions
345
dashboard/css/troubleshoot.css
Normal file
345
dashboard/css/troubleshoot.css
Normal file
|
|
@ -0,0 +1,345 @@
|
|||
/* ============================================
|
||||
Troubleshooting UI Styles
|
||||
============================================ */
|
||||
|
||||
/* Troubleshoot section inside node panel */
|
||||
#troubleshoot-section {
|
||||
margin-top: 12px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
/* Offline troubleshooting card */
|
||||
.troubleshoot-card {
|
||||
background: rgba(255, 167, 38, 0.08);
|
||||
border: 1px solid rgba(255, 167, 38, 0.25);
|
||||
border-radius: 6px;
|
||||
padding: 10px;
|
||||
margin-bottom: 8px;
|
||||
font-size: 12px;
|
||||
animation: troubleshoot-fade-in 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes troubleshoot-fade-in {
|
||||
from { opacity: 0; transform: translateY(-4px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.troubleshoot-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 8px;
|
||||
color: #ffa726;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.troubleshoot-card-icon {
|
||||
font-size: 14px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.troubleshoot-dismiss {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #888;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
padding: 0 2px;
|
||||
line-height: 1;
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.troubleshoot-dismiss:hover {
|
||||
color: #eee;
|
||||
}
|
||||
|
||||
/* Timeline steps */
|
||||
.troubleshoot-timeline {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.troubleshoot-step {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.troubleshoot-step-num {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 167, 38, 0.2);
|
||||
color: #ffa726;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.troubleshoot-step-text {
|
||||
color: #bbb;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.troubleshoot-step-text strong {
|
||||
color: #eee;
|
||||
}
|
||||
|
||||
/* More options expander */
|
||||
.troubleshoot-more {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.troubleshoot-more summary {
|
||||
color: #888;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.troubleshoot-more summary:hover {
|
||||
color: #bbb;
|
||||
}
|
||||
|
||||
.troubleshoot-more[open] .troubleshoot-step {
|
||||
animation: troubleshoot-fade-in 0.2s ease-out;
|
||||
}
|
||||
|
||||
/* Reset button inside card */
|
||||
.troubleshoot-reset-btn {
|
||||
background: rgba(244, 67, 54, 0.15);
|
||||
border: 1px solid rgba(244, 67, 54, 0.3);
|
||||
color: #ef5350;
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.troubleshoot-reset-btn:hover {
|
||||
background: rgba(244, 67, 54, 0.25);
|
||||
}
|
||||
|
||||
/* Quality banner (fixed at top, below status bar) */
|
||||
.troubleshoot-quality-banner {
|
||||
position: fixed;
|
||||
top: 44px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: rgba(255, 167, 38, 0.15);
|
||||
border: 1px solid rgba(255, 167, 38, 0.3);
|
||||
border-radius: 6px;
|
||||
padding: 8px 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
color: #ffa726;
|
||||
z-index: 150;
|
||||
max-width: 600px;
|
||||
animation: troubleshoot-slide-down 0.3s ease-out;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@keyframes troubleshoot-slide-down {
|
||||
from { opacity: 0; transform: translateX(-50%) translateY(-10px); }
|
||||
to { opacity: 1; transform: translateX(-50%) translateY(0); }
|
||||
}
|
||||
|
||||
.troubleshoot-quality-icon {
|
||||
font-size: 14px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.troubleshoot-quality-banner .troubleshoot-dismiss {
|
||||
color: #ffa726;
|
||||
}
|
||||
|
||||
.troubleshoot-quality-banner .troubleshoot-dismiss:hover {
|
||||
color: #eee;
|
||||
}
|
||||
|
||||
/* Factory reset modal */
|
||||
.troubleshoot-modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 2000;
|
||||
}
|
||||
|
||||
.troubleshoot-modal {
|
||||
background: #1e1e3a;
|
||||
border-radius: 12px;
|
||||
padding: 24px 28px;
|
||||
max-width: 480px;
|
||||
width: 90%;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.troubleshoot-modal h3 {
|
||||
font-size: 16px;
|
||||
color: #eee;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.troubleshoot-list {
|
||||
color: #bbb;
|
||||
font-size: 13px;
|
||||
line-height: 1.8;
|
||||
padding-left: 20px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.troubleshoot-list li {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.troubleshoot-modal-close {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Tooltip Styles
|
||||
============================================ */
|
||||
|
||||
.spaxel-tooltip {
|
||||
position: fixed;
|
||||
z-index: 2000;
|
||||
background: rgba(30, 30, 58, 0.95);
|
||||
border: 1px solid rgba(79, 195, 247, 0.4);
|
||||
border-radius: 6px;
|
||||
padding: 8px 12px;
|
||||
max-width: 260px;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
|
||||
animation: spaxel-tooltip-appear 0.3s ease-out;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@keyframes spaxel-tooltip-appear {
|
||||
from { opacity: 0; transform: scale(0.95); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
|
||||
.spaxel-tooltip-text {
|
||||
color: #eee;
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* Arrows */
|
||||
.spaxel-tooltip-arrow {
|
||||
position: absolute;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.spaxel-tooltip-arrow-top {
|
||||
bottom: -6px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
border-left: 6px solid transparent;
|
||||
border-right: 6px solid transparent;
|
||||
border-top: 6px solid rgba(79, 195, 247, 0.4);
|
||||
}
|
||||
|
||||
.spaxel-tooltip-arrow-bottom {
|
||||
top: -6px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
border-left: 6px solid transparent;
|
||||
border-right: 6px solid transparent;
|
||||
border-bottom: 6px solid rgba(79, 195, 247, 0.4);
|
||||
}
|
||||
|
||||
.spaxel-tooltip-arrow-left {
|
||||
right: -6px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
border-top: 6px solid transparent;
|
||||
border-bottom: 6px solid transparent;
|
||||
border-left: 6px solid rgba(79, 195, 247, 0.4);
|
||||
}
|
||||
|
||||
.spaxel-tooltip-arrow-right {
|
||||
left: -6px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
border-top: 6px solid transparent;
|
||||
border-bottom: 6px solid transparent;
|
||||
border-right: 6px solid rgba(79, 195, 247, 0.4);
|
||||
}
|
||||
|
||||
/* Dismiss all button */
|
||||
.spaxel-dismiss-all {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
color: #aaa;
|
||||
font-size: 12px;
|
||||
padding: 6px 16px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
z-index: 2001;
|
||||
transition: background 0.2s, color 0.2s;
|
||||
}
|
||||
|
||||
.spaxel-dismiss-all:hover {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
color: #eee;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Post-Calibration Card (inside wizard)
|
||||
============================================ */
|
||||
|
||||
.post-cal-card {
|
||||
text-align: center;
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
.post-cal-card .wizard-icon-large {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.post-cal-card h3 {
|
||||
font-size: 18px;
|
||||
color: #eee;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.post-cal-summary {
|
||||
color: #66bb6a !important;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.post-cal-expect {
|
||||
color: #bbb;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.post-cal-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@
|
|||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Spaxel Dashboard</title>
|
||||
<link rel="stylesheet" href="css/troubleshoot.css">
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
|
|
@ -844,6 +845,10 @@
|
|||
|
||||
<!-- 3-D spatial visualisation layer -->
|
||||
<script src="js/viz3d.js"></script>
|
||||
<!-- Troubleshooting manager (must load before app.js) -->
|
||||
<script src="js/troubleshoot.js"></script>
|
||||
<!-- First-time feature tooltips (must load before app.js) -->
|
||||
<script src="js/tooltips.js"></script>
|
||||
<!-- Main application -->
|
||||
<script src="js/app.js"></script>
|
||||
<!-- esp-web-tools for firmware flashing (Web Serial) -->
|
||||
|
|
|
|||
|
|
@ -263,11 +263,21 @@
|
|||
lastSeen: Date.now()
|
||||
});
|
||||
updateNodeList();
|
||||
if (window.SpaxelTroubleshoot) {
|
||||
window.SpaxelTroubleshoot.handleEvent('node_connected', msg);
|
||||
}
|
||||
// Show first-time tooltips on first node connection
|
||||
if (window.SpaxelTooltips && state.nodes.size === 1) {
|
||||
setTimeout(function () { window.SpaxelTooltips.showSequence(); }, 2000);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'node_disconnected':
|
||||
state.nodes.delete(msg.mac);
|
||||
updateNodeList();
|
||||
if (window.SpaxelTroubleshoot) {
|
||||
window.SpaxelTroubleshoot.handleEvent('node_disconnected', msg);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'link_active':
|
||||
|
|
@ -922,4 +932,11 @@
|
|||
} else {
|
||||
init();
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Public API
|
||||
// ============================================
|
||||
window.SpaxelApp = {
|
||||
getLinks: function () { return state.links; },
|
||||
};
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@
|
|||
calibratePhase: 'idle',
|
||||
ws: null,
|
||||
csiHistory: [],
|
||||
calibrationLinks: [], // unique link IDs seen during calibration
|
||||
container: null,
|
||||
};
|
||||
|
||||
|
|
@ -101,8 +102,20 @@
|
|||
} catch (e) {
|
||||
if (e.name === 'NotFoundError') {
|
||||
throw new UserError(
|
||||
'No device detected. Make sure the USB cable is connected ' +
|
||||
'and hold the BOOT button while plugging in.'
|
||||
'No device detected. Did you hold the BOOT button while plugging in? ' +
|
||||
'Try again: hold BOOT, then plug in the USB cable.'
|
||||
);
|
||||
}
|
||||
if (e.name === 'NotAllowedError') {
|
||||
throw new UserError(
|
||||
'Browser blocked USB access. Check your browser\'s site permissions ' +
|
||||
'for this address and try again.'
|
||||
);
|
||||
}
|
||||
if (e.name === 'NetworkError') {
|
||||
throw new UserError(
|
||||
'Another application is using this USB port. Close Arduino IDE, esptool, ' +
|
||||
'or any other serial monitor and try again.'
|
||||
);
|
||||
}
|
||||
throw new UserError(
|
||||
|
|
@ -598,6 +611,7 @@
|
|||
function renderCalibrate(contentEl) {
|
||||
state.calibratePhase = 'walk';
|
||||
state.csiHistory = [];
|
||||
state.calibrationLinks = [];
|
||||
|
||||
contentEl.innerHTML =
|
||||
'<div class="wizard-step-content">' +
|
||||
|
|
@ -638,6 +652,11 @@
|
|||
if (frame && (frame.nodeMAC === state.nodeMAC || frame.peerMAC === state.nodeMAC)) {
|
||||
pushCSISample(frame);
|
||||
drawCalibrateWaveform();
|
||||
// Track unique links for post-calibration card
|
||||
var linkKey = frame.nodeMAC + ':' + frame.peerMAC;
|
||||
if (state.calibrationLinks.indexOf(linkKey) === -1) {
|
||||
state.calibrationLinks.push(linkKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
@ -757,7 +776,8 @@
|
|||
if (state.csiHistory.length < 5) {
|
||||
statusEl.innerHTML =
|
||||
'<span class="wizard-warn">Very little CSI data received. ' +
|
||||
'The node may not be oriented correctly. Continuing anyway...</span>';
|
||||
'The node connected but is not sensing yet. Check the antenna ' +
|
||||
'orientation \u2014 the PCB antenna should face away from walls.</span>';
|
||||
}
|
||||
startCalibratePhase('still');
|
||||
});
|
||||
|
|
@ -791,9 +811,7 @@
|
|||
statusEl.textContent = 'The sensor can see you!';
|
||||
runCalibrateCountdown(CONFIG.calibrateWalkThroughDuration, function () {
|
||||
if (state.calibratePhase !== 'walk_through') return;
|
||||
statusEl.innerHTML = '<span class="wizard-success">✓ Calibration complete!</span>';
|
||||
saveState();
|
||||
setTimeout(function () { goToStep(state.currentStepIndex + 1); }, 1500);
|
||||
showPostCalibrationCard();
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
|
@ -816,6 +834,45 @@
|
|||
tick();
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Post-Calibration Reinforcement Card
|
||||
// ============================================
|
||||
function showPostCalibrationCard() {
|
||||
var instructions = document.getElementById('calibrate-instructions');
|
||||
var statusEl = document.getElementById('calibrate-status');
|
||||
if (!instructions || !statusEl) return;
|
||||
|
||||
var linkCount = state.calibrationLinks.length || 1;
|
||||
var nodeLabel = state.nodeMAC || 'Node';
|
||||
|
||||
instructions.innerHTML =
|
||||
'<div class="post-cal-card">' +
|
||||
'<div class="wizard-icon-large wizard-success-icon">\u2713</div>' +
|
||||
'<h3>You\'re All Set!</h3>' +
|
||||
'<p class="post-cal-summary">' + escapeAttr(nodeLabel) + ' calibrated. ' +
|
||||
linkCount + ' sensing link' + (linkCount !== 1 ? 's' : '') +
|
||||
' active. Motion detection: Ready.</p>' +
|
||||
'<p class="post-cal-expect">You\'ll see the CSI waveform react when someone walks through the room. ' +
|
||||
'The system learns your space over the next few hours and becomes more accurate.</p>' +
|
||||
'<div class="post-cal-actions">' +
|
||||
'<button class="wizard-btn wizard-btn-secondary" id="post-cal-add">Add another node</button>' +
|
||||
'<button class="wizard-btn wizard-btn-primary" id="post-cal-done">I\'m done for now</button>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
statusEl.innerHTML = '';
|
||||
|
||||
document.getElementById('post-cal-add').addEventListener('click', function () {
|
||||
clearState();
|
||||
closeWizard();
|
||||
// Let the user start a new wizard from the dashboard
|
||||
});
|
||||
|
||||
document.getElementById('post-cal-done').addEventListener('click', function () {
|
||||
saveState();
|
||||
goToStep(state.currentStepIndex + 1);
|
||||
});
|
||||
}
|
||||
|
||||
function renderPlacement(contentEl) {
|
||||
contentEl.innerHTML =
|
||||
'<div class="wizard-step-content">' +
|
||||
|
|
@ -984,6 +1041,7 @@
|
|||
window.SpaxelOnboard._provisionAndSend = provisionAndSend;
|
||||
window.SpaxelOnboard._UserError = UserError;
|
||||
window.SpaxelOnboard._isUserError = isUserError;
|
||||
window.SpaxelOnboard._showPostCalibrationCard = showPostCalibrationCard;
|
||||
|
||||
// Auto-start if on /onboard path
|
||||
if (window.location.pathname === '/onboard') {
|
||||
|
|
|
|||
277
dashboard/js/tooltips.js
Normal file
277
dashboard/js/tooltips.js
Normal file
|
|
@ -0,0 +1,277 @@
|
|||
/**
|
||||
* Spaxel First-Time Feature Tooltips
|
||||
*
|
||||
* Shows contextual tooltips on first dashboard open after a node is added.
|
||||
* Each tooltip is shown once and tracked via localStorage flags.
|
||||
*
|
||||
* TooltipManager.show(tooltipId, targetSelector, text, direction)
|
||||
* TooltipManager.showSequence() — walks through the manifest automatically
|
||||
*/
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
// ============================================
|
||||
// Configuration
|
||||
// ============================================
|
||||
var STORAGE_PREFIX = 'spaxel_tooltip_';
|
||||
var DISMISS_MS = 8000; // auto-dismiss after 8 s
|
||||
var SEQUENCE_GAP_MS = 2000; // pause between sequential tooltips
|
||||
|
||||
var TOOLTIP_MANIFEST = [
|
||||
{
|
||||
id: 'csi-chart',
|
||||
target: '#chart-panel',
|
||||
text: 'This is your live signal. Motion causes the waves to change.',
|
||||
direction: 'left',
|
||||
},
|
||||
{
|
||||
id: '3d-view',
|
||||
target: '#scene-container',
|
||||
text: 'This 3D space updates as people move around.',
|
||||
direction: 'top',
|
||||
},
|
||||
{
|
||||
id: 'presence-indicator',
|
||||
target: '#presence-indicator',
|
||||
text: 'Green = no one detected. Red = motion detected.',
|
||||
direction: 'bottom',
|
||||
},
|
||||
{
|
||||
id: 'link-list',
|
||||
target: '.link-section',
|
||||
text: 'Each line between two nodes is a sensing link.',
|
||||
direction: 'left',
|
||||
},
|
||||
];
|
||||
|
||||
// ============================================
|
||||
// Internal State
|
||||
// ============================================
|
||||
var state = {
|
||||
activeTooltip: null,
|
||||
dismissTimer: null,
|
||||
sequenceTimer: null,
|
||||
sequenceIndex: 0,
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// localStorage Helpers
|
||||
// ============================================
|
||||
function hasShown(id) {
|
||||
try {
|
||||
return localStorage.getItem(STORAGE_PREFIX + id + '_shown') === 'true';
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function markShown(id) {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_PREFIX + id + '_shown', 'true');
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
function shouldShowSequence() {
|
||||
try {
|
||||
return localStorage.getItem('spaxel_tooltips_shown') !== 'true';
|
||||
} catch (e) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function markAllShown() {
|
||||
try {
|
||||
localStorage.setItem('spaxel_tooltips_shown', 'true');
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Tooltip Rendering
|
||||
// ============================================
|
||||
function show(tooltipId, targetSelector, text, direction) {
|
||||
if (hasShown(tooltipId)) return false;
|
||||
|
||||
dismiss();
|
||||
|
||||
var target = document.querySelector(targetSelector);
|
||||
if (!target) return false;
|
||||
|
||||
var tooltip = document.createElement('div');
|
||||
tooltip.className = 'spaxel-tooltip';
|
||||
tooltip.id = 'spaxel-tooltip-' + tooltipId;
|
||||
tooltip.innerHTML =
|
||||
'<div class="spaxel-tooltip-text">' + escapeHTML(text) + '</div>' +
|
||||
'<div class="spaxel-tooltip-arrow spaxel-tooltip-arrow-' + (direction || 'top') + '"></div>';
|
||||
|
||||
// Append first so we can measure dimensions
|
||||
tooltip.style.visibility = 'hidden';
|
||||
document.body.appendChild(tooltip);
|
||||
|
||||
var rect = target.getBoundingClientRect();
|
||||
var tipRect = tooltip.getBoundingClientRect();
|
||||
var top, left, transform;
|
||||
|
||||
switch (direction) {
|
||||
case 'bottom':
|
||||
top = rect.bottom + 10;
|
||||
left = rect.left + rect.width / 2 - tipRect.width / 2;
|
||||
transform = '';
|
||||
break;
|
||||
case 'left':
|
||||
top = rect.top + rect.height / 2 - tipRect.height / 2;
|
||||
left = rect.left - tipRect.width - 10;
|
||||
transform = '';
|
||||
break;
|
||||
case 'right':
|
||||
top = rect.top + rect.height / 2 - tipRect.height / 2;
|
||||
left = rect.right + 10;
|
||||
transform = '';
|
||||
break;
|
||||
default: // top
|
||||
top = rect.top - tipRect.height - 10;
|
||||
left = rect.left + rect.width / 2 - tipRect.width / 2;
|
||||
transform = '';
|
||||
break;
|
||||
}
|
||||
|
||||
// Clamp to viewport
|
||||
var vpW = window.innerWidth;
|
||||
var vpH = window.innerHeight;
|
||||
left = Math.max(8, Math.min(left, vpW - tipRect.width - 8));
|
||||
top = Math.max(8, Math.min(top, vpH - tipRect.height - 8));
|
||||
|
||||
tooltip.style.top = top + 'px';
|
||||
tooltip.style.left = left + 'px';
|
||||
tooltip.style.transform = transform;
|
||||
tooltip.style.visibility = 'visible';
|
||||
|
||||
state.activeTooltip = tooltip;
|
||||
|
||||
// Auto-dismiss
|
||||
state.dismissTimer = setTimeout(function () {
|
||||
markShown(tooltipId);
|
||||
dismiss();
|
||||
}, DISMISS_MS);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function dismiss() {
|
||||
if (state.dismissTimer) {
|
||||
clearTimeout(state.dismissTimer);
|
||||
state.dismissTimer = null;
|
||||
}
|
||||
if (state.activeTooltip) {
|
||||
if (state.activeTooltip.parentNode) {
|
||||
state.activeTooltip.parentNode.removeChild(state.activeTooltip);
|
||||
}
|
||||
state.activeTooltip = null;
|
||||
}
|
||||
}
|
||||
|
||||
function dismissAll() {
|
||||
dismiss();
|
||||
if (state.sequenceTimer) {
|
||||
clearTimeout(state.sequenceTimer);
|
||||
state.sequenceTimer = null;
|
||||
}
|
||||
markAllShown();
|
||||
hideDismissAllButton();
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Sequential Tooltip Tour
|
||||
// ============================================
|
||||
function showSequence() {
|
||||
if (!shouldShowSequence()) return;
|
||||
|
||||
// Collect tooltips whose targets exist in the DOM
|
||||
var visible = [];
|
||||
for (var i = 0; i < TOOLTIP_MANIFEST.length; i++) {
|
||||
var t = TOOLTIP_MANIFEST[i];
|
||||
if (!hasShown(t.id) && document.querySelector(t.target)) {
|
||||
visible.push(t);
|
||||
}
|
||||
}
|
||||
|
||||
if (visible.length === 0) {
|
||||
markAllShown();
|
||||
return;
|
||||
}
|
||||
|
||||
showDismissAllButton();
|
||||
|
||||
// Show first tooltip
|
||||
if (show(visible[0].id, visible[0].target, visible[0].text, visible[0].direction)) {
|
||||
state.sequenceIndex = 1;
|
||||
scheduleNext(visible);
|
||||
} else {
|
||||
markAllShown();
|
||||
hideDismissAllButton();
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleNext(visible) {
|
||||
if (state.sequenceIndex >= visible.length) {
|
||||
markAllShown();
|
||||
hideDismissAllButton();
|
||||
return;
|
||||
}
|
||||
|
||||
state.sequenceTimer = setTimeout(function () {
|
||||
var next = visible[state.sequenceIndex];
|
||||
if (show(next.id, next.target, next.text, next.direction)) {
|
||||
state.sequenceIndex++;
|
||||
scheduleNext(visible);
|
||||
} else {
|
||||
// Target no longer visible — skip
|
||||
state.sequenceIndex++;
|
||||
scheduleNext(visible);
|
||||
}
|
||||
}, DISMISS_MS + SEQUENCE_GAP_MS);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Dismiss-All Button
|
||||
// ============================================
|
||||
function showDismissAllButton() {
|
||||
if (document.getElementById('spaxel-dismiss-all-tooltips')) return;
|
||||
|
||||
var btn = document.createElement('button');
|
||||
btn.id = 'spaxel-dismiss-all-tooltips';
|
||||
btn.className = 'spaxel-dismiss-all';
|
||||
btn.textContent = 'Dismiss all tips';
|
||||
btn.addEventListener('click', dismissAll);
|
||||
document.body.appendChild(btn);
|
||||
}
|
||||
|
||||
function hideDismissAllButton() {
|
||||
var btn = document.getElementById('spaxel-dismiss-all-tooltips');
|
||||
if (btn && btn.parentNode) btn.parentNode.removeChild(btn);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Helpers
|
||||
// ============================================
|
||||
function escapeHTML(s) {
|
||||
var div = document.createElement('div');
|
||||
div.textContent = s;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Public API
|
||||
// ============================================
|
||||
window.SpaxelTooltips = {
|
||||
show: show,
|
||||
dismiss: dismiss,
|
||||
dismissAll: dismissAll,
|
||||
showSequence: showSequence,
|
||||
// Exposed for testing
|
||||
_TOOLTIP_MANIFEST: TOOLTIP_MANIFEST,
|
||||
_STORAGE_PREFIX: STORAGE_PREFIX,
|
||||
_DISMISS_MS: DISMISS_MS,
|
||||
_state: state,
|
||||
};
|
||||
})();
|
||||
311
dashboard/js/troubleshoot.js
Normal file
311
dashboard/js/troubleshoot.js
Normal file
|
|
@ -0,0 +1,311 @@
|
|||
/**
|
||||
* Spaxel Troubleshooting Manager
|
||||
*
|
||||
* Handles node offline cards, detection quality banners, and
|
||||
* calibration completion reinforcement. Subscribes to WebSocket
|
||||
* events via TroubleshootManager.handleEvent() called from app.js.
|
||||
*
|
||||
* Issue state machine per issue key:
|
||||
* DETECTED -> NOTIFIED -> RESOLVED | DISMISSED
|
||||
*
|
||||
* State is in-memory only — issues re-fire on next event after page refresh.
|
||||
*/
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
// ============================================
|
||||
// Constants
|
||||
// ============================================
|
||||
var STATES = {
|
||||
DETECTED: 'detected',
|
||||
NOTIFIED: 'notified',
|
||||
RESOLVED: 'resolved',
|
||||
DISMISSED: 'dismissed',
|
||||
};
|
||||
|
||||
var PACKET_CHECK_MS = 5000; // check link health every 5 s
|
||||
var NO_FRAME_MS = 60000; // 60 s with no frames = quality issue
|
||||
var RECOVERY_RATIO = 0.8; // frames resume → resolve if within 80 % of threshold
|
||||
|
||||
// ============================================
|
||||
// Internal State
|
||||
// ============================================
|
||||
var state = {
|
||||
issues: {}, // issueKey -> { state, data, element }
|
||||
checkTimer: null,
|
||||
nodePanelSection: null,
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// Initialization
|
||||
// ============================================
|
||||
function init() {
|
||||
// Create troubleshoot section inside the node panel
|
||||
var panel = document.getElementById('node-panel');
|
||||
if (panel) {
|
||||
state.nodePanelSection = document.createElement('div');
|
||||
state.nodePanelSection.id = 'troubleshoot-section';
|
||||
panel.appendChild(state.nodePanelSection);
|
||||
}
|
||||
|
||||
// Periodic client-side link health check
|
||||
state.checkTimer = setInterval(checkLinkHealth, PACKET_CHECK_MS);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Public Event Handler (called from app.js)
|
||||
// ============================================
|
||||
function handleEvent(type, data) {
|
||||
switch (type) {
|
||||
case 'node_disconnected':
|
||||
handleNodeOffline(data);
|
||||
break;
|
||||
case 'node_connected':
|
||||
handleNodeOnline(data);
|
||||
break;
|
||||
case 'low_packet_rate':
|
||||
handleLowPacketRate(data);
|
||||
break;
|
||||
case 'calibration_complete':
|
||||
handleCalibrationComplete(data);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Node Offline
|
||||
// ============================================
|
||||
function handleNodeOffline(data) {
|
||||
var mac = data.mac;
|
||||
var key = 'offline_' + mac;
|
||||
|
||||
if (state.issues[key]) return; // already tracking
|
||||
|
||||
var issue = { state: STATES.DETECTED, data: data, element: null };
|
||||
state.issues[key] = issue;
|
||||
issue.state = STATES.NOTIFIED;
|
||||
issue.element = renderOfflineCard(mac);
|
||||
}
|
||||
|
||||
function handleNodeOnline(data) {
|
||||
var mac = data.mac;
|
||||
var key = 'offline_' + mac;
|
||||
|
||||
resolveIssue(key);
|
||||
|
||||
// Also clear any quality banners for links involving this node
|
||||
clearQualityIssuesForNode(mac);
|
||||
}
|
||||
|
||||
function renderOfflineCard(mac) {
|
||||
var last4 = mac.slice(-5).replace(':', '');
|
||||
var time = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
|
||||
var card = document.createElement('div');
|
||||
card.className = 'troubleshoot-card troubleshoot-offline-card';
|
||||
card.innerHTML =
|
||||
'<div class="troubleshoot-card-header">' +
|
||||
'<span class="troubleshoot-card-icon">\u26A0</span>' +
|
||||
'<span>Node <strong>' + escapeAttr(mac) + '</strong> went offline at ' + escapeAttr(time) + '</span>' +
|
||||
'<button class="troubleshoot-dismiss" title="Dismiss">×</button>' +
|
||||
'</div>' +
|
||||
'<div class="troubleshoot-timeline">' +
|
||||
'<div class="troubleshoot-step">' +
|
||||
'<div class="troubleshoot-step-num">1</div>' +
|
||||
'<div class="troubleshoot-step-text">Check the node\'s power LED is on (solid green = connected, blinking = attempting WiFi)</div>' +
|
||||
'</div>' +
|
||||
'<div class="troubleshoot-step">' +
|
||||
'<div class="troubleshoot-step-num">2</div>' +
|
||||
'<div class="troubleshoot-step-text">If blinking: move the node closer to your WiFi router temporarily</div>' +
|
||||
'</div>' +
|
||||
'<details class="troubleshoot-more">' +
|
||||
'<summary>More options</summary>' +
|
||||
'<div class="troubleshoot-step">' +
|
||||
'<div class="troubleshoot-step-num">3</div>' +
|
||||
'<div class="troubleshoot-step-text">If the LED blinks rapidly after 5 minutes: the node has lost its WiFi configuration. Connect to <strong>spaxel-' + escapeAttr(last4) + '</strong> WiFi network to reconfigure.</div>' +
|
||||
'</div>' +
|
||||
'<div class="troubleshoot-step">' +
|
||||
'<div class="troubleshoot-step-num">4</div>' +
|
||||
'<div class="troubleshoot-step-text">If the LED is off: check the power supply and USB cable</div>' +
|
||||
'</div>' +
|
||||
'<div class="troubleshoot-step">' +
|
||||
'<div class="troubleshoot-step-num">5</div>' +
|
||||
'<div class="troubleshoot-step-text">Still stuck? <button class="troubleshoot-reset-btn" data-mac="' + escapeAttr(mac) + '">Reset to factory defaults</button></div>' +
|
||||
'</div>' +
|
||||
'</details>' +
|
||||
'</div>';
|
||||
|
||||
// Dismiss
|
||||
card.querySelector('.troubleshoot-dismiss').addEventListener('click', function () {
|
||||
resolveIssue('offline_' + mac);
|
||||
});
|
||||
|
||||
// Factory reset instructions
|
||||
var resetBtn = card.querySelector('.troubleshoot-reset-btn');
|
||||
if (resetBtn) {
|
||||
resetBtn.addEventListener('click', function () {
|
||||
showResetInstructions(mac);
|
||||
});
|
||||
}
|
||||
|
||||
if (state.nodePanelSection) {
|
||||
state.nodePanelSection.appendChild(card);
|
||||
}
|
||||
return card;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Factory Reset Instructions Modal
|
||||
// ============================================
|
||||
function showResetInstructions(mac) {
|
||||
var modal = document.createElement('div');
|
||||
modal.className = 'troubleshoot-modal-overlay';
|
||||
modal.innerHTML =
|
||||
'<div class="troubleshoot-modal">' +
|
||||
'<h3>Factory Reset Instructions</h3>' +
|
||||
'<ol class="troubleshoot-list">' +
|
||||
'<li>Unplug the node from power</li>' +
|
||||
'<li>Hold the <strong>BOOT</strong> button on the ESP32-S3</li>' +
|
||||
'<li>While holding BOOT, plug in the USB cable</li>' +
|
||||
'<li>Keep holding BOOT for 3 seconds, then release</li>' +
|
||||
'<li>The node will enter setup mode (captive portal)</li>' +
|
||||
'<li>Look for a WiFi network named <strong>Spaxel-Setup</strong> and connect to it</li>' +
|
||||
'</ol>' +
|
||||
'<button class="troubleshoot-modal-close wizard-btn wizard-btn-primary">Got it</button>' +
|
||||
'</div>';
|
||||
|
||||
modal.querySelector('.troubleshoot-modal-close').addEventListener('click', function () {
|
||||
if (modal.parentNode) modal.parentNode.removeChild(modal);
|
||||
});
|
||||
modal.addEventListener('click', function (e) {
|
||||
if (e.target === modal && modal.parentNode) modal.parentNode.removeChild(modal);
|
||||
});
|
||||
|
||||
document.body.appendChild(modal);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Detection Quality (Low Packet Rate)
|
||||
// ============================================
|
||||
function handleLowPacketRate(data) {
|
||||
var linkID = data.link_id;
|
||||
var key = 'quality_' + linkID;
|
||||
|
||||
if (state.issues[key]) return;
|
||||
|
||||
state.issues[key] = { state: STATES.NOTIFIED, data: data, element: null };
|
||||
state.issues[key].element = renderQualityBanner(data);
|
||||
}
|
||||
|
||||
function renderQualityBanner(data) {
|
||||
var banner = document.createElement('div');
|
||||
banner.className = 'troubleshoot-quality-banner';
|
||||
var safeId = (data.link_id || '').replace(/[^a-zA-Z0-9]/g, '_');
|
||||
banner.id = 'quality-banner-' + safeId;
|
||||
banner.innerHTML =
|
||||
'<span class="troubleshoot-quality-icon">\u26A0</span>' +
|
||||
'<span>Node is having trouble communicating. Check that it is powered on and within WiFi range.</span>' +
|
||||
'<button class="troubleshoot-dismiss" title="Dismiss">×</button>';
|
||||
|
||||
banner.querySelector('.troubleshoot-dismiss').addEventListener('click', function () {
|
||||
resolveIssue('quality_' + (data.link_id || ''));
|
||||
});
|
||||
|
||||
document.body.appendChild(banner);
|
||||
return banner;
|
||||
}
|
||||
|
||||
function clearQualityIssuesForNode(mac) {
|
||||
var keys = Object.keys(state.issues);
|
||||
for (var i = 0; i < keys.length; i++) {
|
||||
var key = keys[i];
|
||||
if (key.indexOf('quality_') !== 0) continue;
|
||||
var d = state.issues[key].data;
|
||||
if (d && (d.node_mac === mac || d.peer_mac === mac)) {
|
||||
resolveIssue(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Client-side Link Health Check
|
||||
// ============================================
|
||||
function checkLinkHealth() {
|
||||
if (!window.SpaxelApp || typeof window.SpaxelApp.getLinks !== 'function') return;
|
||||
|
||||
var links = window.SpaxelApp.getLinks();
|
||||
var now = Date.now();
|
||||
|
||||
links.forEach(function (link, linkID) {
|
||||
if (!link.lastFrame) return;
|
||||
var elapsed = now - link.lastFrame;
|
||||
|
||||
if (elapsed > NO_FRAME_MS) {
|
||||
// No frames for over 60 s — flag quality issue
|
||||
var key = 'quality_' + linkID;
|
||||
if (!state.issues[key]) {
|
||||
handleLowPacketRate({
|
||||
link_id: linkID,
|
||||
node_mac: link.nodeMAC,
|
||||
peer_mac: link.peerMAC,
|
||||
});
|
||||
}
|
||||
} else if (elapsed < NO_FRAME_MS * RECOVERY_RATIO) {
|
||||
// Frames resumed — auto-resolve
|
||||
var key = 'quality_' + linkID;
|
||||
if (state.issues[key]) {
|
||||
resolveIssue(key);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Calibration Complete
|
||||
// ============================================
|
||||
function handleCalibrationComplete(data) {
|
||||
// The post-calibration reinforcement card is rendered by the
|
||||
// onboarding wizard itself (onboard.js). This handler is a
|
||||
// hook for future dashboard-level use (e.g. showing a
|
||||
// notification when calibration completes on a node that was
|
||||
// already on the dashboard).
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Issue Helpers
|
||||
// ============================================
|
||||
function resolveIssue(key) {
|
||||
var issue = state.issues[key];
|
||||
if (!issue) return;
|
||||
issue.state = STATES.RESOLVED;
|
||||
if (issue.element && issue.element.parentNode) {
|
||||
issue.element.parentNode.removeChild(issue.element);
|
||||
}
|
||||
delete state.issues[key];
|
||||
}
|
||||
|
||||
function escapeAttr(s) {
|
||||
return String(s || '').replace(/&/g, '&').replace(/"/g, '"')
|
||||
.replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Public API
|
||||
// ============================================
|
||||
window.SpaxelTroubleshoot = {
|
||||
init: init,
|
||||
handleEvent: handleEvent,
|
||||
// Exposed for testing
|
||||
_state: state,
|
||||
_STATES: STATES,
|
||||
_NO_FRAME_MS: NO_FRAME_MS,
|
||||
};
|
||||
|
||||
// Auto-init when DOM is ready
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
})();
|
||||
479
dashboard/js/troubleshoot.test.js
Normal file
479
dashboard/js/troubleshoot.test.js
Normal file
|
|
@ -0,0 +1,479 @@
|
|||
/**
|
||||
* 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue