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>
595 lines
24 KiB
JavaScript
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}">⚡</button>
|
|
<button class="action-btn btn-ota" data-mac="${node.mac}">↑</button>
|
|
<button class="action-btn btn-flyto" data-mac="${node.mac}">⛶</button>
|
|
<button class="action-btn btn-more" data-mac="${node.mac}">…</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`;
|
|
}
|
|
}
|
|
});
|