spaxel/dashboard/js/fleet-page.test.js
jedarden 136b2ecd97 feat: add OTA progress tracking to fleet status page
Add real-time OTA update progress broadcasting to dashboard clients:
- Hub.BroadcastOTAProgress() sends progress updates over WebSocket
- OTA Manager now broadcasts state transitions: pending → downloading → rebooting → verified/failed/rollback
- Dashboard can show live OTA status per node during fleet updates
- Includes test suite for fleet page functionality

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-11 22:41:51 -04:00

595 lines
24 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 expert mode', 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');
});
});
// 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`;
}
}
});