Files
Microtracking/docker_tracker_hub/_dashboard_test.cjs
T

213 lines
10 KiB
JavaScript

// 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(/<script(?:\s[^>]*)?>([\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; });