Se adecuó el UI
This commit is contained in:
parent
b3d6471018
commit
99264aba3d
48
README.md
48
README.md
@ -148,3 +148,51 @@ Si deseas realizar consultas, sugerencias o simplemente tenes alguna duda sobre
|
||||
|
||||
[](https://www.gnu.org/licenses/gpl-3.0.en.html)
|
||||
[](https://www.gugler.com.ar)[](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.
|
||||
|
||||
@ -28,7 +28,8 @@
|
||||
"desktop": "node src/tools/nw.cjs start",
|
||||
"desktop:dev": "node src/tools/nw.cjs dev",
|
||||
"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": {
|
||||
"chai": "^4.3.4",
|
||||
|
||||
@ -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)
|
||||
+ Se actualiza la versión de NWJS → 0.107.0
|
||||
+ Node-hid ahora no necesita nw-gyp, se actualiza la compilación
|
||||
|
||||
16
src/conf/ConfiguracionGeneral.js
Executable file → Normal file
16
src/conf/ConfiguracionGeneral.js
Executable file → Normal file
@ -1,5 +1,4 @@
|
||||
// Configuraciones generales de EMA RECOLECTOR
|
||||
|
||||
// Configuración de EMA Libre
|
||||
module.exports = {
|
||||
"ConfGeneral": {
|
||||
"Tiempo_de_Recoleccion": 40,
|
||||
@ -7,21 +6,20 @@ module.exports = {
|
||||
"SistemaMetrico": "SI",
|
||||
"debug": 3,
|
||||
"debugTamanio": 10,
|
||||
"NombreEstacion": "-",
|
||||
"Modo": "Recolector",
|
||||
"NombreEstacion": "Mi estación",
|
||||
"Modo": "Visor",
|
||||
"Llave_R": "e5303ab3-2a8b-4d42-90ab-1c8367d8521d",
|
||||
"inicio_automatico": false,
|
||||
"Audio": true,
|
||||
"notificar_cambios": true,
|
||||
"Audio": false,
|
||||
"notificar_cambios": true,
|
||||
"audioVoces": "Spanish_(Latin_America) espeak-ng",
|
||||
"IP": "192.168.1.220",
|
||||
"Puerto": "5000",
|
||||
"Ruta": "/datos"
|
||||
|
||||
},
|
||||
"ConfEmail": {
|
||||
"ActivarCorreos": false,
|
||||
"ReceptorEmail": "-",
|
||||
"ReceptorEmail": "xxx@xx.com",
|
||||
"Servicio": "Deshabilitado",
|
||||
"Usuario": "",
|
||||
"Password": "",
|
||||
@ -29,4 +27,4 @@ module.exports = {
|
||||
"Puerto": ""
|
||||
},
|
||||
"ConfPublicador": []
|
||||
};
|
||||
};
|
||||
|
||||
@ -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.');
|
||||
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;
|
||||
|
||||
@ -930,8 +930,8 @@ function VerHistoricoT(lugar)
|
||||
|
||||
async function GuardarConf(origen) {
|
||||
const persist = async draft => {
|
||||
await require('../core/configuracion-store.js').guardarConfiguracion(draft, undefined, origen === 'conf');
|
||||
EMA.Reconfigurar();
|
||||
await require('../core/configuracion-store.js').guardarConfiguracion(draft);
|
||||
try { EMA.Reconfigurar(draft); } catch (error) { console.warn('Configuración guardada; reiniciá el recolector para aplicarla.'); }
|
||||
configGeneralEMA = draft;
|
||||
};
|
||||
if (origen === 'conf') return ConfigUI.save(persist);
|
||||
|
||||
@ -31,7 +31,7 @@
|
||||
|
||||
// Requiere el módulo NWJS
|
||||
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) {
|
||||
window.alert('Esta versión requiere NW.js ' + runtime.nw + '. Iniciá con npm run desktop.');
|
||||
gui.App.quit();
|
||||
|
||||
@ -54,6 +54,7 @@ if (_IS_NW) {
|
||||
gui = require('nw.gui'); // Libreria Grafica
|
||||
os = require('os'); // Libreria del sistema operativo
|
||||
win = gui.Window.get(); // ventana actual
|
||||
win.on('close', function () { if (ConfigUI.canLeave()) this.close(true); });
|
||||
// **********************Iconizado / Menu Superior *********************************************************
|
||||
gui.App.clearCache(); // Se limpia cache
|
||||
win = gui.Window.get(); // Se obtiene la ventana actual
|
||||
@ -94,6 +95,7 @@ if (_IS_NW) {
|
||||
menu.append(new nw.MenuItem({
|
||||
label: 'Salir',
|
||||
click: () => {
|
||||
if (!ConfigUI.canLeave()) return;
|
||||
if (typeof EMA !== 'undefined' && typeof EMA.Salir === 'function') EMA.Salir();
|
||||
nw.App.closeAllWindows();
|
||||
nw.App.quit();
|
||||
|
||||
@ -555,3 +555,17 @@ table.dataTable.table-bordered {
|
||||
#principal > .container > .row > [class*="col-"] { width: 100%; }
|
||||
.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; }
|
||||
}
|
||||
|
||||
@ -17,7 +17,8 @@
|
||||
};
|
||||
function read(config) {
|
||||
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 ?? '')];
|
||||
}));
|
||||
}
|
||||
|
||||
@ -93,7 +93,7 @@ const ConfigUI = (() => {
|
||||
visibility();
|
||||
}
|
||||
}
|
||||
document.addEventListener('click', event => {
|
||||
window.addEventListener('click', event => {
|
||||
if (event.target.closest('[data-bs-toggle="tab"], #Wclo') && !canLeave()) { event.preventDefault(); event.stopImmediatePropagation(); }
|
||||
}, true);
|
||||
window.addEventListener('beforeunload', event => { if (dirty() || saving) { event.preventDefault(); event.returnValue = ''; } });
|
||||
|
||||
@ -1,8 +1,10 @@
|
||||
const EstadoUI = (() => {
|
||||
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})/);
|
||||
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) {
|
||||
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 ? 'warning' : running ? 'success' : 'secondary', reason || !running ? configAction : null);
|
||||
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;
|
||||
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);
|
||||
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 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',
|
||||
@ -50,7 +52,7 @@ const EstadoUI = (() => {
|
||||
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})));
|
||||
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.'});}
|
||||
if (started) timer = setTimeout(refresh, 10000);
|
||||
}
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
<div class="col-centered">
|
||||
<h1>Configuración General</h1>
|
||||
<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 class="row">
|
||||
<div class="col-12 col-md-6 col-lg-4 col-centered">
|
||||
|
||||
18
src/gui_web/configuracion-api.js
Normal file
18
src/gui_web/configuracion-api.js
Normal 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};
|
||||
@ -920,8 +920,9 @@ function VerHistoricoT(lugar)
|
||||
async function GuardarConf(origen) {
|
||||
const persist = async draft => {
|
||||
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.warning) HABLA(0, result.warning, 'warn');
|
||||
} catch (request) {
|
||||
const error = new Error(request.responseJSON?.error || request.message || 'No se pudo conectar con el servidor. Reintentá el guardado.');
|
||||
error.fields = request.responseJSON?.fields;
|
||||
|
||||
@ -157,14 +157,9 @@ app.get('/purgarPendiente', function (req, res)
|
||||
});
|
||||
|
||||
|
||||
app.post('/GuardarConf', async function (req, res) {
|
||||
try {
|
||||
await require('../core/configuracion-store.js').guardarConfiguracion(req.body?.configuracion, undefined, req.body?.validar !== false);
|
||||
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});
|
||||
}
|
||||
require('./configuracion-api.js').registrarConfiguracion(app, {
|
||||
guardar: require('../core/configuracion-store.js').guardarConfiguracion,
|
||||
reconfigurar: config => EMA.Reconfigurar(config)
|
||||
});
|
||||
|
||||
app.get('/VerHistorico', function (req, res)
|
||||
|
||||
26824
src/logs/debug.log
26824
src/logs/debug.log
File diff suppressed because it is too large
Load Diff
@ -41,7 +41,8 @@
|
||||
"postARM": "node tools/nw.cjs rebuild-arm",
|
||||
"desktop:dev": "node tools/nw.cjs dev",
|
||||
"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": {
|
||||
"bootstrap": "^5.3.8",
|
||||
|
||||
84
test/configuracion.test.cjs
Normal file
84
test/configuracion.test.cjs
Normal 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
9
test/fixtures/configuracion.cjs
vendored
Normal 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}]
|
||||
});
|
||||
@ -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 clean = html => html.replace(/<!--[\s\S]*?-->/g, '');
|
||||
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.
|
||||
const routes = [...read('gui_web/express.js').matchAll(/app.use\('([^']+)', express.static\(__dirname \+ '([^']+)'\)\)/g)];
|
||||
app.use(express.static(path.join(src, 'gui_web')));
|
||||
@ -33,39 +37,59 @@ async function probe() {
|
||||
}
|
||||
tests.push('all navigation tabs');
|
||||
$('#settings').html(window.settingsHTML);
|
||||
// Repeated updates must keep one label and one menu item per option.
|
||||
$('.selectpicker').selectpicker();
|
||||
await wait(200);
|
||||
for (const select of document.querySelectorAll('#settings select.selectpicker')) {
|
||||
const $select = $(select);
|
||||
$select.append(new Option('Alternativa', '__alternative'), new Option('Prueba dinámica', '__smoke'));
|
||||
actualizarSelects($select);
|
||||
$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');
|
||||
const fixture = window.configFixture;
|
||||
const interfaces = {eth0: [{family:'IPv4',internal:false,address:'127.0.0.1'}]};
|
||||
for (let i = 0; i < 3; i++) {
|
||||
ConfigUI.mount(fixture, interfaces, [{name:'Voz de prueba'}]);
|
||||
check(!ConfigUI.dirty(), 'Mount marked the form dirty');
|
||||
check(document.querySelectorAll('#r_ip option').length === 1, 'Duplicated IP options');
|
||||
check(document.querySelectorAll('#vocesAudio option').length === 1, 'Duplicated voice options');
|
||||
}
|
||||
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');
|
||||
check(!!document.querySelector('.tooltip'), 'Tooltip did not open');
|
||||
$('#tip').tooltip('dispose');
|
||||
tests.push('tooltip show/dispose');
|
||||
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()); }});
|
||||
});
|
||||
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');
|
||||
check(window.failures.length === 0, window.failures.join('\n'));
|
||||
} 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));
|
||||
nw.App.quit();
|
||||
}
|
||||
@ -113,18 +138,27 @@ async function run(mode, origin) {
|
||||
return `<div class="tab-pane ${id === 'inicio' ? 'active show' : ''}" id="${id}"></div>`;
|
||||
}).join('');
|
||||
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 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}">
|
||||
<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>
|
||||
<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>
|
||||
<script>window.settingsHTML=${JSON.stringify(settings)};window.languageURL=${JSON.stringify(language)};window.resultFile=${JSON.stringify(resultFile)};addEventListener('load',${probe.toString()});</script>`);
|
||||
<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.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', '');
|
||||
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) => {
|
||||
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 = '';
|
||||
child.stderr.on('data', data => {log += data;});
|
||||
const timer = setTimeout(() => {child.kill(); reject(new Error('NW.js timed out: ' + folder));}, 30000);
|
||||
|
||||
34
test/ui-startup.cjs
Normal file
34
test/ui-startup.cjs
Normal 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;}});
|
||||
Loading…
Reference in New Issue
Block a user