// Dependency-free state/routing tests for the existing inline dashboard script. const assert = require('node:assert/strict'); const fs = require('node:fs'); const vm = require('node:vm'); const path = require('node:path'); class Element { constructor() { this.children = []; this.style = {}; this.value = ''; this.files = []; this.disabled = false; this.checked = false; this._text = ''; this.classList = {toggle() {}}; } set textContent(value) { this._text = String(value); this.children = []; } get textContent() { return this._text; } get firstChild() { return this.children[0] || null; } appendChild(child) { this.children.push(child); return child; } removeChild(child) { this.children.splice(this.children.indexOf(child), 1); } } const elements = new Map(); const element = id => { if (!elements.has(id)) elements.set(id, new Element()); return elements.get(id); }; const sockets = [], requests = [], timers = [], confirmations = []; let responseOverride = null; class Socket { constructor(url) { this.url = url; this.readyState = 1; this.sent = []; sockets.push(this); } send(message) { this.sent.push(message); } close() { this.readyState = 3; if (this.onclose) this.onclose(); } emit(message) { this.onmessage({data: message}); } } const context = vm.createContext({ console, AbortController, DOMException, Date, Number, JSON, document: { getElementById: element, createElement: () => new Element(), querySelectorAll: () => [element('calPyrano'), element('pcbSel'), element('otaFile'), element('otaVersion')], }, window: {addEventListener() {}}, location: {protocol: 'https:', host: 'test.invalid'}, localStorage: {setItem() {}, getItem() { return ''; }}, WebSocket: Socket, fetch: async (url, options = {}) => { requests.push({url, options}); if (responseOverride) { const override = responseOverride(url, options); if (override) return override; } const body = url.endsWith('/logs') || url === '/api/devices' ? [] : {ok: true}; return {ok: true, json: async () => body}; }, setTimeout: callback => { timers.push(callback); return timers.length; }, clearTimeout() {}, setInterval() {}, confirm: message => { confirmations.push(message); return true; }, alert: message => { throw new Error('Unexpected alert: ' + message); }, }); const html = fs.readFileSync(path.join(__dirname, 'static', 'index.html'), 'utf8'); const scripts = [...html.matchAll(/]*)?>([\s\S]*?)<\/script>/g)]; vm.runInContext(scripts.map(match => match[1]).join('\n'), context); const A = 'esp32-001122aabbcc', B = 'esp32-001122aabbdd'; context.fleet = [{device_id: A, alias: 'Tracker A'}, {device_id: B, alias: 'Tracker B'}]; context.charts.traj = {options: {scales: {x: {type: 'linear', min: 0, max: 106000}}}}; const config = id => 'CONFIG:' + JSON.stringify({device_id: id, pyrano: 0.2, azdev: 5, pcb_version: 'V1'}); const axisAck = (id, x, y, base) => 'AXISCFG:' + JSON.stringify({device_id:id, x, y, base, x_budget:x*base, y_budget:y*base}); const flush = async () => { for (let i = 0; i < 5; i++) await new Promise(resolve => setImmediate(resolve)); }; async function run() { context.selectDevice(A); assert.equal(context.charts.traj.options.scales.x.max, 106000, 'trajectory uses motor steps, not time'); const old = sockets.at(-1); assert(old.url.endsWith('device_id=' + A)); assert.equal(context.deviceReady, false); old.emit(config(A)); assert.equal(context.deviceReady, true); old.emit('DATA:' + JSON.stringify({device_id: A, ts: 100, pcpv_mw: 590, x: 12, y: 34})); assert.equal(context.samples.length, 1); old.emit('DATA:' + JSON.stringify({device_id: B, ts: 101})); assert.equal(context.samples.length, 1, 'foreign payload must be ignored'); // Axis controls retain desired values while partial ACKs describe actual board state. old.emit('STATUS:ONLINE'); old.emit(axisAck(A, 3, 1, 4)); assert(element('axisSchedule').textContent.includes('12 X y luego 4 Y')); element('axisXFactor').value = '5'; element('axisYFactor').value = '2'; context.sendAxisFactors(); assert.deepEqual(old.sent, ['SET_PARAM:axisXFactor=5', 'SET_PARAM:axisYFactor=2']); assert(context.axisRequested); old.emit(axisAck(A, 5, 1, 4)); assert(context.axisRequested, 'first ACK must not confirm the second, still-pending factor'); assert(element('axisSchedule').textContent.includes('Solicitud pendiente')); assert.equal(element('axisYFactor').value, '2', 'partial ACK must not overwrite requested Y'); old.emit(axisAck(B, 5, 2, 4)); assert(context.axisRequested, 'foreign ACK must not confirm a request'); old.emit('AXISCFG:' + JSON.stringify({device_id:A,x:5,y:2,base:4,x_budget:1,y_budget:8})); assert(context.axisRequested, 'inconsistent budgets must be rejected'); old.emit(axisAck(A, 5, 2, 4)); assert.equal(context.axisRequested, null); assert(element('axisSchedule').textContent.includes('20 X y luego 8 Y')); element('inCicleMax').value = '3'; context.sendParam('cicleMax', 'inCicleMax'); old.emit(axisAck(A, 5, 2, 4)); assert(context.axisRequested, 'a stale base ACK must not confirm the newly requested base'); old.emit(axisAck(A, 5, 2, 3)); assert.equal(context.axisRequested, null); element('inCicleMax').value = '0'; assert.throws(() => context.sendParam('cicleMax', 'inCicleMax'), /entero entre 1 y 64/); element('inCicleMax').value = '64'; assert.throws(() => context.sendParam('cicleMax', 'inCicleMax'), /superar 64 ciclos/); element('axisXFactor').value = '2.5'; assert.throws(() => context.sendAxisFactors(), /enteros entre 1 y 8/); old.emit('STATUS:OFFLINE'); assert.equal(context.axisConfirmed, null, 'board disconnect must clear confirmation even while browser WS stays open'); const beforeOffline = old.sent.length; element('axisXFactor').value = '3'; element('axisYFactor').value = '1'; context.sendAxisFactors(); assert.equal(old.sent.length, beforeOffline, 'axis request must not be sent to an offline board'); old.emit(axisAck(A, 3, 1, 4)); assert.equal(context.axisConfirmed, null, 'late ACK while board is offline must be ignored'); old.emit('STATUS:ONLINE'); context.sendAxisFactors(); assert.equal(context.axisConfirmed, null, 'old firmware without ACK must remain unconfirmed'); assert(context.axisRequested); old.emit(axisAck(A, 3, 1, 4)); old.sent.length = 0; await flush(); // An HTTP response initiated for A must never populate B after switching. let finish; responseOverride = url => url === '/api/devices/' + A + '/calib' ? new Promise(resolve => { finish = resolve; }) : null; element('calPyrano').value = '0.3'; context.applyCalib(); const request = requests.at(-1); context.selectDevice(B); assert.equal(context.axisConfirmed, null, 'device selection must clear the previous board confirmation'); assert.equal(context.axisRequested, null); assert.equal(context.samples.length, 0); assert.equal(context.telSamples.length, 0); assert.equal(element('t_power').textContent, '--'); assert.equal(element('inPosX').value, ''); assert.equal(context.deviceReady, false); assert.equal(request.options.signal.aborted, true); const current = sockets.at(-1); old.emit(config(A)); old.emit('POS:999:999'); old.close(); assert.equal(element('posX').textContent, '--'); assert.equal(context.ws, current); current.emit(config(B)); current.emit('STATUS:ONLINE'); current.emit(axisAck(A, 3, 1, 4)); assert.equal(context.axisConfirmed, null, 'selected B must not accept an A acknowledgement'); current.emit(axisAck(B, 1, 1, 4)); assert(element('axisSchedule').textContent.includes('4 X y luego 4 Y')); finish({ok: true, json: async () => ({mV_per_WM2: 12345})}); await flush(); assert(!element('calNote').textContent.includes('12345')); assert.equal(context.cfg.device_id, B); responseOverride = null; // Manual OTA explicitly captures selected ID and the image that was confirmed. const sha256 = 'a'.repeat(64); current.emit('OTA_STATE:' + JSON.stringify({device_id: B, connected: true, device: '1.0', available: {version: '2.0', sha256}, auto: false})); assert.equal(element('otaPush').disabled, false); context.pushOta(); const push = requests.at(-1); assert.equal(push.url, '/api/devices/' + B + '/ota/push'); assert.equal(JSON.parse(push.options.body).sha256, sha256); assert(confirmations.at(-1).includes('Tracker B [' + B + ']')); assert(confirmations.at(-1).includes('2.0')); await flush(); assert(element('otaStatus').textContent.includes('Pendiente de confirmar')); context.sendRaw('CMD:10'); assert.deepEqual(current.sent, ['CMD:10']); assert.deepEqual(old.sent, []); assert(requests.every(r => !['/api/calib', '/api/pcb', '/ota/push'].includes(r.url))); responseOverride = url => url.endsWith('/pcb') || url.endsWith('/ota/auto') ? Promise.resolve({ok: false, status: 503, json: async () => ({detail: 'storage unavailable'})}) : null; element('pcbSel').value = 'V2'; context.applyPcb(); await flush(); assert.equal(element('pcbSel').value, 'V1', 'failed PCB save must not display an unpersisted version'); assert(element('pcbNote').textContent.includes('storage unavailable')); element('otaAuto').checked = true; context.saveOtaAuto(); await flush(); assert.equal(element('otaAuto').checked, false, 'failed auto-OTA save must restore server state'); assert(element('otaStatus').textContent.includes('storage unavailable')); responseOverride = null; // Failed loads must not retain controls for an old connection or overwrite a new one. current.close(); assert.equal(context.axisConfirmed, null, 'browser disconnect also clears the board confirmation'); assert.equal(context.deviceReady, false); context.sendRaw('CMD:4'); assert.deepEqual(current.sent, ['CMD:10']); context.selectDevice(''); current.emit('FW:old-firmware'); assert.equal(element('otaDevice').textContent, 'sin informar'); assert.equal(context.activeDevice, ''); assert.equal(element('otaPush').disabled, true); await flush(); const repeated = Array.from({length: 16000}, () => ({ts: 100, x: 1, y: 2})); context.seedHistory(repeated); context.seedTel(repeated); assert.equal(context.samples.length, 15000, 'sample cap must hold even for repeated timestamps'); assert.equal(context.telSamples.length, 15000, 'telemetry cap must hold even for repeated timestamps'); console.log('OK: dashboard selection, stale HTTP/WS isolation, axis ACK/pending/bounds/offline state, control guards and targeted OTA confirmation.'); } run().catch(error => { console.error(error); process.exitCode = 1; });