Se adecuó el UI

This commit is contained in:
Exequiel Aramburu 2026-09-27 23:05:15 -03:00
parent b3d6471018
commit 99264aba3d
22 changed files with 325 additions and 26883 deletions

View File

@ -148,3 +148,51 @@ Si deseas realizar consultas, sugerencias o simplemente tenes alguna duda sobre
[![N|Solid](https://ema.gugler.com.ar/imgs/LogoLGPLv3-mobile.png)](https://www.gnu.org/licenses/gpl-3.0.en.html) [![N|Solid](https://ema.gugler.com.ar/imgs/LogoLGPLv3-mobile.png)](https://www.gnu.org/licenses/gpl-3.0.en.html)
[![N|Solid](https://ema.gugler.com.ar/imgs/logo_gugler_lab-mobile.png)](https://www.gugler.com.ar)[![N|Solid](https://ema.gugler.com.ar/imgs/logo_uader_mini-mobile.png)](http://fcyt.uader.edu.ar/web/) [![N|Solid](https://ema.gugler.com.ar/imgs/logo_gugler_lab-mobile.png)](https://www.gugler.com.ar)[![N|Solid](https://ema.gugler.com.ar/imgs/logo_uader_mini-mobile.png)](http://fcyt.uader.edu.ar/web/)
## Desarrollo y pruebas de la interfaz
Usar Node.js 20 o posterior (las pruebas se ejecutaron con Node.js 24 y NW.js 0.117.0).
La versión de NW.js está en `src/runtime.json`; los launchers, recompilaciones y
empaquetadores x64 la leen desde ese archivo. `npm run desktop` obtiene el SDK
exacto mediante npm si no está en caché. Para usar un SDK ya instalado, establecer
`NW_BINARY` con la ruta completa a su ejecutable. El arranque comprueba la versión.
Desde `src/`:
```sh
npm ci
npm run desktop
npm run desktop:dev
npm run web
```
`web` respeta la configuración de inicio automático existente. Las siguientes
pruebas usan configuración ficticia y archivos temporales; no activan la estación
ni publican registros:
```sh
npm run test:config
npm run test:ui -- /ruta/al/sdk/nw
npm run test:startup -- /ruta/al/sdk/nw
```
Las pruebas gráficas necesitan una sesión de escritorio. `test:ui` comprueba ambos
juegos de recursos (escritorio/web), formularios, navegación, guardado, recarga,
tablas y estados. `test:startup` abre una copia temporal de la aplicación completa
con el recolector desactivado. Los resultados quedan en el directorio temporal
indicado por cada comando.
El formulario único está en `src/gui_common/view/modelo_configuracion.html`.
Su modelo, controlador, tablas y estados compartidos están en
`src/gui_common/js/`. No se necesita bootstrap-select: todos los selects usan
controles nativos. La configuración web se guarda con `POST /GuardarConf`,
valida en servidor y responde después de la escritura. El GET de escritura
anterior fue retirado.
Los instaladores se generan con `node empaquetarLinux.js` o
`node empaquetarWindows.js` desde la raíz, después de instalar las dependencias
de raíz y `src/`. No se ejecutaron empaquetados ni instalaciones del sistema
como parte de las pruebas de interfaz. El script ARM histórico incluía Node 14
y otro NW.js; ahora se detiene antes de modificar el sistema. Linux ARM puede
usar el modo web con Node.js moderno; no hay una GUI ARM validada para este runtime.

View File

@ -28,7 +28,8 @@
"desktop": "node src/tools/nw.cjs start", "desktop": "node src/tools/nw.cjs start",
"desktop:dev": "node src/tools/nw.cjs dev", "desktop:dev": "node src/tools/nw.cjs dev",
"test:config": "node --test test/configuracion.test.cjs", "test:config": "node --test test/configuracion.test.cjs",
"test:ui": "node test/ui-smoke.cjs" "test:ui": "node test/ui-smoke.cjs",
"test:startup": "node test/ui-startup.cjs"
}, },
"devDependencies": { "devDependencies": {
"chai": "^4.3.4", "chai": "^4.3.4",

View File

@ -1,3 +1,13 @@
** Mejoras de interfaz y configuración (Septiembre 27, 2026)
+ NW.js 0.117.0 centralizado en runtime.json para desarrollo y empaquetado x64.
+ Formulario compartido con controles nativos, validación y aviso de cambios sin guardar.
+ Guardado atómico y POST con confirmación; el núcleo recibe la configuración nueva.
+ Tablas y controles de estado compartidos entre escritorio y web.
+ Diseño adaptable sin zoom automático, con notificaciones ajustadas a pantallas pequeñas.
+ Estados separados para conexión, última lectura y publicación, con motivos y acciones.
+ Pruebas de configuración, interfaz y arranque aisladas de la estación real.
+ El empaquetador ARM histórico queda bloqueado: su Node/NW.js no soportan esta versión.
** Versión 2.0.0: (Septiembre 27, 2026) ** Versión 2.0.0: (Septiembre 27, 2026)
+ Se actualiza la versión de NWJS → 0.107.0 + Se actualiza la versión de NWJS → 0.107.0
+ Node-hid ahora no necesita nw-gyp, se actualiza la compilación + Node-hid ahora no necesita nw-gyp, se actualiza la compilación

12
src/conf/ConfiguracionGeneral.js Executable file → Normal file
View File

@ -1,5 +1,4 @@
// Configuraciones generales de EMA RECOLECTOR // Configuración de EMA Libre
module.exports = { module.exports = {
"ConfGeneral": { "ConfGeneral": {
"Tiempo_de_Recoleccion": 40, "Tiempo_de_Recoleccion": 40,
@ -7,21 +6,20 @@ module.exports = {
"SistemaMetrico": "SI", "SistemaMetrico": "SI",
"debug": 3, "debug": 3,
"debugTamanio": 10, "debugTamanio": 10,
"NombreEstacion": "-", "NombreEstacion": "Mi estación",
"Modo": "Recolector", "Modo": "Visor",
"Llave_R": "e5303ab3-2a8b-4d42-90ab-1c8367d8521d", "Llave_R": "e5303ab3-2a8b-4d42-90ab-1c8367d8521d",
"inicio_automatico": false, "inicio_automatico": false,
"Audio": true, "Audio": false,
"notificar_cambios": true, "notificar_cambios": true,
"audioVoces": "Spanish_(Latin_America) espeak-ng", "audioVoces": "Spanish_(Latin_America) espeak-ng",
"IP": "192.168.1.220", "IP": "192.168.1.220",
"Puerto": "5000", "Puerto": "5000",
"Ruta": "/datos" "Ruta": "/datos"
}, },
"ConfEmail": { "ConfEmail": {
"ActivarCorreos": false, "ActivarCorreos": false,
"ReceptorEmail": "-", "ReceptorEmail": "xxx@xx.com",
"Servicio": "Deshabilitado", "Servicio": "Deshabilitado",
"Usuario": "", "Usuario": "",
"Password": "", "Password": "",

View File

@ -167,7 +167,11 @@ APP = module.exports = {
}); });
} }
}, },
Reconfigurar: function () { Reconfigurar: function (nuevaConfiguracion) {
// Keep the shared object used by the collector and publishers up to date.
if (nuevaConfiguracion) Object.assign(configGeneralEMA, nuevaConfiguracion);
APP.Configuracion.Modo = configGeneralEMA.ConfGeneral.Modo;
APP.Configuracion.utc = configGeneralEMA.ConfGeneral.UTC;
APP.RegistrarLog('0|CORE|⚙️ RECONFIGURANDO: Se reconfigura el recolectorde EMA LIBRE.'); APP.RegistrarLog('0|CORE|⚙️ RECONFIGURANDO: Se reconfigura el recolectorde EMA LIBRE.');
if (configGeneralEMA.ConfEmail.ActivarCorreos === true) Correo.EnviarEmail(configGeneralEMA,'EMA - CONFIG', '<b>Estimado</b>,<br><br> Le informamos que se modificó la configuración de la estación <b>' + configGeneralEMA.ConfGeneral.NombreEstacion + '</b>.<br><br>Saludos Cordiales.'); if (configGeneralEMA.ConfEmail.ActivarCorreos === true) Correo.EnviarEmail(configGeneralEMA,'EMA - CONFIG', '<b>Estimado</b>,<br><br> Le informamos que se modificó la configuración de la estación <b>' + configGeneralEMA.ConfGeneral.NombreEstacion + '</b>.<br><br>Saludos Cordiales.');
APP.Configuracion.TiempoContinuo = configGeneralEMA.ConfGeneral.Tiempo_de_Recoleccion; APP.Configuracion.TiempoContinuo = configGeneralEMA.ConfGeneral.Tiempo_de_Recoleccion;

View File

@ -930,8 +930,8 @@ function VerHistoricoT(lugar)
async function GuardarConf(origen) { async function GuardarConf(origen) {
const persist = async draft => { const persist = async draft => {
await require('../core/configuracion-store.js').guardarConfiguracion(draft, undefined, origen === 'conf'); await require('../core/configuracion-store.js').guardarConfiguracion(draft);
EMA.Reconfigurar(); try { EMA.Reconfigurar(draft); } catch (error) { console.warn('Configuración guardada; reiniciá el recolector para aplicarla.'); }
configGeneralEMA = draft; configGeneralEMA = draft;
}; };
if (origen === 'conf') return ConfigUI.save(persist); if (origen === 'conf') return ConfigUI.save(persist);

View File

@ -31,7 +31,7 @@
// Requiere el módulo NWJS // Requiere el módulo NWJS
const gui = require('nw.gui'); const gui = require('nw.gui');
const runtime = require('../../runtime.json'); const runtime = require(require('path').join(gui.App.startPath, 'runtime.json'));
if (process.versions.nw !== runtime.nw) { if (process.versions.nw !== runtime.nw) {
window.alert('Esta versión requiere NW.js ' + runtime.nw + '. Iniciá con npm run desktop.'); window.alert('Esta versión requiere NW.js ' + runtime.nw + '. Iniciá con npm run desktop.');
gui.App.quit(); gui.App.quit();

View File

@ -54,6 +54,7 @@ if (_IS_NW) {
gui = require('nw.gui'); // Libreria Grafica gui = require('nw.gui'); // Libreria Grafica
os = require('os'); // Libreria del sistema operativo os = require('os'); // Libreria del sistema operativo
win = gui.Window.get(); // ventana actual win = gui.Window.get(); // ventana actual
win.on('close', function () { if (ConfigUI.canLeave()) this.close(true); });
// **********************Iconizado / Menu Superior ********************************************************* // **********************Iconizado / Menu Superior *********************************************************
gui.App.clearCache(); // Se limpia cache gui.App.clearCache(); // Se limpia cache
win = gui.Window.get(); // Se obtiene la ventana actual win = gui.Window.get(); // Se obtiene la ventana actual
@ -94,6 +95,7 @@ if (_IS_NW) {
menu.append(new nw.MenuItem({ menu.append(new nw.MenuItem({
label: 'Salir', label: 'Salir',
click: () => { click: () => {
if (!ConfigUI.canLeave()) return;
if (typeof EMA !== 'undefined' && typeof EMA.Salir === 'function') EMA.Salir(); if (typeof EMA !== 'undefined' && typeof EMA.Salir === 'function') EMA.Salir();
nw.App.closeAllWindows(); nw.App.closeAllWindows();
nw.App.quit(); nw.App.quit();

View File

@ -555,3 +555,17 @@ table.dataTable.table-bordered {
#principal > .container > .row > [class*="col-"] { width: 100%; } #principal > .container > .row > [class*="col-"] { width: 100%; }
.pre-scrollable-m { max-height: none; overflow-y: visible; } .pre-scrollable-m { max-height: none; overflow-y: visible; }
} }
@media (max-width: 767px) {
.notifyjs-container {
position: fixed !important;
inset: auto .75rem .75rem !important;
width: auto !important;
max-width: calc(100vw - 1.5rem);
}
.notifyjs-container > div {
max-width: 100%;
white-space: normal;
overflow-wrap: anywhere;
}
.notifyjs-arrow { display: none !important; }
}

View File

@ -17,7 +17,8 @@
}; };
function read(config) { function read(config) {
return Object.fromEntries(Object.entries(fields).map(([id, [section, key, type]]) => { return Object.fromEntries(Object.entries(fields).map(([id, [section, key, type]]) => {
const value = config[section]?.[key]; let value = config[section]?.[key];
if (id === 'Modo' && value === 'Recolector') value = 'Recolector_USB';
return [id, type === 'boolean' ? (value ? 'Activado' : 'Desactivado') : String(value ?? '')]; return [id, type === 'boolean' ? (value ? 'Activado' : 'Desactivado') : String(value ?? '')];
})); }));
} }

View File

@ -93,7 +93,7 @@ const ConfigUI = (() => {
visibility(); visibility();
} }
} }
document.addEventListener('click', event => { window.addEventListener('click', event => {
if (event.target.closest('[data-bs-toggle="tab"], #Wclo') && !canLeave()) { event.preventDefault(); event.stopImmediatePropagation(); } if (event.target.closest('[data-bs-toggle="tab"], #Wclo') && !canLeave()) { event.preventDefault(); event.stopImmediatePropagation(); }
}, true); }, true);
window.addEventListener('beforeunload', event => { if (dirty() || saving) { event.preventDefault(); event.returnValue = ''; } }); window.addEventListener('beforeunload', event => { if (dirty() || saving) { event.preventDefault(); event.returnValue = ''; } });

View File

@ -1,8 +1,10 @@
const EstadoUI = (() => { const EstadoUI = (() => {
let timer, started = false; let timer, started = false;
function date(value) { function date(value, utc) {
const match = String(value || '').match(/^(\d{2})\/(\d{2})\/(\d{4})[ T](\d{2}):(\d{2}):(\d{2})/); const match = String(value || '').match(/^(\d{2})\/(\d{2})\/(\d{4})[ T](\d{2}):(\d{2}):(\d{2})/);
return match ? new Date(+match[3], +match[2] - 1, +match[1], +match[4], +match[5], +match[6]).getTime() : Date.parse(value); if (!match) return Date.parse(value);
if (Number.isFinite(Number(utc))) return Date.UTC(+match[3], +match[2] - 1, +match[1], +match[4], +match[5], +match[6]) - Number(utc) * 3600000;
return new Date(+match[3], +match[2] - 1, +match[1], +match[4], +match[5], +match[6]).getTime();
} }
function render(data) { function render(data) {
const target = document.getElementById('estado-estacion'); const target = document.getElementById('estado-estacion');
@ -29,11 +31,11 @@ const EstadoUI = (() => {
reason || (sensorFault ? 'La estación informa un problema de sensores. Revisá alimentación y señal.' : visor ? 'La fecha de lectura indica la vigencia del dato recibido.' : running ? 'El proceso está activo; revisá la última lectura para confirmar recepción.' : 'Iniciá el recolector desde Inicio o revisá la configuración.'), reason || (sensorFault ? 'La estación informa un problema de sensores. Revisá alimentación y señal.' : visor ? 'La fecha de lectura indica la vigencia del dato recibido.' : running ? 'El proceso está activo; revisá la última lectura para confirmar recepción.' : 'Iniciá el recolector desde Inicio o revisá la configuración.'),
reason || sensorFault ? 'warning' : running ? 'success' : 'secondary', reason || !running ? configAction : null); reason || sensorFault ? 'warning' : running ? 'success' : 'secondary', reason || !running ? configAction : null);
const record = data.record; const record = data.record;
const age = Date.now() - date(record?.fecha); const age = Date.now() - date(record?.fecha, data.utc);
const stale = Number.isFinite(age) && age > Math.max(120, Number(data.interval || 60) * 3) * 1000; const stale = Number.isFinite(age) && age > Math.max(120, Number(data.interval || 60) * 3) * 1000;
card('Última lectura', record?.fecha || 'Sin lecturas', stale ? 'La lectura no es reciente. Revisá conexión, sensores y horario de la estación.' : record?.fecha ? 'Último registro recibido.' : 'Esperando el primer registro.', stale ? 'warning' : record?.fecha ? 'success' : 'secondary'); card('Última lectura', record?.fecha || 'Sin lecturas', stale ? 'La lectura no es reciente. Revisá conexión, sensores y horario de la estación.' : record?.fecha ? 'Último registro recibido.' : 'Esperando el primer registro.', stale ? 'warning' : record?.fecha ? 'success' : 'secondary');
const publications = Object.values(data.publications || {}).filter(p => p && p.fecha); const publications = Object.values(data.publications || {}).filter(p => p && p.fecha);
publications.sort((a,b) => (date(b.fecha) || 0) - (date(a.fecha) || 0)); publications.sort((a,b) => (date(b.fecha, data.utc) || 0) - (date(a.fecha, data.utc) || 0));
const last = publications[0]; const last = publications[0];
const failed = publications.find(p => p.estado !== true && !['Esperar', 'No enviado'].includes(p.estado)); const failed = publications.find(p => p.estado !== true && !['Esperar', 'No enviado'].includes(p.estado));
card('Última publicación', last ? `${last.red}: ${last.fecha}` : visor ? 'No corresponde al modo visor' : 'Sin publicaciones', card('Última publicación', last ? `${last.red}: ${last.fecha}` : visor ? 'No corresponde al modo visor' : 'Sin publicaciones',
@ -50,7 +52,7 @@ const EstadoUI = (() => {
if (local) { state = EMA.Estado(); record = EMA.UltimoRegistro(); publications = EMA.UltimoPublicado(); sensors = EMA.EstadoSensores(); } if (local) { state = EMA.Estado(); record = EMA.UltimoRegistro(); publications = EMA.UltimoPublicado(); sensors = EMA.EstadoSensores(); }
else [state, record, publications, sensors] = await Promise.all(['/EMA_Estado','/UltimoRegistro','/UltimoPublicado','/EstadoSensores'].map(url => $.ajax({url, timeout:5000}))); else [state, record, publications, sensors] = await Promise.all(['/EMA_Estado','/UltimoRegistro','/UltimoPublicado','/EstadoSensores'].map(url => $.ajax({url, timeout:5000})));
if (config.ConfGeneral.Modo === 'Visor' && typeof UltimoReg !== 'undefined') record = UltimoReg; if (config.ConfGeneral.Modo === 'Visor' && typeof UltimoReg !== 'undefined') record = UltimoReg;
render({mode:config.ConfGeneral.Modo, interval:config.ConfGeneral.Tiempo_de_Recoleccion, state, record, publications, sensors}); render({mode:config.ConfGeneral.Modo, interval:config.ConfGeneral.Tiempo_de_Recoleccion, utc:config.ConfGeneral.UTC, state, record, publications, sensors});
} catch (error) {render({error:'No se pudo consultar el estado. Comprobá que el servicio esté disponible.'});} } catch (error) {render({error:'No se pudo consultar el estado. Comprobá que el servicio esté disponible.'});}
if (started) timer = setTimeout(refresh, 10000); if (started) timer = setTimeout(refresh, 10000);
} }

View File

@ -3,7 +3,7 @@
<div class="col-centered"> <div class="col-centered">
<h1>Configuración General</h1> <h1>Configuración General</h1>
<div class="row"> <div class="row">
<div class="col-12 text-end" style="vertical-align:top;"><button class="btn btn-sm btn-success" onclick="MenuGUI('logs');" role="button">Ver Logs</button> </div> <div class="col-12 text-end" style="vertical-align:top;"><button type="button" class="btn btn-sm btn-success" onclick="MenuGUI('logs');" role="button">Ver Logs</button> </div>
</div> </div>
<div class="row"> <div class="row">
<div class="col-12 col-md-6 col-lg-4 col-centered"> <div class="col-12 col-md-6 col-lg-4 col-centered">

View File

@ -0,0 +1,18 @@
// Injectable storage keeps the real HTTP contract testable without station hardware.
function registrarConfiguracion(app, {guardar, reconfigurar}) {
app.post('/GuardarConf', async (req, res) => {
try {
await guardar(req.body?.configuracion);
} catch (error) {
return res.status(error.fields ? 400 : 500).json({ok: false, error: error.fields ? error.message : 'No se pudo guardar la configuración.', fields: error.fields});
}
// Persistence succeeded even if the running collector cannot reload immediately.
try {
await reconfigurar(req.body.configuracion);
res.json({ok: true});
} catch (error) {
res.json({ok: true, warning: 'La configuración se guardó. Reiniciá el recolector para aplicarla.'});
}
});
}
module.exports = {registrarConfiguracion};

View File

@ -920,8 +920,9 @@ function VerHistoricoT(lugar)
async function GuardarConf(origen) { async function GuardarConf(origen) {
const persist = async draft => { const persist = async draft => {
try { try {
const result = await $.ajax({url: '/GuardarConf', method: 'POST', contentType: 'application/json', data: JSON.stringify({configuracion: draft, validar: origen === 'conf'})}); const result = await $.ajax({url: '/GuardarConf', method: 'POST', contentType: 'application/json', data: JSON.stringify({configuracion: draft})});
if (!result.ok) throw new Error('No se pudo guardar la configuración.'); if (!result.ok) throw new Error('No se pudo guardar la configuración.');
if (result.warning) HABLA(0, result.warning, 'warn');
} catch (request) { } catch (request) {
const error = new Error(request.responseJSON?.error || request.message || 'No se pudo conectar con el servidor. Reintentá el guardado.'); const error = new Error(request.responseJSON?.error || request.message || 'No se pudo conectar con el servidor. Reintentá el guardado.');
error.fields = request.responseJSON?.fields; error.fields = request.responseJSON?.fields;

View File

@ -157,14 +157,9 @@ app.get('/purgarPendiente', function (req, res)
}); });
app.post('/GuardarConf', async function (req, res) { require('./configuracion-api.js').registrarConfiguracion(app, {
try { guardar: require('../core/configuracion-store.js').guardarConfiguracion,
await require('../core/configuracion-store.js').guardarConfiguracion(req.body?.configuracion, undefined, req.body?.validar !== false); reconfigurar: config => EMA.Reconfigurar(config)
EMA.Reconfigurar();
res.json({ok: true});
} catch (error) {
res.status(error.fields ? 400 : 500).json({ok: false, error: error.fields ? error.message : 'No se pudo guardar la configuración.', fields: error.fields});
}
}); });
app.get('/VerHistorico', function (req, res) app.get('/VerHistorico', function (req, res)

File diff suppressed because it is too large Load Diff

View File

@ -41,7 +41,8 @@
"postARM": "node tools/nw.cjs rebuild-arm", "postARM": "node tools/nw.cjs rebuild-arm",
"desktop:dev": "node tools/nw.cjs dev", "desktop:dev": "node tools/nw.cjs dev",
"test:ui": "node ../test/ui-smoke.cjs", "test:ui": "node ../test/ui-smoke.cjs",
"test:config": "node --test ../test/configuracion.test.cjs" "test:config": "node --test ../test/configuracion.test.cjs",
"test:startup": "node ../test/ui-startup.cjs"
}, },
"dependencies": { "dependencies": {
"bootstrap": "^5.3.8", "bootstrap": "^5.3.8",

View File

@ -0,0 +1,84 @@
const {test} = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs/promises');
const path = require('node:path');
const os = require('node:os');
const vm = require('node:vm');
const model = require('../src/gui_common/js/configuracion-modelo.js');
const {guardarConfiguracion} = require('../src/core/configuracion-store.js');
const {registrarConfiguracion} = require('../src/gui_web/configuracion-api.js');
const express = require('../src/node_modules/express');
const fixture = require('./fixtures/configuracion.cjs');
test('el formulario conserva propiedades ajenas y no modifica el objeto original', () => {
const old = fixture(), fields = model.read(old);
fields.NombreEstacion = 'Nuevo'; fields.inicio_auto = 'Activado';
const next = model.build(old, fields);
assert.equal(old.ConfGeneral.NombreEstacion, 'Estación prueba');
assert.equal(next.ConfGeneral.inicio_automatico, true);
assert.deepEqual(next.Redes, old.Redes);
assert.equal(next.ConfGeneral.conservar, old.ConfGeneral.conservar);
assert.deepEqual(model.validate(fields), {});
});
test('validación condicional de visor, IP y SMTP', () => {
const fields = model.read(fixture());
fields.Modo = 'Visor'; assert(model.validate(fields).Llave_R);
fields.Llave_R = 'llave'; assert.deepEqual(model.validate(fields), {});
fields.Modo = 'Recolector_IP'; fields.r_puerto = '65536'; fields.r_path = 'datos';
assert(model.validate(fields).r_puerto); assert(model.validate(fields).r_path);
fields.r_puerto = '65535'; fields.r_path = '/datos'; assert.deepEqual(model.validate(fields), {});
fields.Tipo_SMTP = 'Manual'; fields.Usuario_SMTP = 'invalido';
const errors = model.validate(fields);
assert(errors.Usuario_SMTP && errors.Contrasenia_SMTP && errors.Server_SMTP && errors.Email_receptor);
});
test('guardar y volver a leer, rechazo sin alterar el archivo y limpieza de temporales', async t => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'carpincho-config-'));
t.after(() => fs.rm(dir, {recursive: true, force: true}));
const file = path.join(dir, 'ConfiguracionGeneral.js');
const config = fixture(); config.ConfGeneral.NombreEstacion = 'Prueba " especial';
await guardarConfiguracion(config, file);
const previous = await fs.readFile(file, 'utf8');
const sandbox = {module: {exports: {}}}; vm.runInNewContext(previous, sandbox);
assert.deepEqual(JSON.parse(JSON.stringify(sandbox.module.exports)), config);
config.ConfGeneral.debugTamanio = -1;
await assert.rejects(guardarConfiguracion(config, file));
assert.equal(await fs.readFile(file, 'utf8'), previous);
await assert.rejects(guardarConfiguracion(fixture(), dir));
assert.deepEqual(await fs.readdir(dir), ['ConfiguracionGeneral.js']);
});
test('HTTP espera la escritura, informa rechazos y permite reintentar', async t => {
const app = express(); app.use(express.json());
let release, fail = false, reload = 0;
registrarConfiguracion(app, {guardar: async config => {
assert(config.ConfGeneral);
if (fail) throw new Error('disco no disponible');
await new Promise(resolve => {release = resolve;});
}, reconfigurar: () => {reload++;}});
const server = await new Promise(resolve => {const s = app.listen(0,'127.0.0.1',()=>resolve(s));});
t.after(() => server.close());
const url = `http://127.0.0.1:${server.address().port}/GuardarConf`;
const request = () => fetch(url,{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({configuracion:fixture()})});
let replied = false;
const pending = request().then(response => {replied = true; return response.json();});
while (!release) await new Promise(resolve => setTimeout(resolve, 5));
assert.equal(replied, false); assert.equal(reload, 0);
release(); assert.deepEqual(await pending,{ok:true}); assert.equal(reload,1);
fail = true; const failed = await request(); assert.equal(failed.status,500); assert.equal((await failed.json()).ok,false);
fail = false; release = null; const retry = request();
while (!release) await new Promise(resolve => setTimeout(resolve,5));
release(); assert.equal((await (await retry).json()).ok,true);
});
test('la reconfiguración actualiza el objeto compartido y los parámetros del recolector', async () => {
const source = await fs.readFile(path.join(__dirname,'../src/core/ema.js'),'utf8');
const start = source.indexOf(' Reconfigurar: function');
const end = source.indexOf(' Iniciar: function',start);
const current = fixture();
const context = {configGeneralEMA:current, APP:{Configuracion:{},RegistrarLog() {}},recolector:{conf:{}},Correo:{EnviarEmail(){throw new Error('Unexpected email');}}};
vm.runInNewContext('handler = ({'+source.slice(start,end)+'}).Reconfigurar;',context);
const next = fixture(); next.ConfGeneral.Tiempo_de_Recoleccion=30;next.ConfGeneral.UTC=2;next.ConfGeneral.Modo='Recolector_IP';
context.handler(next);
assert.equal(current.ConfGeneral.Tiempo_de_Recoleccion,30);
assert.equal(context.APP.Configuracion.TiempoContinuo,30);
assert.equal(context.APP.Configuracion.Modo,'Recolector_IP');
assert.equal(context.APP.Configuracion.utc,2);
});

9
test/fixtures/configuracion.cjs vendored Normal file
View File

@ -0,0 +1,9 @@
module.exports = () => ({
ConfGeneral: {
NombreEstacion: 'Estación prueba', Modo: 'Recolector_USB', Tiempo_de_Recoleccion: 60,
debug: 1, debugTamanio: 20, UTC: -3, SistemaMetrico: 'SI', IP: '127.0.0.1', Puerto: '5000', Ruta: '/datos', Llave_R: '',
inicio_automatico: false, notificar_cambios: false, Audio: false, audioVoces: '', conservar: 'propiedad ajena al formulario'
},
ConfEmail: {Servicio: 'Deshabilitado', ActivarCorreos: false, Usuario: '', Password: '', Server: '', Puerto: '587', ReceptorEmail: ''},
Redes: [{Nombre: 'Prueba', Publicar: false}]
});

View File

@ -13,6 +13,10 @@ const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'carpincho-ui-'));
const read = name => fs.readFileSync(path.join(src, name), 'utf8'); const read = name => fs.readFileSync(path.join(src, name), 'utf8');
const clean = html => html.replace(/<!--[\s\S]*?-->/g, ''); const clean = html => html.replace(/<!--[\s\S]*?-->/g, '');
const app = express(); const app = express();
app.use(express.json());
const testConfigFile = path.join(temp, 'web-config.js');
const {guardarConfiguracion} = require('../src/core/configuracion-store.js');
require('../src/gui_web/configuracion-api.js').registrarConfiguracion(app, {guardar: config => guardarConfiguracion(config, testConfigFile), reconfigurar() {}});
// Read actual mounts so a stale web route also fails this test. // Read actual mounts so a stale web route also fails this test.
const routes = [...read('gui_web/express.js').matchAll(/app.use\('([^']+)', express.static\(__dirname \+ '([^']+)'\)\)/g)]; const routes = [...read('gui_web/express.js').matchAll(/app.use\('([^']+)', express.static\(__dirname \+ '([^']+)'\)\)/g)];
app.use(express.static(path.join(src, 'gui_web'))); app.use(express.static(path.join(src, 'gui_web')));
@ -33,39 +37,59 @@ async function probe() {
} }
tests.push('all navigation tabs'); tests.push('all navigation tabs');
$('#settings').html(window.settingsHTML); $('#settings').html(window.settingsHTML);
// Repeated updates must keep one label and one menu item per option. const fixture = window.configFixture;
$('.selectpicker').selectpicker(); const interfaces = {eth0: [{family:'IPv4',internal:false,address:'127.0.0.1'}]};
await wait(200); for (let i = 0; i < 3; i++) {
for (const select of document.querySelectorAll('#settings select.selectpicker')) { ConfigUI.mount(fixture, interfaces, [{name:'Voz de prueba'}]);
const $select = $(select); check(!ConfigUI.dirty(), 'Mount marked the form dirty');
$select.append(new Option('Alternativa', '__alternative'), new Option('Prueba dinámica', '__smoke')); check(document.querySelectorAll('#r_ip option').length === 1, 'Duplicated IP options');
actualizarSelects($select); check(document.querySelectorAll('#vocesAudio option').length === 1, 'Duplicated voice options');
$select.val('__smoke');
for (let i = 0; i < 3; i++) actualizarSelects($select);
let wrapper = select.closest('.bootstrap-select');
check(wrapper.querySelector('.filter-option').textContent.trim() === 'Prueba dinámica', 'Render failed: ' + select.id);
wrapper.querySelector('button').click();
await wait(50);
check(wrapper.querySelector('.dropdown-menu').classList.contains('show'), 'Select did not open: ' + select.id);
check([...wrapper.querySelectorAll('[role="option"] .text')].filter(item => item.textContent === 'Prueba dinámica').length === 1, 'Duplicated menu option: ' + select.id);
const first = wrapper.querySelector('[role="option"]');
first.click();
check(select.value !== '__smoke', 'Option selection failed: ' + select.id);
$select.prop('disabled', true);
actualizarSelects($select);
wrapper = select.closest('.bootstrap-select');
check(wrapper.querySelector('button').getAttribute('aria-disabled') === 'true', 'Disabled state failed: ' + select.id);
wrapper.querySelector('button').click();
check(!wrapper.querySelector('.dropdown-menu').classList.contains('show'), 'Disabled select opened: ' + select.id);
$select.selectpicker('destroy');
} }
tests.push('configuration selects: refresh, value, open, select, disable, destroy'); check(!document.querySelector('.bootstrap-select'), 'Plugin is still used');
check(document.getElementById('inicio_auto').selectedOptions[0].textContent === 'Desactivado', 'Duplicated selected text');
const set = (id, value) => {const e=document.getElementById(id); e.value=value; e.dispatchEvent(new Event('change',{bubbles:true}));};
set('Modo','Visor');
check(!document.getElementById('LlaveR').hidden && document.getElementById('bloque_por_IP_WIFI').hidden, 'Visor visibility failed');
let writes = 0;
check(await ConfigUI.save(async () => {writes++;}) === false && writes === 0, 'Invalid visor config saved');
check(document.getElementById('Llave_R').getAttribute('aria-invalid') === 'true', 'No field validation message');
set('Llave_R','llave-prueba'); set('Modo','Recolector_IP');
check(!document.getElementById('bloque_por_IP_WIFI').hidden && document.getElementById('LlaveR').hidden, 'IP visibility failed');
set('r_puerto','65536');
check(await ConfigUI.save(async () => {writes++;}) === false && writes === 0, 'Invalid port saved');
set('r_puerto','5000'); set('Modo','Recolector_USB'); set('NombreEstacion','Nombre nuevo');
const confirm = window.confirm;
window.confirm = () => false;
check(!ConfigUI.canLeave(), 'Unsaved changes did not block navigation');
const active = document.querySelector('.nav-link.active');
document.getElementById('inicio-tab').click();
check(document.querySelector('.nav-link.active') === active, 'Tab changed despite cancel');
check(await ConfigUI.save(async () => {throw new Error('Fallo simulado');}) === false, 'Save failure not reported');
check(ConfigUI.dirty() && document.getElementById('config-status').textContent.includes('Fallo simulado'), 'Failed save lost dirty state');
window.confirm = confirm;
const saved = await ConfigUI.save(window.persistConfiguration);
check(saved && !ConfigUI.dirty(), 'Successful save did not clear dirty state');
const loaded = window.readConfiguration();
check(loaded.ConfGeneral.NombreEstacion === 'Nombre nuevo', 'Saved config was not persisted');
check(loaded.ConfGeneral.Llave_R === 'llave-prueba', 'Mode switch discarded entered values');
ConfigUI.mount(loaded, interfaces, [{name:'Voz de prueba'}]);
check(document.getElementById('NombreEstacion').value === 'Nombre nuevo', 'Reload lost saved value');
set('NombreEstacion','Cambio descartado'); window.confirm = () => true;
check(ConfigUI.canLeave(), 'Discard did not permit navigation'); window.confirm = confirm;
ConfigUI.mount(loaded, interfaces, []);
tests.push('native selects, repeated mount, mode changes, validation, unsaved changes, failed save, disk save and reload');
EstadoUI.render({state:[false,'Sin conexión USB'], mode:'Recolector_USB',record:false,publications:[]});
check(document.getElementById('estado-estacion').textContent.includes('Sin conexión USB'), 'Missing connection reason');
EstadoUI.render({state:[true,''],record:{fecha:'01/01/2000 00:00:00'},publications:[{red:'Prueba',fecha:'01/01/2000 00:00:00',estado:false,error:'Llave inválida'}]});
check(document.getElementById('estado-estacion').textContent.includes('no es reciente'), 'Stale reading not flagged');
check(document.getElementById('estado-estacion').textContent.includes('Llave inválida'), 'Publication failure missing');
tests.push('separate connection, stale reading and publication error');
$('#tip').tooltip('show'); $('#tip').tooltip('show');
check(!!document.querySelector('.tooltip'), 'Tooltip did not open'); check(!!document.querySelector('.tooltip'), 'Tooltip did not open');
$('#tip').tooltip('dispose'); $('#tip').tooltip('dispose');
tests.push('tooltip show/dispose'); tests.push('tooltip show/dispose');
const table = await new Promise(resolve => { const table = await new Promise(resolve => {
$('#table').DataTable({dom: 'Bfrtip', buttons: ['copy', 'csv'], pageLength: 1, $('#table').DataTable({...TablaUI.options(), pageLength: 1,
language: {url: window.languageURL}, initComplete() { resolve(this.api()); }}); language: {url: window.languageURL}, initComplete() { resolve(this.api()); }});
}); });
check(table.rows().count() === 2, 'Missing table rows'); check(table.rows().count() === 2, 'Missing table rows');
@ -79,6 +103,7 @@ async function probe() {
tests.push('table initialization, filtering, export data, buttons, translation, destroy'); tests.push('table initialization, filtering, export data, buttons, translation, destroy');
check(window.failures.length === 0, window.failures.join('\n')); check(window.failures.length === 0, window.failures.join('\n'));
} catch (error) { window.failures.push(error.stack); } } catch (error) { window.failures.push(error.stack); }
document.getElementById('settings').replaceChildren();
require('fs').writeFileSync(window.resultFile, JSON.stringify({versions: process.versions, tests, failures: window.failures}, null, 2)); require('fs').writeFileSync(window.resultFile, JSON.stringify({versions: process.versions, tests, failures: window.failures}, null, 2));
nw.App.quit(); nw.App.quit();
} }
@ -113,18 +138,27 @@ async function run(mode, origin) {
return `<div class="tab-pane ${id === 'inicio' ? 'active show' : ''}" id="${id}"></div>`; return `<div class="tab-pane ${id === 'inicio' ? 'active show' : ''}" id="${id}"></div>`;
}).join(''); }).join('');
const resultFile = path.join(folder, 'result.json'); const resultFile = path.join(folder, 'result.json');
const settings = read(mode + '/view/modelo_configuracion.html').replace(/<script\b[\s\S]*?<\/script>/g, '').replace(/\son\w+="[^"]*"/g, ''); const settings = read('gui_common/view/modelo_configuracion.html').replace(/<script\b[\s\S]*?<\/script>/g, '').replace(/\son\w+="[^"]*"/g, '');
const language = new URL(mode === 'gui' ? '../gui_common/css/Spanish.json' : 'css/Spanish.json', base).href; const language = new URL(mode === 'gui' ? '../gui_common/css/Spanish.json' : 'css/Spanish.json', base).href;
const configFile = mode === 'gui' ? path.join(folder,'config.js') : testConfigFile;
const fixture = require('./fixtures/configuracion.cjs')();
fs.writeFileSync(path.join(folder, 'index.html'), `<!doctype html><meta charset="utf-8"><base href="${base}"> fs.writeFileSync(path.join(folder, 'index.html'), `<!doctype html><meta charset="utf-8"><base href="${base}">
<script>window.failures=[];addEventListener('error',e=>failures.push(e.message||'Resource: '+e.target.src),true);addEventListener('unhandledrejection',e=>failures.push(String(e.reason)));</script> <script>window.failures=[];addEventListener('error',e=>failures.push(e.message||'Resource: '+e.target.src),true);addEventListener('unhandledrejection',e=>failures.push(String(e.reason)));</script>
${head}<body class="dia"><ul class="nav nav-tabs">${tabs.join('')}</ul>${panes}<div id="settings"></div> ${head}<body class="dia"><ul class="nav nav-tabs">${tabs.join('')}</ul>${panes}<section id="estado-estacion" class="estado-estacion"></section><div id="settings"></div>
<button id="tip" title="Prueba" class="btn btn-primary">Tooltip</button> <button id="tip" title="Prueba" class="btn btn-primary">Tooltip</button>
<table id="table" class="table"><thead><tr><th>Nombre</th></tr></thead><tbody><tr><td>Alpha</td></tr><tr><td>Beta</td></tr></tbody></table> <table id="table" class="table table-bordered"><thead><tr><th>Nombre</th></tr></thead><tbody><tr><td>Alpha</td></tr><tr><td>Beta</td></tr></tbody></table>
<script>window.settingsHTML=${JSON.stringify(settings)};window.languageURL=${JSON.stringify(language)};window.resultFile=${JSON.stringify(resultFile)};addEventListener('load',${probe.toString()});</script>`); <script>
window.configFixture=${JSON.stringify(fixture)};
window.persistConfiguration=async draft => {
if (${JSON.stringify(mode)} === 'gui') await require(${JSON.stringify(path.join(src,'core/configuracion-store.js'))}).guardarConfiguracion(draft, ${JSON.stringify(configFile)});
else {const response=await $.ajax({url:'/GuardarConf',method:'POST',contentType:'application/json',data:JSON.stringify({configuracion:draft})});if(!response.ok)throw new Error('Save failed');}
};
window.readConfiguration=()=>{const sandbox={module:{exports:{}}};require('vm').runInNewContext(require('fs').readFileSync(${JSON.stringify(configFile)},'utf8'),sandbox);return sandbox.module.exports;};
window.settingsHTML=${JSON.stringify(settings)};window.languageURL=${JSON.stringify(language)};window.resultFile=${JSON.stringify(resultFile)};addEventListener('load',${probe.toString()});</script>`);
const args = JSON.parse(read('package.json'))['chromium-args'].replace('--disable-logging', ''); const args = JSON.parse(read('package.json'))['chromium-args'].replace('--disable-logging', '');
fs.writeFileSync(path.join(folder, 'package.json'), JSON.stringify({name:'carpincho-ui-test',main:'index.html','chromium-args':args,window:{show:false,width:1200,height:900}})); fs.writeFileSync(path.join(folder, 'package.json'), JSON.stringify({name:'carpincho-ui-test',main:'index.html','chromium-args':args,window:{show:false,width:1200,height:900}}));
await new Promise((resolve, reject) => { await new Promise((resolve, reject) => {
const child = spawn(process.argv[2] || 'nw', [folder, '--user-data-dir=' + path.join(folder, 'profile')], {stdio: ['ignore', 'ignore', 'pipe']}); const child = spawn(process.argv[2] || process.env.NW_BINARY || 'nw', [folder, '--user-data-dir=' + path.join(folder, 'profile')], {stdio: ['ignore', 'ignore', 'pipe']});
let log = ''; let log = '';
child.stderr.on('data', data => {log += data;}); child.stderr.on('data', data => {log += data;});
const timer = setTimeout(() => {child.kill(); reject(new Error('NW.js timed out: ' + folder));}, 30000); const timer = setTimeout(() => {child.kill(); reject(new Error('NW.js timed out: ' + folder));}, 30000);

34
test/ui-startup.cjs Normal file
View File

@ -0,0 +1,34 @@
// Full desktop startup test; uses a temporary copy and disables collection/publication.
const fs=require('fs'),path=require('path'),os=require('os'),{spawn}=require('child_process');
const root=path.resolve(__dirname,'..');
const dir=fs.mkdtempSync(path.join(os.tmpdir(),'carpincho-startup-'));
fs.cpSync(path.join(root,'src'),dir,{recursive:true,filter:p=>!p.includes('/node_modules')&&!p.includes('/logs')&&!p.endsWith('/ConfiguracionGeneral.js')});
fs.symlinkSync(path.join(root,'src/node_modules'),path.join(dir,'node_modules'),'dir');
fs.mkdirSync(path.join(dir,'logs'));
for(const f of ['debug.log','TanquePendientes.log','registro_diario.log']) fs.writeFileSync(path.join(dir,'logs',f),f==='debug.log'?'':'[]');
const conf=require(path.join(root,'test/fixtures/configuracion.cjs'))();conf.ConfPublicador=[];
fs.writeFileSync(path.join(dir,'conf/ConfiguracionGeneral.js'),'module.exports='+JSON.stringify(conf));
const probe=`<script>
window.startupErrors=[];addEventListener('error',e=>startupErrors.push(e.message||e.target.src),true);addEventListener('unhandledrejection',e=>startupErrors.push(String(e.reason)));
addEventListener('load',()=>setTimeout(async()=>{
try{
await MenuGUI('configuracion');await new Promise(r=>setTimeout(r,700));
if(!document.getElementById('config-form'))throw new Error('No configuration form');
if(document.getElementById('NombreEstacion').value!=='Estación prueba')throw new Error('Configuration not initialized');
document.getElementById('NombreEstacion').value='Prueba arranque';
document.getElementById('Tiempo_de_Recoleccion').value='30';
const saved=await GuardarConf('conf');if(!saved)throw new Error('Save did not succeed');
if(EMA.Configuracion.TiempoContinuo!==30)throw new Error('Collector retained old config');
await MenuGUI('inicio');await new Promise(r=>setTimeout(r,300));
await MenuGUI('configuracion');await new Promise(r=>setTimeout(r,300));
if(document.getElementById('NombreEstacion').value!=='Prueba arranque')throw new Error('Reload failed');
}catch(e){startupErrors.push(e.stack);}
require('fs').writeFileSync(${JSON.stringify(path.join(dir,'result.json'))},JSON.stringify({errors:startupErrors,version:process.versions.nw},null,2));
window.confirm=()=>true;if(typeof ConfigUI!=='undefined')ConfigUI.canLeave();nw.App.quit();
},2000));</script>`;
const html=path.join(dir,'gui/index.html');fs.writeFileSync(html,fs.readFileSync(html,'utf8').replace('<head>','<head>'+probe));
const pkgPath=path.join(dir,'package.json');let pkg=JSON.parse(fs.readFileSync(pkgPath));pkg['chromium-args']+=' --enable-logging=stderr';fs.writeFileSync(pkgPath,JSON.stringify(pkg));
const nw=process.argv[2]||process.env.NW_BINARY||'nw';
const child=spawn(nw,[dir,'--user-data-dir='+path.join(dir,'profile')],{cwd:dir,stdio:['ignore','ignore','pipe']});let log='';child.stderr.on('data',d=>log+=d);const timer=setTimeout(()=>child.kill(),20000);
child.on('error',error=>{clearTimeout(timer);console.error(error.message);process.exitCode=1;});
child.on('exit',()=>{clearTimeout(timer);fs.writeFileSync(path.join(dir,'stderr.log'),log);console.log(dir);if(fs.existsSync(path.join(dir,'result.json'))) {const result=JSON.parse(fs.readFileSync(path.join(dir,'result.json'),'utf8'));console.log(result);if(result.errors.length)process.exitCode=1;}else {console.log(log.slice(-4500));process.exitCode=1;}});