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>
697 lines
28 KiB
JavaScript
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}">⚡</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 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`;
|
|
}
|
|
}
|
|
});
|