/** * 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 = `
0
0
`; // 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 => `
${node.name || node.label || 'Unnamed'}
${node.mac} ${node.status} ${node.firmware_version || '--'} ${formatUptime(node.uptime_seconds)} ${node.role}
${Math.round((node.health_score || 0) * 100)}%
${node.packet_rate} / ${node.configured_rate} Hz ${node.temperature ? Math.round(node.temperature) + '°C' : '--'}
`).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 => ` ${node.status} `).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 => `
${Math.round((node.health_score || 0) * 100)}%
`).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 = `
Kitchen North
`; 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 = `
Kitchen North
`; 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 = `
Kitchen North
`; 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 = `
Kitchen North
`; 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 => ` `).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`; } } });