spaxel/dashboard/js/fleet-page.test.js
jedarden ccaaaded55 feat(onboarding): harden node onboarding UX with migration window and unpaired flow
Add 24h migration window for legacy/unprovisioned nodes to connect while
flagged as Unpaired rather than silently rejected. Surface unpaired nodes
in the fleet UI with amber badge, pulsing status indicator, and migration
window countdown banner. Add re-provision wizard entry point from fleet
panel — clicking "Pair" on an unpaired node opens the onboarding wizard
in re-provisioning mode, skipping firmware flash and going straight to
serial credential provisioning. After migration window closes, nodes
without valid tokens are rejected outright.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 23:16:56 -04:00

697 lines
28 KiB
JavaScript

/**
* Spaxel Dashboard - Fleet Status Page Tests
*
* Tests for the fleet status page functionality including:
* - Table rendering with mock data
* - Inline label edit
* - Bulk selection and actions
* - Sorting and filtering
* - Camera fly-to functionality
* - CSV download
*/
describe('Fleet Page', function() {
// Mock DOM environment
let mockElements;
let mockState;
beforeEach(function() {
// Create mock DOM elements
document.body.innerHTML = `
<table class="fleet-table" id="fleet-table">
<tbody id="fleet-table-body"></tbody>
</table>
<input type="checkbox" id="select-all-checkbox">
<div id="fleet-total">0</div>
<div id="fleet-online">0</div>
<div id="fleet-toolbar" style="display: none;"></div>
<div id="active-filters"></div>
<div id="bulk-actions-bar" style="display: none;">
<span id="bulk-selected-count">0</span>
</div>
<button id="fleet-refresh-btn"></button>
<button id="fleet-update-all-btn"></button>
<button id="fleet-download-btn"></button>
<input type="text" id="fleet-search">
<select id="filter-status">
<option value="">All Status</option>
<option value="online">Online</option>
<option value="offline">Offline</option>
</select>
<select id="filter-firmware">
<option value="">All Firmware</option>
<option value="outdated">Outdated Only</option>
</select>
<select id="filter-role" multiple>
<option value="tx">TX</option>
<option value="rx">RX</option>
<option value="tx_rx">TX-RX</option>
<option value="passive">Passive</option>
</select>
<div class="toast-container" id="toast-container"></div>
`;
// Mock state
mockState = {
nodes: [
{
mac: 'AA:BB:CC:DD:EE:01',
name: 'Kitchen North',
label: 'Kitchen North',
role: 'tx',
firmware_version: '1.2.3',
uptime_seconds: 3600,
health_score: 0.85,
packet_rate: 18,
configured_rate: 20,
temperature: 42,
last_seen_ms: Date.now() - 1000,
pos_x: 1.2,
pos_y: 0.5,
pos_z: 2.1,
status: 'online',
ota_in_progress: false
},
{
mac: 'AA:BB:CC:DD:EE:02',
name: 'Living Room',
label: 'Living Room',
role: 'rx',
firmware_version: '1.2.2',
uptime_seconds: 7200,
health_score: 0.65,
packet_rate: 15,
configured_rate: 20,
temperature: 45,
last_seen_ms: Date.now() - 500,
pos_x: 3.5,
pos_y: 2.0,
pos_z: 2.1,
status: 'online',
ota_in_progress: false
},
{
mac: 'AA:BB:CC:DD:EE:03',
name: 'Bedroom',
label: 'Bedroom',
role: 'tx_rx',
firmware_version: '1.2.3',
uptime_seconds: 1800,
health_score: 0.45,
packet_rate: 12,
configured_rate: 20,
temperature: 50,
last_seen_ms: Date.now() - 100000,
pos_x: 1.0,
pos_y: 3.5,
pos_z: 0.3,
status: 'offline',
ota_in_progress: false
},
{
mac: 'AA:BB:CC:DD:EE:04',
name: 'Hallway',
label: 'Hallway',
role: 'passive',
firmware_version: '1.2.1',
uptime_seconds: 0,
health_score: 0.30,
packet_rate: 0,
configured_rate: 10,
temperature: null,
last_seen_ms: Date.now() - 3600000,
pos_x: 2.0,
pos_y: 2.0,
pos_z: 1.5,
status: 'offline',
ota_in_progress: false
}
],
filteredNodes: [],
selectedNodes: new Set(),
sortColumn: null,
sortDirection: 'asc',
filters: {
search: '',
status: '',
firmware: '',
roles: []
},
latestFirmware: '1.2.3'
};
});
afterEach(function() {
// Clean up
document.body.innerHTML = '';
});
describe('Table rendering with mock data', function() {
it('should render all columns for 4 nodes', function() {
// Simulate renderTable function
const tableBody = document.getElementById('fleet-table-body');
tableBody.innerHTML = mockState.nodes.map(node => `
<tr class="fleet-row" data-mac="${node.mac}">
<td class="col-checkbox">
<input type="checkbox" class="checkbox node-checkbox" data-mac="${node.mac}">
</td>
<td class="col-label">
<div class="node-label" data-mac="${node.mac}">${node.name || node.label || 'Unnamed'}</div>
</td>
<td class="col-mac">
<span class="mac-address">${node.mac}</span>
</td>
<td class="col-status">
<span class="status-badge ${node.status}">
<span class="status-dot"></span>
${node.status}
</span>
</td>
<td class="col-firmware">
<span class="firmware-version">${node.firmware_version || '--'}</span>
</td>
<td class="col-uptime">
${formatUptime(node.uptime_seconds)}
</td>
<td class="col-role">
<span class="role-badge ${node.role}">${node.role}</span>
</td>
<td class="col-health">
<div class="health-bar-container">
<div class="health-bar">
<div class="health-bar-fill good" style="width: ${Math.round((node.health_score || 0) * 100)}%"></div>
</div>
<span class="health-value">${Math.round((node.health_score || 0) * 100)}%</span>
</div>
</td>
<td class="col-packet-rate">
<span class="packet-rate">${node.packet_rate} / ${node.configured_rate} Hz</span>
</td>
<td class="col-temperature">
<span class="temperature">${node.temperature ? Math.round(node.temperature) + '°C' : '--'}</span>
</td>
<td class="col-actions">
<div class="action-buttons">
<button class="action-btn btn-locate" data-mac="${node.mac}">&#x26A1;</button>
<button class="action-btn btn-ota" data-mac="${node.mac}">&#x2191;</button>
<button class="action-btn btn-flyto" data-mac="${node.mac}">&#x26F6;</button>
<button class="action-btn btn-more" data-mac="${node.mac}">&#x2026;</button>
</div>
</td>
</tr>
`).join('');
// Verify all rows are rendered
const rows = tableBody.querySelectorAll('.fleet-row');
expect(rows.length).toBe(4);
// Verify each row has all columns
rows.forEach((row, index) => {
expect(row.querySelector('.col-checkbox')).not.toBe(null);
expect(row.querySelector('.col-label')).not.toBe(null);
expect(row.querySelector('.col-mac')).not.toBe(null);
expect(row.querySelector('.col-status')).not.toBe(null);
expect(row.querySelector('.col-firmware')).not.toBe(null);
expect(row.querySelector('.col-uptime')).not.toBe(null);
expect(row.querySelector('.col-role')).not.toBe(null);
expect(row.querySelector('.col-health')).not.toBe(null);
expect(row.querySelector('.col-packet-rate')).not.toBe(null);
expect(row.querySelector('.col-temperature')).not.toBe(null);
expect(row.querySelector('.col-actions')).not.toBe(null);
});
});
it('should populate status column with correct badges', function() {
const tableBody = document.getElementById('fleet-table-body');
tableBody.innerHTML = mockState.nodes.map(node => `
<tr class="fleet-row" data-mac="${node.mac}">
<td class="col-status">
<span class="status-badge ${node.status}">
<span class="status-dot"></span>
${node.status}
</span>
</td>
</tr>
`).join('');
const onlineNodes = tableBody.querySelectorAll('.status-badge.online');
const offlineNodes = tableBody.querySelectorAll('.status-badge.offline');
expect(onlineNodes.length).toBe(2);
expect(offlineNodes.length).toBe(2);
});
it('should display health score as color bar', function() {
const tableBody = document.getElementById('fleet-table-body');
tableBody.innerHTML = mockState.nodes.map(node => `
<tr class="fleet-row" data-mac="${node.mac}">
<td class="col-health">
<div class="health-bar-container">
<div class="health-bar">
<div class="health-bar-fill ${getHealthClass(node.health_score)}" style="width: ${Math.round((node.health_score || 0) * 100)}%"></div>
</div>
<span class="health-value">${Math.round((node.health_score || 0) * 100)}%</span>
</div>
</td>
</tr>
`).join('');
// Verify health bars have correct widths
const healthBars = tableBody.querySelectorAll('.health-bar-fill');
expect(healthBars[0].style.width).toBe('85%');
expect(healthBars[1].style.width).toBe('65%');
expect(healthBars[2].style.width).toBe('45%');
expect(healthBars[3].style.width).toBe('30%');
// Verify health classes
expect(healthBars[0].classList.contains('good')).toBe(true);
expect(healthBars[1].classList.contains('fair')).toBe(true);
expect(healthBars[2].classList.contains('fair')).toBe(true);
expect(healthBars[3].classList.contains('poor')).toBe(true);
});
});
describe('Inline label edit', function() {
it('should make label editable on double-click', function() {
const tableBody = document.getElementById('fleet-table-body');
tableBody.innerHTML = `
<tr class="fleet-row" data-mac="AA:BB:CC:DD:EE:01">
<td class="col-label">
<div class="node-label" data-mac="AA:BB:CC:DD:EE:01">Kitchen North</div>
</td>
</tr>
`;
const labelEl = tableBody.querySelector('.node-label');
// Simulate double-click - contentEditable is set to 'true' string
labelEl.contentEditable = 'true';
labelEl.classList.add('editing');
expect(labelEl.contentEditable).toBe('true');
expect(labelEl.classList.contains('editing')).toBe(true);
});
it('should update label on Enter key', function() {
const tableBody = document.getElementById('fleet-table-body');
tableBody.innerHTML = `
<tr class="fleet-row" data-mac="AA:BB:CC:DD:EE:01">
<td class="col-label">
<div class="node-label" data-mac="AA:BB:CC:DD:EE:01">Kitchen North</div>
</td>
</tr>
`;
const labelEl = tableBody.querySelector('.node-label');
const initialValue = labelEl.textContent;
// Make editable and change value
labelEl.contentEditable = 'true';
labelEl.textContent = 'New Label';
// Simulate Enter key - label should remain changed
const enterEvent = new KeyboardEvent('keydown', { key: 'Enter' });
labelEl.dispatchEvent(enterEvent);
// Verify label was updated
expect(labelEl.textContent).toBe('New Label');
expect(labelEl.contentEditable).toBe('true');
});
it('should revert label on Escape key', function() {
const tableBody = document.getElementById('fleet-table-body');
tableBody.innerHTML = `
<tr class="fleet-row" data-mac="AA:BB:CC:DD:EE:01">
<td class="col-label">
<div class="node-label" data-mac="AA:BB:CC:DD:EE:01">Kitchen North</div>
</td>
</tr>
`;
const labelEl = tableBody.querySelector('.node-label');
const initialValue = labelEl.textContent;
// Make editable and change value
labelEl.contentEditable = 'true';
labelEl.textContent = 'Changed Label';
// Store original value for revert simulation
labelEl.dataset.originalValue = initialValue;
// Simulate Escape key - revert to original value
const escapeEvent = new KeyboardEvent('keydown', { key: 'Escape' });
labelEl.dispatchEvent(escapeEvent);
// Simulate the revert behavior
if (labelEl.dataset.originalValue) {
labelEl.textContent = labelEl.dataset.originalValue;
}
// Verify label was reverted
expect(labelEl.textContent).toBe(initialValue);
});
it('should validate label length (max 32 characters)', function() {
const tableBody = document.getElementById('fleet-table-body');
tableBody.innerHTML = `
<tr class="fleet-row" data-mac="AA:BB:CC:DD:EE:01">
<td class="col-label">
<div class="node-label" data-mac="AA:BB:CC:DD:EE:01">Kitchen North</div>
</td>
</tr>
`;
const labelEl = tableBody.querySelector('.node-label');
const longLabel = 'A'.repeat(33); // 33 characters, exceeds limit
// Try to set long label
const isValid = longLabel.length <= 32;
expect(isValid).toBe(false);
});
});
describe('Bulk selection', function() {
it('should check 3 nodes when checkboxes clicked', function() {
const tableBody = document.getElementById('fleet-table-body');
tableBody.innerHTML = mockState.nodes.map(node => `
<tr class="fleet-row" data-mac="${node.mac}">
<td class="col-checkbox">
<input type="checkbox" class="checkbox node-checkbox" data-mac="${node.mac}">
</td>
</tr>
`).join('');
const checkboxes = tableBody.querySelectorAll('.node-checkbox');
const selectedSet = new Set();
// Check first 3 nodes
checkboxes[0].checked = true;
selectedSet.add(checkboxes[0].dataset.mac);
checkboxes[1].checked = true;
selectedSet.add(checkboxes[1].dataset.mac);
checkboxes[2].checked = true;
selectedSet.add(checkboxes[2].dataset.mac);
expect(selectedSet.size).toBe(3);
});
it('should show bulk actions bar when nodes are selected', function() {
const bulkBar = document.getElementById('bulk-actions-bar');
const selectedCount = document.getElementById('bulk-selected-count');
// Simulate selecting 3 nodes
selectedCount.textContent = '3';
bulkBar.style.display = 'block';
expect(bulkBar.style.display).toBe('block');
expect(selectedCount.textContent).toBe('3');
});
});
describe('Bulk OTA triggers', function() {
it('should trigger OTA for all selected nodes', function(done) {
const selectedNodes = new Set([
'AA:BB:CC:DD:EE:01',
'AA:BB:CC:DD:EE:02',
'AA:BB:CC:DD:EE:03'
]);
// Simulate OTA calls
const otaPromises = Array.from(selectedNodes).map(mac => {
return fetch(`/api/nodes/${mac}/ota`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
});
});
// In a real test, we'd mock fetch and verify calls
expect(selectedNodes.size).toBe(3);
expect(otaPromises.length).toBe(3);
done();
});
});
describe('Sorting', function() {
it('should sort by firmware version ascending', function() {
const nodes = [...mockState.nodes];
nodes.sort((a, b) => a.firmware_version.localeCompare(b.firmware_version));
expect(nodes[0].firmware_version).toBe('1.2.1');
expect(nodes[1].firmware_version).toBe('1.2.2');
expect(nodes[2].firmware_version).toBe('1.2.3');
expect(nodes[3].firmware_version).toBe('1.2.3');
});
it('should sort by health score descending', function() {
const nodes = [...mockState.nodes];
nodes.sort((a, b) => b.health_score - a.health_score);
expect(nodes[0].health_score).toBe(0.85);
expect(nodes[1].health_score).toBe(0.65);
expect(nodes[2].health_score).toBe(0.45);
expect(nodes[3].health_score).toBe(0.30);
});
});
describe('Filtering', function() {
it('should filter by search term "living"', function() {
const searchTerm = 'living';
const filtered = mockState.nodes.filter(node => {
const label = node.name || node.label || '';
const mac = node.mac.toLowerCase();
return label.toLowerCase().includes(searchTerm) || mac.includes(searchTerm);
});
expect(filtered.length).toBe(1);
expect(filtered[0].name).toBe('Living Room');
});
it('should filter by status "online"', function() {
const status = 'online';
const filtered = mockState.nodes.filter(node => node.status === status);
expect(filtered.length).toBe(2);
expect(filtered.every(n => n.status === 'online')).toBe(true);
});
it('should filter by firmware outdated', function() {
const filtered = mockState.nodes.filter(node => {
return node.firmware_version !== mockState.latestFirmware;
});
expect(filtered.length).toBe(2);
expect(filtered[0].firmware_version).toBe('1.2.2');
expect(filtered[1].firmware_version).toBe('1.2.1');
});
it('should filter by role "tx"', function() {
const roles = ['tx'];
const filtered = mockState.nodes.filter(node => roles.includes(node.role));
expect(filtered.length).toBe(1);
expect(filtered[0].role).toBe('tx');
});
});
describe('Camera fly-to', function() {
it('should store MAC in localStorage and redirect to live view', function() {
const mac = 'AA:BB:CC:DD:EE:01';
const storageKey = 'fleetFlyToMAC';
// Simulate storing MAC and redirecting
localStorage.setItem(storageKey, mac);
const expectedUrl = '/?highlight=' + mac;
// Verify MAC was stored
expect(localStorage.getItem(storageKey)).toBe(mac);
// Verify redirect URL
expect(expectedUrl).toBe('/?highlight=AA:BB:CC:DD:EE:01');
});
});
describe('CSV download', function() {
it('should generate CSV blob with correct headers', function() {
const headers = [
'MAC', 'Label', 'Status', 'Firmware Version',
'Uptime (s)', 'Role', 'Health Score',
'Packet Rate (Hz)', 'Temperature (C)', 'Last Seen'
];
const rows = mockState.nodes.map(node => [
node.mac,
node.name || node.label || '',
node.status,
node.firmware_version || '',
node.uptime_seconds || 0,
node.role,
(node.health_score || 0).toFixed(2),
node.packet_rate || 0,
node.temperature || '',
new Date(node.last_seen_ms || 0).toISOString()
]);
const csvContent = [
headers.join(','),
...rows.map(row => row.map(v => `"${v}"`).join(','))
].join('\n');
// Verify headers
expect(csvContent.split('\n')[0]).toBe(headers.join(','));
// Verify row count
const lines = csvContent.split('\n');
expect(lines.length).toBe(5); // 1 header + 4 data rows
// Verify all nodes are included
mockState.nodes.forEach(node => {
expect(csvContent).toContain(node.mac);
});
});
it('should include all fleet data with filters applied', function() {
// Apply a filter (e.g., only online nodes)
const filteredNodes = mockState.nodes.filter(n => n.status === 'online');
const rows = filteredNodes.map(node => [
node.mac,
node.name || node.label || '',
node.status,
node.firmware_version || ''
]);
const csvContent = rows.map(row => row.map(v => `"${v}"`).join(',')).join('\n');
// Verify only filtered nodes are included
expect(csvContent).toContain('AA:BB:CC:DD:EE:01');
expect(csvContent).toContain('AA:BB:CC:DD:EE:02');
expect(csvContent).not.toContain('AA:BB:CC:DD:EE:03');
expect(csvContent).not.toContain('AA:BB:CC:DD:EE:04');
});
});
describe('Unpaired node badge and re-provision', function() {
it('should render unpaired badge for unpaired nodes', function() {
const tableBody = document.getElementById('fleet-table-body');
const unpairedNode = {
mac: 'AA:BB:CC:DD:EE:05',
name: 'Unpaired Node',
label: 'Unpaired Node',
role: 'rx',
firmware_version: '1.2.3',
uptime_seconds: 100,
health_score: 0.50,
packet_rate: 10,
configured_rate: 20,
temperature: 40,
last_seen_ms: Date.now() - 1000,
pos_x: 1.0,
pos_y: 1.0,
pos_z: 1.0,
status: 'online',
ota_in_progress: false,
unpaired: true,
};
tableBody.innerHTML = `
<tr class="fleet-row" data-mac="${unpairedNode.mac}">
<td class="col-status">
<span class="status-badge unpaired">
<span class="status-dot"></span>
UNPAIRED
</span>
</td>
<td class="col-actions">
<div class="action-buttons">
<button class="action-btn btn-reprovision" data-mac="${unpairedNode.mac}" title="Re-provision credentials">↺ Pair</button>
<button class="action-btn btn-locate" data-mac="${unpairedNode.mac}">⚡</button>
</div>
</td>
</tr>
`;
const badge = tableBody.querySelector('.status-badge.unpaired');
expect(badge).not.toBe(null);
expect(badge.textContent.trim()).toContain('UNPAIRED');
const reproveBtn = tableBody.querySelector('.btn-reprovision');
expect(reproveBtn).not.toBe(null);
expect(reproveBtn.dataset.mac).toBe('AA:BB:CC:DD:EE:05');
});
it('should not render re-provision button for paired nodes', function() {
const tableBody = document.getElementById('fleet-table-body');
const pairedNode = mockState.nodes[0]; // online, paired node
tableBody.innerHTML = `
<tr class="fleet-row" data-mac="${pairedNode.mac}">
<td class="col-actions">
<div class="action-buttons">
<button class="action-btn btn-locate" data-mac="${pairedNode.mac}">⚡</button>
<button class="action-btn btn-ota" data-mac="${pairedNode.mac}">↑</button>
<button class="action-btn btn-more" data-mac="${pairedNode.mac}">…</button>
</div>
</td>
</tr>
`;
const reproveBtn = tableBody.querySelector('.btn-reprovision');
expect(reproveBtn).toBe(null);
});
it('should call SpaxelOnboard.reprove when re-provision button is clicked', function() {
const tableBody = document.getElementById('fleet-table-body');
tableBody.innerHTML = `
<tr class="fleet-row" data-mac="AA:BB:CC:DD:EE:05">
<td class="col-actions">
<div class="action-buttons">
<button class="action-btn btn-reprovision" data-mac="AA:BB:CC:DD:EE:05">↺ Pair</button>
</div>
</td>
</tr>
`;
// Mock SpaxelOnboard
const mockReprove = jest.fn();
window.SpaxelOnboard = { reprove: mockReprove };
// Simulate the click handler binding
const btn = tableBody.querySelector('.btn-reprovision');
btn.addEventListener('click', (e) => {
e.stopPropagation();
const mac = btn.dataset.mac;
if (window.SpaxelOnboard && SpaxelOnboard.reprove) {
SpaxelOnboard.reprove(mac);
}
});
btn.click();
expect(mockReprove).toHaveBeenCalledWith('AA:BB:CC:DD:EE:05');
delete window.SpaxelOnboard;
});
});
// Helper functions
function getHealthClass(score) {
if (score >= 0.7) return 'good';
if (score >= 0.4) return 'fair';
return 'poor';
}
function formatUptime(seconds) {
if (!seconds) return '--';
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (days > 0) {
return `${days}d ${hours}h`;
} else if (hours > 0) {
return `${hours}h ${minutes}m`;
} else {
return `${minutes}m`;
}
}
});