El televisor no es un equipo dedicado: es una ventana de un computador de uso mixto. Si esa ventana queda tapada o minimizada, Chrome congela sus relojes a ~1 tic por minuto, el sondeo se paraliza y los llamados intermedios no suenan (reproducido y documentado en la bitácora el 23/08 a las 12:11). Los eventos de red no se congelan. La pantalla se suscribe ahora al canal SSE que ya existía —todos los caminos de llamado actualizan sse_ping_at: llamar, rellamar, cambiar_estado y llamar_desde_espera— y cada cola_update dispara un refresco inmediato, aunque la pestaña esté de fondo. El sondeo de 1 s queda de respaldo, y el vigía de congelamiento anota en la bitácora si ambos fallan. Al verificar en producción (sin dar por hecho que el canal servía), el SSE resultó estar MUDO: cero bytes en 17 segundos, ni el keep-alive. Causa: el arranque del ERP deja un búfer de salida activo —los jsonOk() de _helpers hacen ob_clean(), que lo confirma— y sse_turno.php usaba flush() a secas, que no lo atraviesa: todo lo emitido quedaba atrapado. Se drena el búfer al iniciar el stream (ob_end_clean + ob_implicit_flush). Verificado ejecutándolo contra la base real: emite cola_update de inmediato. La suite vigila que la suscripción exista (17 verificaciones de voz). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
240 lines
11 KiB
JavaScript
240 lines
11 KiB
JavaScript
/**
|
|
* scripts/test_tv_voz.js — node scripts/test_tv_voz.js
|
|
*
|
|
* Pruebas de la voz del televisor CONTRA EL CÓDIGO REAL: se extrae el bloque
|
|
* de anuncios de display_global.php y se ejecuta con un sintetizador simulado.
|
|
* Cubre los tres fallos intermitentes diagnosticados:
|
|
* 1. recolector de basura llevándose la locución a mitad de frase
|
|
* 2. motor atascado en pausa tras un cancel() (silencio total sin error)
|
|
* 3. voz que nunca arranca (bloqueo o voz rota) dejando el cartel pegado
|
|
*/
|
|
const fs = require('fs');
|
|
|
|
let fallos = 0;
|
|
function ok(desc, cond) {
|
|
console.log((cond ? ' ok ' : ' FALLA ') + desc);
|
|
if (!cond) fallos++;
|
|
}
|
|
|
|
// ── Extraer el código real ──────────────────────────────────────────────
|
|
const src = fs.readFileSync(__dirname + '/../modules/turnero/views/display_global.php', 'utf8')
|
|
.replace(/<\?(php|=)[\s\S]*?\?>/g, '"PHP"');
|
|
const js = src.match(/<script[^>]*>([\s\S]*?)<\/script>/)[1];
|
|
const ini = js.indexOf('const VOCES_PREFERIDAS');
|
|
const fin = js.indexOf('/* ── Fullscreen ── */');
|
|
const cuerpo = 'let sonidoActivo=true,_vozES=null;function playBeep(){}'
|
|
+ js.slice(ini, fin);
|
|
|
|
ok('el código extraído contiene el anclaje anti-recolector', cuerpo.includes('_uttAncladas'));
|
|
ok('resume() se llama antes de cada speak()',
|
|
/resume\(\);\s*\n\s*window\.speechSynthesis\.speak\(utt\)/.test(cuerpo));
|
|
|
|
ok('el despertador SSE está conectado (los llamados despiertan a la pestaña oculta)',
|
|
js.includes("sse_turno.php?area=recepcion") && js.includes("addEventListener('cola_update'"));
|
|
|
|
// ── Armazón de simulación ───────────────────────────────────────────────
|
|
function escenario({ bloqueado = false, msArranque = 20, caida = false } = {}) {
|
|
let t = 0; const cola = [];
|
|
const st = (fn, ms) => cola.push({ t: t + ms, fn });
|
|
class Utt {
|
|
constructor(x) { this.texto = x; this.l = {}; }
|
|
addEventListener(e, f) { (this.l[e] = this.l[e] || []).push(f); }
|
|
emit(e, arg) { (this.l[e] || []).forEach(f => f(arg || {})); }
|
|
}
|
|
let resumeAntesDeSpeak = false, resumePendiente = false;
|
|
let activa = null, totalSpeaks = 0;
|
|
const synth = {
|
|
speaking: false, pending: false,
|
|
pause() {},
|
|
// Como Chrome de verdad: cancelar una locución activa le dispara
|
|
// 'error' con interrupted. Fue exactamente el comportamiento que el
|
|
// simulador viejo no imitaba, y por eso no cazó el doble anuncio.
|
|
cancel() {
|
|
synth.speaking = synth.pending = false;
|
|
const u = activa; activa = null;
|
|
if (u) u.emit('error', { error: 'interrupted' });
|
|
},
|
|
resume() { resumePendiente = true; },
|
|
getVoices: () => [{ name: 'Sabina', lang: 'es-MX', localService: true }],
|
|
speak(u) {
|
|
resumeAntesDeSpeak = resumePendiente; resumePendiente = false;
|
|
totalSpeaks++;
|
|
if (bloqueado || caida) return; // descartado en silencio: ni pending queda
|
|
activa = u;
|
|
synth.pending = true;
|
|
st(() => { if (activa !== u) return; synth.pending = false; synth.speaking = true; u.emit('start'); }, msArranque);
|
|
st(() => { if (activa !== u) return; synth.speaking = false; activa = null; u.emit('end'); }, msArranque + 4000);
|
|
},
|
|
};
|
|
const win = { speechSynthesis: synth };
|
|
const beacons = [];
|
|
const sandbox = new Function(
|
|
'window', 'setTimeout', 'setInterval', 'SpeechSynthesisUtterance',
|
|
'localStorage', 'navigator', 'Blob', 'Date', 'document',
|
|
cuerpo + '; return { anunciarTurno, _uttAncladas };'
|
|
);
|
|
const fakeDate = { now: () => t };
|
|
fakeDate.prototype = Date.prototype;
|
|
const api = sandbox(
|
|
win, st, () => {}, Utt,
|
|
{ getItem: () => '[]', setItem: () => {} },
|
|
{ sendBeacon: (u, b) => beacons.push(u) },
|
|
function Blob() {},
|
|
Object.assign(function () { return { toISOString: () => '2026-01-01T00:00:00' }; }, { now: () => t }),
|
|
{ addEventListener: () => {}, hidden: false }
|
|
);
|
|
const correr = (hastaMs) => {
|
|
let n = 0;
|
|
while (cola.length && n++ < 500) {
|
|
cola.sort((a, b) => a.t - b.t);
|
|
const e = cola.shift();
|
|
if (hastaMs !== undefined && e.t > hastaMs) { cola.unshift(e); break; }
|
|
t = e.t; e.fn();
|
|
}
|
|
};
|
|
return { api, correr, beacons, resumeUsado: () => resumeAntesDeSpeak, t: () => t,
|
|
speaks: () => totalSpeaks, hablando: () => activa };
|
|
}
|
|
|
|
// ── 1. Anclaje: la locución queda referenciada mientras habla ───────────
|
|
{
|
|
const e = escenario();
|
|
e.api.anunciarTurno('A01', 'Consultorio 2', null, () => {});
|
|
e.correr(800); // ya se llamó a speak()
|
|
ok('durante el habla la locución está anclada (no la puede recoger el GC)',
|
|
e.api._uttAncladas.size === 1);
|
|
e.correr(); // hasta el final
|
|
ok('al terminar se suelta el anclaje (sin fuga de memoria)',
|
|
e.api._uttAncladas.size === 0);
|
|
}
|
|
|
|
// ── 2. resume() antes de speak: motor atascado en pausa ─────────────────
|
|
{
|
|
const e = escenario();
|
|
e.api.anunciarTurno('A02', 'Consultorio 1', null, () => {});
|
|
e.correr();
|
|
ok('speak() siempre va precedido de resume() (destranca el motor pausado)',
|
|
e.resumeUsado());
|
|
}
|
|
|
|
// ── 3. Audio bloqueado: avisa para que el cartel no quede pegado ────────
|
|
{
|
|
const e = escenario({ bloqueado: true });
|
|
let aviso = null;
|
|
e.api.anunciarTurno('A03', 'Consultorio 3', null, () => { aviso = e.t(); });
|
|
e.correr();
|
|
ok('con el audio bloqueado igual avisa y el cartel se puede cerrar',
|
|
aviso !== null && aviso < 10000);
|
|
ok('el anclaje también se suelta cuando no llegó a hablar',
|
|
e.api._uttAncladas.size === 0);
|
|
}
|
|
|
|
// ── 4. Voz lenta (remota): no se corta ──────────────────────────────────
|
|
{
|
|
const e = escenario({ msArranque: 2800 });
|
|
let termino = false;
|
|
e.api.anunciarTurno('A04', 'Consultorio 4', 'maría gómez', () => { termino = true; });
|
|
e.correr();
|
|
ok('una voz que tarda 2,8 s en arrancar habla completa y termina', termino);
|
|
}
|
|
|
|
// ── 5. La bitácora registra start y end ─────────────────────────────────
|
|
{
|
|
const e = escenario();
|
|
e.api.anunciarTurno('A05', 'Consultorio 5', null, () => {});
|
|
e.correr();
|
|
ok('la bitácora mandó al menos 2 eventos (start y end) al servidor',
|
|
e.beacons.length >= 2);
|
|
}
|
|
|
|
// ── 5b. La voz no arranca: reintenta UNA vez, sin doble anuncio ─────────
|
|
// El defecto que esto vigila: cancel() dispara 'error' en la locución vieja,
|
|
// y sin el token de generación ese handler lanzaba OTRO reintento: dos voces
|
|
// superpuestas diciendo lo mismo, de forma intermitente.
|
|
{
|
|
// Voz que queda pendiente pero jamás arranca (pending eterno no: el sim
|
|
// bloqueado ni encola — usamos arranque infinito quitando la cola de start)
|
|
// Modo real de Chrome: el speak se descarta EN SILENCIO (ni speaking ni
|
|
// pending). Ese es el caso que el vigilante reintenta. El otro modo —voz
|
|
// eternamente en pending— se tolera a propósito: es indistinguible de una
|
|
// voz remota lenta, y cortarla fue justo el defecto del entrecortado.
|
|
const e = escenario({ caida: true });
|
|
let avisos = 0;
|
|
e.api.anunciarTurno('C01', 'Consultorio 1', null, () => { avisos++; });
|
|
e.correr(30000);
|
|
ok('voz descartada en silencio: exactamente 2 speak (original + 1 reintento), no más',
|
|
e.speaks() === 2);
|
|
ok('y el cartel recibe exactamente UN aviso de cierre (vía «mudo»)', avisos === 1);
|
|
}
|
|
|
|
// ── 5c. Un anuncio nuevo que cancela al anterior no lo resucita ─────────
|
|
{
|
|
const e = escenario({ msArranque: 20 });
|
|
let avisosA = 0;
|
|
e.api.anunciarTurno('D01', 'Consultorio 1', null, () => { avisosA++; });
|
|
e.correr(1000); // D01 está hablando
|
|
const speaksAntes = e.speaks();
|
|
e.api.anunciarTurno('D02', 'Consultorio 2', null, () => {});
|
|
e.correr();
|
|
ok('cancelar al anterior con un anuncio nuevo no dispara reintentos fantasma del viejo',
|
|
e.speaks() === speaksAntes + 1);
|
|
}
|
|
|
|
// ── 6. El camino feliz: habla bien ──────────────────────────────────────
|
|
// Qué dice exactamente, y con qué voz. Un anuncio que "funciona" pero
|
|
// pronuncia mal el nombre o elige la voz equivocada también es un fallo.
|
|
{
|
|
// Instrumentación directa: escenario propio con captura de la locución
|
|
let t = 0; const cola = [];
|
|
const st = (fn, ms) => cola.push({ t: t + ms, fn });
|
|
class Utt {
|
|
constructor(x) { this.texto = x; this.l = {}; }
|
|
addEventListener(e, f) { (this.l[e] = this.l[e] || []).push(f); }
|
|
emit(e, a) { (this.l[e] || []).forEach(f => f(a || {})); }
|
|
}
|
|
let capturada = null;
|
|
const synth = {
|
|
speaking: false, pending: false, pause() {}, cancel() {}, resume() {},
|
|
getVoices: () => [
|
|
{ name: 'Microsoft Sabina', lang: 'es-MX', localService: true },
|
|
{ name: 'Google español de Colombia', lang: 'es-CO', localService: false },
|
|
],
|
|
speak(u) {
|
|
capturada = u;
|
|
st(() => u.emit('start'), 10);
|
|
st(() => u.emit('end'), 4000);
|
|
},
|
|
};
|
|
const api = new Function(
|
|
'window', 'setTimeout', 'setInterval', 'SpeechSynthesisUtterance',
|
|
'localStorage', 'navigator', 'Blob', 'Date', 'document',
|
|
cuerpo + '; return { anunciarTurno };'
|
|
)({ speechSynthesis: synth }, st, () => {}, Utt,
|
|
{ getItem: () => '[]', setItem: () => {} },
|
|
{ sendBeacon: () => {} }, function Blob() {},
|
|
Object.assign(function () { return { toISOString: () => '2026-01-01T00:00:00' }; }, { now: () => t }),
|
|
{ addEventListener: () => {}, hidden: false });
|
|
|
|
api.anunciarTurno('A01', 'Consultorio 2', 'maría fernanda gómez', () => {});
|
|
let n = 0;
|
|
while (cola.length && n++ < 200) { cola.sort((a, b) => a.t - b.t); const ev = cola.shift(); t = ev.t; ev.fn(); }
|
|
|
|
ok('el texto deletrea el código y termina con el destino',
|
|
capturada && capturada.texto === 'Turno A 0 1, María Fernanda Gómez, pase a Consultorio 2');
|
|
ok('los nombres con tilde salen bien capitalizados (María, no MaríA)',
|
|
capturada && capturada.texto.includes('María Fernanda Gómez'));
|
|
ok('elige la voz LOCAL (Sabina) aunque la remota es-CO esté disponible',
|
|
capturada && capturada.voice && capturada.voice.name === 'Microsoft Sabina');
|
|
ok('sin nombre de paciente igual habla: solo código y destino', (() => {
|
|
capturada = null;
|
|
api.anunciarTurno('B02', 'Recepción 1', null, () => {});
|
|
let m = 0;
|
|
while (cola.length && m++ < 200) { cola.sort((a, b) => a.t - b.t); const ev = cola.shift(); t = ev.t; ev.fn(); }
|
|
return capturada && capturada.texto === 'Turno B 0 2, pase a Recepción 1';
|
|
})());
|
|
}
|
|
|
|
console.log();
|
|
console.log(fallos === 0 ? 'Todo correcto.' : fallos + ' pruebas fallaron.');
|
|
process.exit(fallos === 0 ? 0 : 1);
|