Files
whatsapp/scripts/test_tv_voz.js
T
Lizandro GuarnizoandClaude Fable 5 9ebe6990a5 Pruebas de voz: cubrir también el camino feliz
La suite probaba los fallos (recolector, motor pausado, bloqueo, voz lenta)
pero no que HABLE BIEN: qué texto dice exactamente, con qué voz, y cómo
pronuncia. Un anuncio que suena pero dice «MaríA GóMez» o elige la voz remota
teniendo la local también es un fallo, y no estaba vigilado.

Cuatro casos nuevos: el texto deletrea el código y cierra con el destino, las
tildes capitalizan bien, se prefiere la voz local sobre la remota es-CO, y sin
nombre de paciente igual habla. 13 verificaciones en total.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 11:47:59 -05:00

190 lines
8.2 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));
// ── Armazón de simulación ───────────────────────────────────────────────
function escenario({ bloqueado = false, msArranque = 20 } = {}) {
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;
const synth = {
speaking: false, pending: false,
pause() {}, cancel() { synth.speaking = synth.pending = false; },
resume() { resumePendiente = true; },
getVoices: () => [{ name: 'Sabina', lang: 'es-MX', localService: true }],
speak(u) {
resumeAntesDeSpeak = resumePendiente; resumePendiente = false;
if (bloqueado) return;
synth.pending = true;
st(() => { synth.pending = false; synth.speaking = true; u.emit('start'); }, msArranque);
st(() => { synth.speaking = false; u.emit('end'); }, msArranque + 4000);
},
};
const win = { speechSynthesis: synth };
const beacons = [];
const sandbox = new Function(
'window', 'setTimeout', 'setInterval', 'SpeechSynthesisUtterance',
'localStorage', 'navigator', 'Blob', 'Date',
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 })
);
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 };
}
// ── 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);
}
// ── 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',
cuerpo + '; return { anunciarTurno };'
)({ speechSynthesis: synth }, st, () => {}, Utt,
{ getItem: () => '[]', setItem: () => {} },
{ sendBeacon: () => {} }, function Blob() {},
Object.assign(function () { return { toISOString: () => '2026-01-01T00:00:00' }; }, { now: () => t }));
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);