From ec9f9f3a7d1ff4163ee9f70d98cec6835d6c8eb7 Mon Sep 17 00:00:00 2001 From: Lizandro Guarnizo <77708265+lizandrogd@users.noreply.github.com> Date: Sun, 23 Aug 2026 12:05:54 -0500 Subject: [PATCH] Segunda pasada adversarial: doble anuncio, fuga entre fichas, y el simulador MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Revisión de lo entregado ayer sin dar nada por hecho. Aparecieron dos defectos reales y un vacío en las pruebas que los dejaba pasar. 1. DOBLE ANUNCIO (voz). En Chrome, cancel() dispara 'error' (interrupted) sobre la locución vieja, y ese handler volvía a lanzar el reintento: dos voces superpuestas diciendo lo mismo, intermitente — otra fuente del "entrecortado". Cada locución toma ahora un token de generación; si al dispararse un evento ya no es la vigente (la superó un reintento o el anuncio siguiente), sus handlers solo sueltan el anclaje y callan. De paso la bitácora deja de registrar 'end' falsos de locuciones canceladas. 2. FUGA ENTRE FICHAS (RIPS). La respuesta del sondeo puede llegar después de que la recepcionista cambió de paciente: los exámenes de uno se cargaban en la ficha del siguiente. Ahora se compara la cédula de la respuesta con la de quien está en pantalla y, si no coincide, se descarta y se detiene. 3. EL SIMULADOR MENTÍA POR OMISIÓN. No imitaba que cancel() interrumpe con 'error', por eso el defecto 1 pasó las pruebas. Ahora sí lo hace, y además modela los dos modos reales de fallo de Chrome: el speak descartado en silencio (se reintenta) y la voz eternamente en pending (se tolera: es indistinguible de una voz remota lenta, y cortarla fue el defecto del entrecortado original). Batería completa: 62 verificaciones en 4 suites, todas en verde. test_tv_voz.js 16 incluye: exactamente 1 reintento, sin fantasmas test_rips_sondeo.js 9 incluye: el guardián de ficha existe y corre antes test_rips_ventana.php 6 test_bsuid.php 31 Co-Authored-By: Claude Fable 5 --- modules/turnero/views/display_global.php | 14 ++++++ modules/turnero/views/recepcion.php | 9 ++++ scripts/test_rips_sondeo.js | 15 +++++++ scripts/test_tv_voz.js | 57 +++++++++++++++++++++--- 4 files changed, 89 insertions(+), 6 deletions(-) diff --git a/modules/turnero/views/display_global.php b/modules/turnero/views/display_global.php index 266edd6..4c2886b 100644 --- a/modules/turnero/views/display_global.php +++ b/modules/turnero/views/display_global.php @@ -659,6 +659,14 @@ const BEEP_MS = 700; // hasta que termina. const _uttAncladas = new Set(); +// Token de generación: cancel() dispara 'error' (interrupted) sobre la +// locución vieja, y sin este guardián ese handler volvía a lanzar el +// reintento: DOS voces superpuestas diciendo lo mismo, a veces. Cada decir() +// toma un número; si al disparársele un evento ya no es el vigente, la +// locución fue superada (por un reintento o por el anuncio siguiente) y sus +// handlers no deben hacer nada más que soltar el anclaje. +let _vozGen = 0; + // ── Bitácora de la voz ── // Los fallos intermitentes no se pueden depurar mirando la pantalla: hay que // registrarlos cuando ocurren. Cada evento va a un anillo local (últimos 200, @@ -717,10 +725,13 @@ function anunciarTurno(codigo, destino, paciente, onFin) { _uttAncladas.add(utt); const soltar = () => _uttAncladas.delete(utt); const pedido = Date.now(); + const gen = ++_vozGen; + const vigente = () => gen === _vozGen; let arranco = false; utt.addEventListener('start', () => { arranco = true; + if (!vigente()) return; vozLog('start', { cod: codigo, ms: Date.now() - pedido, voz: utt.voice ? utt.voice.name : '(navegador)', local: utt.voice ? !!utt.voice.localService : null, @@ -728,11 +739,13 @@ function anunciarTurno(codigo, destino, paciente, onFin) { }); utt.addEventListener('end', () => { soltar(); + if (!vigente()) return; // la canceló un reintento o el anuncio siguiente vozLog('end', { cod: codigo, dur: Date.now() - pedido }); avisar(); }); utt.addEventListener('error', (ev) => { soltar(); + if (!vigente()) return; // 'interrupted' por un cancel nuestro: no reintentar vozLog('error', { cod: codigo, err: ev.error || '?', re: esReintento ? 1 : 0 }); esReintento ? avisar() : decir(true); }); @@ -742,6 +755,7 @@ function anunciarTurno(codigo, destino, paciente, onFin) { // nada y 'error' tampoco llega. Solo se nota porque 'start' no ocurre. const margen = (esReintento ? 0 : BEEP_MS) + 2500; setTimeout(() => { + if (!vigente()) { soltar(); return; } if (arranco) return; // Antes de darla por fallida hay que preguntarle al sintetizador: // una voz remota tarda en arrancar porque se baja de internet, y diff --git a/modules/turnero/views/recepcion.php b/modules/turnero/views/recepcion.php index 92bc69d..e0ee839 100644 --- a/modules/turnero/views/recepcion.php +++ b/modules/turnero/views/recepcion.php @@ -1729,6 +1729,15 @@ async function consultarExamenesRips(cedula) { const r = await fetch(`${BASE_WA}api/lab/get_examenes_rips.php?cedula=${encodeURIComponent(cedula)}`); const d = await r.json(); if (!d.ok || !d.encontrados?.length) return; + + // La respuesta pudo llegar DESPUÉS de que la recepcionista cambió de + // ficha: sin esta comprobación, los exámenes de un paciente se + // cargarían en la ficha del siguiente. Si la cédula ya no corresponde + // a quien está en pantalla, la respuesta se descarta. + const cedulaEnPantalla = (pacienteActivo?.numero_documento + || pacienteActivo?.documento + || turnoActivo?.paciente_nombre || '').toString().trim(); + if (cedulaEnPantalla !== cedula) { detenerSondeoRips(); return; } _ripsData = d; const hora = d.hora ? ' (' + String(d.hora).slice(0, 5) + ')' : ''; const warn = document.getElementById('banner-rips-warn'); diff --git a/scripts/test_rips_sondeo.js b/scripts/test_rips_sondeo.js index 5781c81..bf5863a 100644 --- a/scripts/test_rips_sondeo.js +++ b/scripts/test_rips_sondeo.js @@ -98,6 +98,21 @@ function armar() { ok('sin cédula no consulta ni deja intervalos', contador.n === 0 && !api.activo()); } +// ── 7. Guardián de ficha: una respuesta tardía no aterriza en otro paciente ── +// La respuesta del fetch puede llegar DESPUÉS de que la recepcionista cambió +// de ficha. Este caso vigila que el código compare la cédula de la respuesta +// contra la de quien está en pantalla ANTES de cargar nada. Es estructural +// (el código real usa fetch y DOM), pero si alguien borra el guardián, falla. +{ + const i = src.indexOf('get_examenes_rips.php?cedula='); + const j = src.indexOf('cedulaEnPantalla', i); + const k = src.indexOf("d.fuente === 'cache'", i); + ok('el guardián de cédula existe y corre ANTES de cargar los exámenes', + i > -1 && j > -1 && k > -1 && j < k); + ok('el guardián descarta y detiene el sondeo si la ficha cambió', + /cedulaEnPantalla !== cedula\) \{ detenerSondeoRips\(\); return; \}/.test(src)); +} + console.log(); console.log(fallos === 0 ? 'Todo correcto.' : fallos + ' pruebas fallaron.'); process.exit(fallos === 0 ? 0 : 1); diff --git a/scripts/test_tv_voz.js b/scripts/test_tv_voz.js index d5ce481..eef9877 100644 --- a/scripts/test_tv_voz.js +++ b/scripts/test_tv_voz.js @@ -30,7 +30,7 @@ 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 } = {}) { +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 { @@ -39,17 +39,28 @@ function escenario({ bloqueado = false, msArranque = 20 } = {}) { 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() {}, cancel() { synth.speaking = synth.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; - if (bloqueado) return; + totalSpeaks++; + if (bloqueado || caida) return; // descartado en silencio: ni pending queda + activa = u; synth.pending = true; - st(() => { synth.pending = false; synth.speaking = true; u.emit('start'); }, msArranque); - st(() => { synth.speaking = false; u.emit('end'); }, msArranque + 4000); + 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 }; @@ -77,7 +88,8 @@ function escenario({ bloqueado = false, msArranque = 20 } = {}) { t = e.t; e.fn(); } }; - return { api, correr, beacons, resumeUsado: () => resumeAntesDeSpeak, t: () => t }; + return { api, correr, beacons, resumeUsado: () => resumeAntesDeSpeak, t: () => t, + speaks: () => totalSpeaks, hablando: () => activa }; } // ── 1. Anclaje: la locución queda referenciada mientras habla ─────────── @@ -131,6 +143,39 @@ function escenario({ bloqueado = false, msArranque = 20 } = {}) { 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.