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.