/** * 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(/]*>([\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)); // ── 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);