Voz del televisor y demora de RIPS: análisis de fondo de los dos "a veces"

── Voz que a veces no suena o se corta ──

Dos causas nuevas, ambas intermitentes por naturaleza:

1. Defecto conocido de Chrome: si nada referencia la SpeechSynthesisUtterance,
   el recolector de basura puede llevársela A MITAD DE FRASE. El audio se corta
   en seco y 'end' no llega. Depende de cuándo pase el recolector, así que
   nunca se reproduce a voluntad. Cada locución queda anclada en un Set hasta
   terminar (_uttAncladas).

2. Tras un cancel(), en Windows y ChromeOS el motor puede quedar en pausa:
   speak() encola y jamás suena, sin error alguno. resume() antes de cada
   speak() lo destranca, y sobre un motor sano no hace nada.

Y porque lo intermitente no se depura mirando la pantalla, queda una bitácora:
cada intento de hablar registra voz elegida, si es local, cuánto tardó en
arrancar y cómo terminó — en localStorage.tvVozLog (últimos 200) y en la tabla
turnero_tv_log vía sendBeacon (endpoint log_tv.php, insert-only, eventos de
lista cerrada). La próxima vez que reporten "ayer a las 10 no sonó", se
consulta la tabla y se ve qué pasó exactamente.

── Servicios de RIPS que "se demoran" ──

Medido en datos, no en hipótesis: la facturación en el sistema del laboratorio
ocurre MIENTRAS la recepcionista atiende. La consulta a RIPS se hacía UNA sola
vez, al vincular al paciente — y en 7 días, 547 de 549 registros llegaron
DESPUÉS de esa única consulta. Nadie volvía a preguntar: eso es lo que se
percibía como lentitud de RIPS. (El scheduler en sí tarda segundos; de paso:
el reloj del equipo legado está ~1 hora atrasado, visible en hora_recepcion.)

Ahora la ficha sondea cada 10 s hasta 10 minutos y se detiene al encontrar,
al limpiar la ficha o al agotar los intentos. Si la recepcionista ya marcó
exámenes a mano, no se le pisan: se le ofrece el banner en vez de autocargar.

── Pruebas (22, todas contra el código real extraído de las vistas) ──

  scripts/test_tv_voz.js        9  anclaje GC, resume(), bloqueo, voz lenta, bitácora
  scripts/test_rips_sondeo.js   7  reintentos, paradas, tope, sin duplicar
  scripts/test_rips_ventana.php 6  ventana SQL en tabla TEMPORARY, sin tocar datos

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Lizandro Guarnizo
2026-08-23 11:11:40 -05:00
co-authored by Claude Fable 5
parent 8218daaff7
commit a28ae82402
7 changed files with 482 additions and 7 deletions
+103
View File
@@ -0,0 +1,103 @@
/**
* scripts/test_rips_sondeo.js — node scripts/test_rips_sondeo.js
*
* Pruebas del sondeo RIPS contra el código real de recepcion.php.
*
* El defecto que esto vigila: la consulta a RIPS se hacía UNA vez al vincular
* al paciente, y en 7 días 547 de 549 registros llegaron después de esa única
* consulta. El sondeo reintenta cada 10 s hasta 10 minutos y debe detenerse
* al encontrar, al limpiar la ficha, o al agotar los intentos.
*/
const fs = require('fs');
let fallos = 0;
const ok = (d, c) => { console.log((c ? ' ok ' : ' FALLA ') + d); if (!c) fallos++; };
const src = fs.readFileSync(__dirname + '/../modules/turnero/views/recepcion.php', 'utf8')
.replace(/<\?(php|=)[\s\S]*?\?>/g, '"PHP"');
// Se extraen exactamente las tres piezas del control de sondeo
const m = src.match(/let _ripsSondeoId[\s\S]*?function detenerSondeoRips\(\) \{[\s\S]*?\n\}/);
if (!m) { console.log(' FALLA no se encontró el bloque de sondeo'); process.exit(1); }
function armar() {
let consultas = 0;
const intervalos = new Map();
let proximoId = 1;
const setIntervalFake = (fn, ms) => { const id = proximoId++; intervalos.set(id, { fn, ms }); return id; };
const clearIntervalFake = (id) => intervalos.delete(id);
const api = new Function(
'setInterval', 'clearInterval', 'INTERVALOS', 'CONTADOR',
'let _ripsData = null;' +
'const consultarExamenesRips = (c) => { CONTADOR.n++; };' +
m[0] + `;
return {
iniciar: iniciarSondeoRips,
detener: detenerSondeoRips,
tic() { for (const v of [...INTERVALOS.values()]) v.fn(); },
activo() { return _ripsSondeoId !== null; },
encontrado() { _ripsData = { encontrados: [1] }; },
};`
)(setIntervalFake, clearIntervalFake, intervalos, (globalThis.__c = { n: 0 }));
return { api, contador: globalThis.__c, intervalos };
}
// ── 1. Consulta de inmediato y reintenta ────────────────────────────────
{
const { api, contador } = armar();
api.iniciar('12345678');
ok('consulta de inmediato al vincular', contador.n === 1);
api.tic(); api.tic(); api.tic();
ok('reintenta en cada ciclo mientras no encuentra', contador.n === 4);
}
// ── 2. Al encontrar, deja de sondear ────────────────────────────────────
{
const { api, contador } = armar();
api.iniciar('12345678');
api.tic();
api.encontrado();
api.tic(); // este ciclo detecta _ripsData y se apaga
const n = contador.n;
api.tic(); api.tic();
ok('al encontrar los exámenes se apaga y no consulta más',
contador.n === n && !api.activo());
}
// ── 3. Limpiar la ficha lo detiene ──────────────────────────────────────
{
const { api, contador } = armar();
api.iniciar('12345678');
api.detener();
const n = contador.n;
api.tic(); api.tic();
ok('limpiar la ficha detiene el sondeo en seco', contador.n === n && !api.activo());
}
// ── 4. Tope de intentos: no sondea para siempre ─────────────────────────
{
const { api, contador } = armar();
api.iniciar('12345678');
for (let i = 0; i < 80; i++) api.tic();
ok('se rinde tras ~60 intentos (10 min), no queda sondeando eternamente',
contador.n <= 61 && !api.activo());
}
// ── 5. Vincular a otro paciente reinicia sin duplicar intervalos ────────
{
const { api, contador, intervalos } = armar();
api.iniciar('11111111');
api.iniciar('22222222');
ok('re-vincular no deja dos sondeos corriendo a la vez', intervalos.size === 1);
}
// ── 6. Sin cédula no arranca ────────────────────────────────────────────
{
const { api, contador } = armar();
api.iniciar('');
ok('sin cédula no consulta ni deja intervalos', contador.n === 0 && !api.activo());
}
console.log();
console.log(fallos === 0 ? 'Todo correcto.' : fallos + ' pruebas fallaron.');
process.exit(fallos === 0 ? 0 : 1);
+84
View File
@@ -0,0 +1,84 @@
<?php
/**
* scripts/test_rips_ventana.php — php scripts/test_rips_ventana.php
*
* Prueba la VENTANA de la caché RIPS (la consulta de get_examenes_rips.php)
* contra la base real, en una tabla TEMPORARY que se esfuma al desconectar:
* ni toca ni ensucia los datos de producción.
*
* Reglas que vigila:
* - un registro de hace 5 minutos se encuentra;
* - uno de hace más de 30 minutos NO (ventana);
* - uno ya asignado a un turno NO (turno_id);
* - uno de ayer NO (solo el día actual);
* - de dos del mismo documento gana el más reciente.
*/
require_once __DIR__ . '/../config/config.php';
$fallos = 0;
function ok(string $d, bool $c): void {
global $fallos;
echo ($c ? ' ok ' : ' FALLA ') . $d . "\n";
if (!$c) $fallos++;
}
$pdo = Database::getInstance()->getConnection();
$pdo->exec("CREATE TEMPORARY TABLE tmp_rips LIKE rips_examenes_pendientes");
$ins = $pdo->prepare(
"INSERT INTO tmp_rips (numero_documento, datos, recepcion_id, hora_recepcion, created_at, turno_id)
VALUES (?, ?, ?, ?, ?, ?)"
);
// (documento, minutos hacia atrás de created_at, turno_id)
$casos = [
['1111', 5, null], // reciente y libre → debe aparecer
['2222', 45, null], // fuera de la ventana de 30 min
['3333', 5, 999], // ya usado por un turno
['4444', 1500, null], // de ayer
['5555', 20, null], // mismo doc, dos registros…
['5555', 3, null], // …gana el más reciente (marcado v2)
];
foreach ($casos as $i => [$doc, $min, $turno]) {
$ins->execute([
$doc,
json_encode([['cod_examen' => 'T' . $i, 'v' => $i === 5 ? 'v2' : 'v1']]),
1000 + $i,
'08:00:00',
date('Y-m-d H:i:s', time() - $min * 60),
$turno,
]);
}
// La MISMA consulta de get_examenes_rips.php, apuntada a la tabla temporal
$q = $pdo->prepare(
"SELECT datos FROM tmp_rips
WHERE numero_documento = ?
AND DATE(created_at) = CURDATE()
AND created_at >= NOW() - INTERVAL 30 MINUTE
AND turno_id IS NULL
ORDER BY created_at DESC
LIMIT 1"
);
$buscar = function (string $doc) use ($q) {
$q->execute([$doc]);
$r = $q->fetch(PDO::FETCH_ASSOC);
return $r ? json_decode($r['datos'], true) : null;
};
ok('un registro de hace 5 minutos se encuentra', $buscar('1111') !== null);
ok('uno de hace 45 minutos queda fuera de la ventana', $buscar('2222') === null);
ok('uno ya asignado a un turno no se reutiliza', $buscar('3333') === null);
ok('uno de ayer no aparece aunque el documento coincida', $buscar('4444') === null);
$r = $buscar('5555');
ok('con dos del mismo documento gana el más reciente', ($r[0]['v'] ?? '') === 'v2');
// El re-empuje del scheduler (DELETE + INSERT) refresca created_at, así que un
// registro viejo que el sistema legado reenvía vuelve a entrar en la ventana.
$pdo->exec("DELETE FROM tmp_rips WHERE numero_documento = '2222'");
$ins->execute(['2222', json_encode([['cod_examen' => 'RE']]), 2000, '08:00:00',
date('Y-m-d H:i:s'), null]);
ok('un re-empuje del scheduler vuelve a hacer visible el registro', $buscar('2222') !== null);
echo "\n" . ($fallos === 0 ? "Todo correcto.\n" : "$fallos pruebas fallaron.\n");
exit($fallos === 0 ? 0 : 1);
+136
View File
@@ -0,0 +1,136 @@
/**
* 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);
}
console.log();
console.log(fallos === 0 ? 'Todo correcto.' : fallos + ' pruebas fallaron.');
process.exit(fallos === 0 ? 0 : 1);