Segunda pasada adversarial: doble anuncio, fuga entre fichas, y el simulador

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 <noreply@anthropic.com>
This commit is contained in:
Lizandro Guarnizo
2026-08-23 12:05:54 -05:00
co-authored by Claude Fable 5
parent 9ebe6990a5
commit ec9f9f3a7d
4 changed files with 89 additions and 6 deletions
+14
View File
@@ -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
+9
View File
@@ -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');