/**
* 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
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`;
}
}
});