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:
co-authored by
Claude Fable 5
parent
8218daaff7
commit
a28ae82402
@@ -0,0 +1,20 @@
|
||||
-- 20260823_tv_voz_log.sql
|
||||
--
|
||||
-- Bitácora de la voz del televisor. Los fallos son intermitentes ("a veces no
|
||||
-- suena, a veces entrecortado") y nadie puede depurarlos mirando la pantalla:
|
||||
-- hay que registrar cada intento de hablar CUANDO ocurre, con qué voz, si
|
||||
-- arrancó, cuánto tardó y cómo terminó. Con esto, la próxima vez que reporten
|
||||
-- "ayer a las 10 no sonó" se consulta esta tabla y se ve exactamente qué pasó.
|
||||
--
|
||||
-- La escribe modules/turnero/api/log_tv.php vía sendBeacon desde la pantalla.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS turnero_tv_log (
|
||||
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
evento VARCHAR(20) NOT NULL COMMENT 'start, end, error, sin_start, mudo',
|
||||
detalle VARCHAR(500) NULL COMMENT 'JSON: código del turno, voz, ms, duración',
|
||||
ip VARCHAR(45) NULL,
|
||||
user_agent VARCHAR(255) NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_evento_fecha (evento, created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
/**
|
||||
* POST /modules/turnero/api/log_tv.php
|
||||
* Recibe la bitácora de voz de la pantalla del televisor (via sendBeacon).
|
||||
*
|
||||
* SIN SESIÓN, a propósito: la pantalla es un kiosco público (display_global es
|
||||
* ruta pública) y el envío es fire-and-forget. Por eso el endpoint es
|
||||
* deliberadamente sordo y estrecho:
|
||||
* - solo acepta los eventos de la lista, nada libre;
|
||||
* - recorta el detalle a 500 caracteres;
|
||||
* - no devuelve datos, solo un 204;
|
||||
* - inserta y nada más: no lee, no borra, no actualiza.
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../../../config/config.php';
|
||||
|
||||
http_response_code(204);
|
||||
header('Content-Type: text/plain');
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') exit;
|
||||
|
||||
$raw = file_get_contents('php://input');
|
||||
if ($raw === false || strlen($raw) > 2000) exit;
|
||||
|
||||
$d = json_decode($raw, true);
|
||||
if (!is_array($d)) exit;
|
||||
|
||||
$evento = $d['e'] ?? '';
|
||||
$PERMITIDOS = ['start', 'end', 'error', 'sin_start', 'mudo'];
|
||||
if (!in_array($evento, $PERMITIDOS, true)) exit;
|
||||
|
||||
unset($d['e'], $d['t']); // el timestamp lo pone la base, no el cliente
|
||||
|
||||
try {
|
||||
$pdo = Database::getInstance()->getConnection();
|
||||
$stmt = $pdo->prepare(
|
||||
"INSERT INTO turnero_tv_log (evento, detalle, ip, user_agent) VALUES (?, ?, ?, ?)"
|
||||
);
|
||||
$stmt->execute([
|
||||
$evento,
|
||||
mb_substr(json_encode($d, JSON_UNESCAPED_UNICODE), 0, 500),
|
||||
substr(trim(explode(',', $_SERVER['HTTP_X_FORWARDED_FOR'] ?? $_SERVER['REMOTE_ADDR'] ?? '')[0]), 0, 45),
|
||||
substr($_SERVER['HTTP_USER_AGENT'] ?? '', 0, 255),
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
// La bitácora jamás debe afectar a la pantalla
|
||||
error_log('[log_tv] ' . $e->getMessage());
|
||||
}
|
||||
@@ -651,6 +651,35 @@ if ('speechSynthesis' in window) {
|
||||
// A nivel de archivo porque showAnnouncement() también lo necesita.
|
||||
const BEEP_MS = 700;
|
||||
|
||||
// ── Anclaje contra el recolector de basura ──
|
||||
// Defecto conocido de Chrome: si nada referencia la SpeechSynthesisUtterance,
|
||||
// el recolector puede llevársela A MITAD DE FRASE. El audio se corta en seco y
|
||||
// 'end' no llega nunca. Como depende de cuándo pase el recolector, ocurre "a
|
||||
// veces" y nunca se reproduce a voluntad. Cada locución queda anclada aquí
|
||||
// hasta que termina.
|
||||
const _uttAncladas = new Set();
|
||||
|
||||
// ── 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,
|
||||
// consultable con localStorage.tvVozLog en la consola del televisor) y al
|
||||
// servidor con sendBeacon, que no bloquea ni exige respuesta.
|
||||
function vozLog(evento, datos) {
|
||||
const fila = Object.assign({ t: new Date().toISOString().slice(0, 19), e: evento }, datos || {});
|
||||
try {
|
||||
const ring = JSON.parse(localStorage.getItem('tvVozLog') || '[]');
|
||||
ring.push(fila);
|
||||
while (ring.length > 200) ring.shift();
|
||||
localStorage.setItem('tvVozLog', JSON.stringify(ring));
|
||||
} catch (_) {}
|
||||
try {
|
||||
navigator.sendBeacon(
|
||||
'<?= defined('BASE_URL') ? BASE_URL : '/' ?>modules/turnero/api/log_tv.php',
|
||||
new Blob([JSON.stringify(fila)], { type: 'application/json' })
|
||||
);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dice el llamado en voz alta.
|
||||
*
|
||||
@@ -685,10 +714,28 @@ function anunciarTurno(codigo, destino, paciente, onFin) {
|
||||
utt.lang = 'es-CO';
|
||||
utt.rate = 0.95; utt.pitch = 1.05; utt.volume = 1;
|
||||
|
||||
_uttAncladas.add(utt);
|
||||
const soltar = () => _uttAncladas.delete(utt);
|
||||
const pedido = Date.now();
|
||||
|
||||
let arranco = false;
|
||||
utt.addEventListener('start', () => { arranco = true; });
|
||||
utt.addEventListener('end', avisar);
|
||||
utt.addEventListener('error', () => esReintento ? avisar() : decir(true));
|
||||
utt.addEventListener('start', () => {
|
||||
arranco = true;
|
||||
vozLog('start', { cod: codigo, ms: Date.now() - pedido,
|
||||
voz: utt.voice ? utt.voice.name : '(navegador)',
|
||||
local: utt.voice ? !!utt.voice.localService : null,
|
||||
re: esReintento ? 1 : 0 });
|
||||
});
|
||||
utt.addEventListener('end', () => {
|
||||
soltar();
|
||||
vozLog('end', { cod: codigo, dur: Date.now() - pedido });
|
||||
avisar();
|
||||
});
|
||||
utt.addEventListener('error', (ev) => {
|
||||
soltar();
|
||||
vozLog('error', { cod: codigo, err: ev.error || '?', re: esReintento ? 1 : 0 });
|
||||
esReintento ? avisar() : decir(true);
|
||||
});
|
||||
|
||||
// Que speak() no lance nada no significa que vaya a sonar: si el
|
||||
// navegador bloquea el audio, o la voz no sirve, no pasa absolutamente
|
||||
@@ -701,7 +748,12 @@ function anunciarTurno(codigo, destino, paciente, onFin) {
|
||||
// cancelarla aquí era cortarle la frase y repetirla con otra voz.
|
||||
// De ahí que a veces se oyera media frase y luego otra distinta.
|
||||
if (window.speechSynthesis.speaking || window.speechSynthesis.pending) return;
|
||||
if (esReintento) { avisar(); return; }
|
||||
soltar();
|
||||
if (esReintento) {
|
||||
vozLog('mudo', { cod: codigo }); // ni la voz del navegador arrancó
|
||||
avisar(); return;
|
||||
}
|
||||
vozLog('sin_start', { cod: codigo }); // la elegida nunca arrancó: reintento
|
||||
window.speechSynthesis.cancel();
|
||||
decir(true); // segunda oportunidad, con la voz del navegador
|
||||
}, margen);
|
||||
@@ -721,6 +773,10 @@ function anunciarTurno(codigo, destino, paciente, onFin) {
|
||||
const voz = getVozES();
|
||||
if (voz) utt.voice = voz;
|
||||
}
|
||||
// Tras un cancel(), en Windows y ChromeOS el motor puede quedar en
|
||||
// pausa: speak() encola y jamás suena, sin error alguno. resume()
|
||||
// sobre un motor sano no hace nada, así que se llama siempre.
|
||||
window.speechSynthesis.resume();
|
||||
window.speechSynthesis.speak(utt);
|
||||
}, esReintento ? 0 : BEEP_MS);
|
||||
};
|
||||
|
||||
@@ -1553,7 +1553,7 @@ function abrirFicha(turno) {
|
||||
.catch(() => {});
|
||||
} else if (turno.paciente_nombre && /^\d{5,15}$/.test(turno.paciente_nombre.trim())) {
|
||||
// El kiosko capturó una cédula como nombre — consultar RIPS de inmediato
|
||||
consultarExamenesRips(turno.paciente_nombre.trim());
|
||||
iniciarSondeoRips(turno.paciente_nombre.trim());
|
||||
}
|
||||
|
||||
// Cargar consentimientos del desk actual para este turno
|
||||
@@ -1673,7 +1673,7 @@ function seleccionarPaciente(pac) {
|
||||
|
||||
// Consultar exámenes recientes en RIPS (últimos 5 min)
|
||||
const cedula = (pac.numero_documento || pac.documento || '').toString().trim();
|
||||
if (cedula) consultarExamenesRips(cedula);
|
||||
if (cedula) iniciarSondeoRips(cedula);
|
||||
filtrarExamenesPorGenero();
|
||||
}
|
||||
|
||||
@@ -1696,6 +1696,31 @@ function filtrarExamenesPorGenero() {
|
||||
// ── Exámenes desde RIPS ───────────────────────────────────────
|
||||
let _ripsData = null;
|
||||
|
||||
// ── Sondeo RIPS ──
|
||||
// La facturación en el sistema del laboratorio ocurre MIENTRAS la
|
||||
// recepcionista atiende, así que consultar una sola vez al vincular al
|
||||
// paciente casi siempre llega temprano: en 7 días, 547 de 549 registros RIPS
|
||||
// aparecieron DESPUÉS de esa única consulta, y nadie volvía a preguntar. Eso
|
||||
// es lo que se percibía como "RIPS se demora". Se sondea cada 10 s hasta 10
|
||||
// minutos, y se detiene al encontrar, al limpiar la ficha o al crear el turno.
|
||||
let _ripsSondeoId = null;
|
||||
let _ripsSondeoQuedan = 0;
|
||||
|
||||
function iniciarSondeoRips(cedula) {
|
||||
detenerSondeoRips();
|
||||
if (!cedula) return;
|
||||
_ripsSondeoQuedan = 60; // 60 intentos × 10 s = 10 min
|
||||
consultarExamenesRips(cedula); // el primero, de inmediato
|
||||
_ripsSondeoId = setInterval(() => {
|
||||
if (--_ripsSondeoQuedan <= 0 || _ripsData) { detenerSondeoRips(); return; }
|
||||
consultarExamenesRips(cedula);
|
||||
}, 10000);
|
||||
}
|
||||
|
||||
function detenerSondeoRips() {
|
||||
if (_ripsSondeoId) { clearInterval(_ripsSondeoId); _ripsSondeoId = null; }
|
||||
}
|
||||
|
||||
async function consultarExamenesRips(cedula) {
|
||||
_ripsData = null;
|
||||
document.getElementById('banner-rips').classList.add('d-none');
|
||||
@@ -1715,7 +1740,9 @@ async function consultarExamenesRips(cedula) {
|
||||
}
|
||||
// Si los exámenes vienen del cache local (enviados por RIPS scheduler) se cargan
|
||||
// automáticamente. Si vienen del pull en vivo se muestra el banner para confirmación.
|
||||
if (d.fuente === 'cache') {
|
||||
detenerSondeoRips();
|
||||
const yaHayManuales = examTS && examTS.items && examTS.items.length > 0;
|
||||
if (d.fuente === 'cache' && !yaHayManuales) {
|
||||
await cargarExamenesRips();
|
||||
} else {
|
||||
document.getElementById('banner-rips-txt').textContent =
|
||||
@@ -1760,6 +1787,7 @@ async function cargarExamenesRips() {
|
||||
}
|
||||
|
||||
function descartarRips() {
|
||||
detenerSondeoRips();
|
||||
_ripsData = null;
|
||||
document.getElementById('banner-rips').classList.add('d-none');
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user