Spaces:
Sleeping
Sleeping
File size: 19,479 Bytes
70e641d 5b69117 70e641d 5b69117 70e641d 5b69117 70e641d 5b69117 70e641d 5b69117 70e641d 5b69117 70e641d 5b69117 70e641d 5b69117 70e641d 5b69117 70e641d 5b69117 70e641d 5b69117 70e641d 5b69117 70e641d bf9f7d1 5b69117 70e641d bf9f7d1 5b69117 bf9f7d1 5b69117 bf9f7d1 70e641d 5b69117 70e641d 5b69117 70e641d 5b69117 70e641d bf9f7d1 5b69117 bf9f7d1 5b69117 bf9f7d1 5b69117 bf9f7d1 5b69117 bf9f7d1 5b69117 bf9f7d1 5b69117 bf9f7d1 5b69117 e6acd07 5b69117 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 | // Suite de regresión del motor clínico. Fija el comportamiento actual de analizarResultados
// para que la migración (y cualquier cambio futuro) no altere la lógica validada por el veterinario.
// Carga los datos reales de data/*.json como única fuente de verdad.
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, resolve } from 'node:path';
import { describe, expect, it } from 'vitest';
import { analizarResultados, _internos } from '../src/analisis.js';
import type { AjustesClinicos, Alteraciones, Paciente, Referencias } from '../src/tipos.js';
const aquí = dirname(fileURLToPath(import.meta.url));
const raízDatos = resolve(aquí, '../../data');
const referencias = JSON.parse(
readFileSync(resolve(raízDatos, 'valores_referencia.json'), 'utf8'),
) as Referencias;
const alteraciones = JSON.parse(
readFileSync(resolve(raízDatos, 'alteraciones.json'), 'utf8'),
) as Alteraciones;
const ajustes = JSON.parse(
readFileSync(resolve(raízDatos, 'ajustes_clinicos.json'), 'utf8'),
) as AjustesClinicos;
const paciente = (over: Partial<Paciente> = {}): Paciente => ({
especie: 'canino',
raza: null,
edadMeses: null,
sexo: null,
...over,
});
const nombresPatrones = (valores: Record<string, number>, p = paciente()): string[] =>
analizarResultados(valores, p, referencias, alteraciones, ajustes).patrones.map((x) => x.nombre);
describe('datos de referencia', () => {
it('cubre ambas especies', () => {
expect(referencias.canino).toBeDefined();
expect(referencias.felino).toBeDefined();
});
it('canino define 90 analitos', () => {
expect(Object.keys(referencias.canino).length).toBe(90);
});
});
describe('guardas de entrada', () => {
it('sin especie devuelve vacío', () => {
const r = analizarResultados({ wbc: 30 }, paciente({ especie: null }), referencias, alteraciones, ajustes);
expect(r).toEqual({ hallazgos: [], patrones: [] });
});
it('ignora valores vacíos, null y no numéricos', () => {
const r = analizarResultados(
{ wbc: '', hct: null, rbc: 'abc' as unknown as number },
paciente(),
referencias,
alteraciones,
ajustes,
);
expect(r.hallazgos).toHaveLength(0);
});
it('un valor dentro de rango no genera hallazgos', () => {
// WBC canino ref 6-17 → 10 está dentro
const r = analizarResultados({ wbc: 10 }, paciente(), referencias, alteraciones, ajustes);
expect(r.hallazgos).toHaveLength(0);
expect(r.patrones).toHaveLength(0);
});
});
describe('clasificación de gravedad', () => {
const ref = { inferior: 6, superior: 17, unidad: 'x10³/μL', nombre: 'WBC' };
it('desviación ≤ 0.5 anchos → leve', () => {
// ancho 11; 17 + 5 = 22 → 5/11 ≈ 0.45
expect(_internos.clasificarGravedad(22, ref, ajustes)).toBe('leve');
});
it('desviación ≤ 1.5 anchos → moderado', () => {
// 17 + 11 = 28 → 11/11 = 1.0
expect(_internos.clasificarGravedad(28, ref, ajustes)).toBe('moderado');
});
it('desviación > 1.5 anchos → grave', () => {
// 17 + 22 = 39 → 22/11 = 2.0
expect(_internos.clasificarGravedad(39, ref, ajustes)).toBe('grave');
});
it('aplica igual por debajo del límite inferior', () => {
// 6 - 6 = 0 → 6/11 ≈ 0.55 → moderado
expect(_internos.clasificarGravedad(0, ref, ajustes)).toBe('moderado');
});
});
describe('ajustes por edad', () => {
it('cachorro canino eleva el techo de FAL (x3)', () => {
// FAL canino base: superior típico ~150; con cachorro x3 no se marca a valores moderados
const base = referencias.canino.fal.superior;
const adulto = analizarResultados({ fal: base * 2 }, paciente({ edadMeses: 6 }), referencias, alteraciones, ajustes);
// A los 6 meses el techo es x3, así que base*2 queda dentro de rango → sin patrón colestásico
expect(adulto.patrones.map((p) => p.nombre)).not.toContain(alteraciones.patron_colestasico.nombre);
});
it('geriátrico canino eleva el techo de creatinina', () => {
const sup = referencias.canino.creat.superior;
// Valor justo por encima del techo adulto pero dentro del geriátrico (x1.25)
const valor = sup * 1.2;
const adulto = analizarResultados({ creat: valor }, paciente({ edadMeses: 60 }), referencias, alteraciones, ajustes);
const geriatrico = analizarResultados({ creat: valor }, paciente({ edadMeses: 130 }), referencias, alteraciones, ajustes);
expect(adulto.hallazgos.some((h) => h.clave === 'creat')).toBe(true);
expect(geriatrico.hallazgos.some((h) => h.clave === 'creat')).toBe(false);
});
});
describe('ajustes por raza', () => {
it('el galgo tolera hematocrito más alto sin marcar eritrocitosis', () => {
const sup = referencias.canino.hct.superior;
const valor = sup * 1.1; // dentro del +12% del galgo, fuera para un mestizo
const mestizo = nombresPatrones({ hct: valor });
const galgo = nombresPatrones({ hct: valor }, paciente({ raza: 'Galgo Español' }));
expect(mestizo).toContain(alteraciones.eritrocitosis.nombre);
expect(galgo).not.toContain(alteraciones.eritrocitosis.nombre);
});
it('el galgo tiene menor umbral de trombocitopenia (plaquetas normales más bajas)', () => {
const inf = referencias.canino.plt.inferior;
const valor = inf * 0.8; // bajo para mestizo; el galgo baja el piso a x0.75
const mestizo = nombresPatrones({ plt: valor });
const galgo = nombresPatrones({ plt: valor }, paciente({ raza: 'greyhound' }));
expect(mestizo).toContain(alteraciones.trombocitopenia.nombre);
expect(galgo).not.toContain(alteraciones.trombocitopenia.nombre);
});
});
describe('ajustes por sexo', () => {
// Antes el gato MACHO toleraba un 15% más de creatinina. Se retiró en la auditoría del
// 2026-07-31: no aparece en ninguna de las dos obras del corpus, y toleraba azotemia justo
// en la población con más ERC. Lo documentado (masa muscular en lebreles, raza Birman) es
// ajuste de raza, no de sexo.
it('el sexo ya no altera el umbral de creatinina en el gato', () => {
const valor = referencias.felino.creat.superior * 1.1;
const hembra = analizarResultados({ creat: valor }, paciente({ especie: 'felino', sexo: 'Hembra' }), referencias, alteraciones, ajustes);
const macho = analizarResultados({ creat: valor }, paciente({ especie: 'felino', sexo: 'Macho' }), referencias, alteraciones, ajustes);
expect(hembra.hallazgos.some((h) => h.clave === 'creat')).toBe(true);
expect(macho.hallazgos.some((h) => h.clave === 'creat')).toBe(true);
});
it('el gato Birman sí tolera mayor creatinina (Fundamentals p. 585)', () => {
const valor = referencias.felino.creat.superior * 1.1;
const comun = analizarResultados({ creat: valor }, paciente({ especie: 'felino', raza: 'Común Europeo' }), referencias, alteraciones, ajustes);
const birman = analizarResultados({ creat: valor }, paciente({ especie: 'felino', raza: 'Birman' }), referencias, alteraciones, ajustes);
expect(comun.hallazgos.some((h) => h.clave === 'creat')).toBe(true);
expect(birman.hallazgos.some((h) => h.clave === 'creat')).toBe(false);
});
});
describe('cortes clínicos de gravedad (auditoría 2026-07-31)', () => {
const gravedadDe = (valores: Record<string, number>, p = paciente(), clave = 'hct') =>
analizarResultados(valores, p, referencias, alteraciones, ajustes)
.hallazgos.find((h) => h.clave === clave)?.gravedad;
it('una anemia felina puede ser grave (antes era imposible por construcción)', () => {
// Con la regla genérica de anchos de rango, 'grave' en un gato exigía un Hct NEGATIVO.
expect(gravedadDe({ hct: 12 }, paciente({ especie: 'felino' }))).toBe('grave');
expect(gravedadDe({ hct: 16 }, paciente({ especie: 'felino' }))).toBe('moderado');
expect(gravedadDe({ hct: 22 }, paciente({ especie: 'felino' }))).toBe('leve');
});
it('el Hct canino se gradúa por el umbral de transfusión (PCV ≤ 20%)', () => {
expect(gravedadDe({ hct: 18 })).toBe('grave');
expect(gravedadDe({ hct: 25 })).toBe('moderado');
expect(gravedadDe({ hct: 33 })).toBe('leve');
});
it('la trombocitopenia es grave por debajo del umbral hemorrágico (30x10³/µL)', () => {
expect(gravedadDe({ plt: 25 }, paciente(), 'plt')).toBe('grave');
expect(gravedadDe({ plt: 60 }, paciente(), 'plt')).toBe('moderado');
expect(gravedadDe({ plt: 150 }, paciente(), 'plt')).toBe('leve');
});
it('el lado alto sigue con la regla genérica de anchos de rango', () => {
// No hay umbrales publicados para eritrocitosis en el corpus: no se inventan.
expect(gravedadDe({ hct: 60 })).toBe('leve');
});
});
describe('patrones — serie roja', () => {
it('anemia microcítica cuando HCT y VCM bajos', () => {
const patrones = analizarResultados(
{ hct: 25, vcm: 55 },
paciente(),
referencias,
alteraciones,
ajustes,
).patrones;
const anemia = patrones.find((p) => p.nombre.startsWith('Anemia'));
expect(anemia?.nombre).toBe('Anemia microcítica');
expect(anemia?.descripcion).toContain(alteraciones.anemia.prefijo);
});
it('anemia macrocítica cuando HCT bajo y VCM alto', () => {
expect(nombresPatrones({ hct: 25, vcm: 85 })).toContain('Anemia macrocítica');
});
it('eritrocitosis cuando HCT alto', () => {
expect(nombresPatrones({ hct: 70 })).toContain(alteraciones.eritrocitosis.nombre);
});
});
describe('patrones — serie blanca', () => {
it('leucocitosis neutrofílica cuando WBC y neutrófilos altos', () => {
expect(nombresPatrones({ wbc: 30, neutro: 90 })).toContain(alteraciones.leucocitosis_neutrofilica.nombre);
});
it('leucocitosis genérica cuando sólo WBC alto', () => {
expect(nombresPatrones({ wbc: 30 })).toContain(alteraciones.leucocitosis.nombre);
});
it('leucopenia cuando WBC bajo', () => {
expect(nombresPatrones({ wbc: 2 })).toContain(alteraciones.leucopenia.nombre);
});
});
describe('patrones — riñón e hígado', () => {
it('azotemia cuando BUN y creatinina altos', () => {
expect(nombresPatrones({ bun: 80, creat: 5 })).toContain(alteraciones.azotemia.nombre);
});
it('daño hepatocelular cuando ALT y AST altos', () => {
expect(nombresPatrones({ alt: 300, ast: 300 })).toContain(alteraciones.dano_hepatocelular.nombre);
});
it('ALT aislada cuando sólo ALT alta', () => {
expect(nombresPatrones({ alt: 300 })).toContain(alteraciones.alt_aislada.nombre);
});
});
describe('patrones — electrolitos y ácido-base', () => {
it('ratio Na/K bajo dispara sospecha de hipoadrenocorticismo con gravedad escalada', () => {
const patrones = analizarResultados({ sodio: 130, potasio: 7 }, paciente(), referencias, alteraciones, ajustes).patrones;
const ratio = patrones.find((p) => p.nombre === alteraciones.ratio_nak.nombre);
expect(ratio).toBeDefined();
// 130/7 ≈ 18.6 < 20 → grave
expect(ratio?.gravedad).toBe('grave');
expect(ratio?.descripcion).toContain('18.6');
});
it('acidosis mixta (respiratoria + metabólica) cuando pH bajo, pCO2 alto y HCO3 bajo', () => {
const nombres = nombresPatrones({ ph_sangre: 7.1, pco2: 60, hco3: 12 });
expect(nombres.some((n) => n.includes(alteraciones.acidosis_metabolica.nombre) && n.includes(alteraciones.acidosis_respiratoria.nombre))).toBe(true);
});
});
describe('patrones — coagulación', () => {
it('CID cuando D-dímeros altos y fibrinógeno bajo (gravedad grave)', () => {
const patrones = analizarResultados({ ddimeros: 5000, fibrinogeno: 50 }, paciente(), referencias, alteraciones, ajustes).patrones;
const cid = patrones.find((p) => p.nombre === alteraciones.cid.nombre);
expect(cid?.gravedad).toBe('grave');
});
it('coagulopatía mixta cuando PT y aPTT altos', () => {
expect(nombresPatrones({ pt: 30, aptt: 120 })).toContain(alteraciones.coagulopatia_mixta.nombre);
});
});
describe('ajustes por raza (auditoría 2026-07-31)', () => {
it('las razas asiáticas tienen VCM fisiológicamente bajo, no serie roja alta', () => {
// Fundamentals p. 221: "some healthy Akitas, shibas, Jindos, chow-chows, and shar-peis
// have lower MCV". Antes se les subía RBC/Hct/Hgb, que no es lo que dice la literatura.
const valor = referencias.canino.vcm.inferior * 0.95;
const mestizo = analizarResultados({ vcm: valor }, paciente(), referencias, alteraciones, ajustes);
const akita = analizarResultados({ vcm: valor }, paciente({ raza: 'Akita Inu' }), referencias, alteraciones, ajustes);
expect(mestizo.hallazgos.some((h) => h.clave === 'vcm')).toBe(true);
expect(akita.hallazgos.some((h) => h.clave === 'vcm')).toBe(false);
});
it('el shiba acumula microcitosis y plaquetas bajas (dos grupos de raza a la vez)', () => {
const plt = referencias.canino.plt.inferior * 0.8;
const mestizo = nombresPatrones({ plt });
const shiba = nombresPatrones({ plt }, paciente({ raza: 'Shiba Inu' }));
expect(mestizo).toContain(alteraciones.trombocitopenia.nombre);
expect(shiba).not.toContain(alteraciones.trombocitopenia.nombre);
});
it('el galgo sano no sale hipotiroideo', () => {
// Fundamentals p. 1065: ~90% de los galgos sanos por debajo del límite inferior de tT4.
const valor = referencias.canino.t4_total.inferior * 0.7;
const mestizo = analizarResultados({ t4_total: valor }, paciente(), referencias, alteraciones, ajustes);
const galgo = analizarResultados({ t4_total: valor }, paciente({ raza: 'Galgo español' }), referencias, alteraciones, ajustes);
expect(mestizo.hallazgos.some((h) => h.clave === 't4_total')).toBe(true);
expect(galgo.hallazgos.some((h) => h.clave === 't4_total')).toBe(false);
});
});
describe('ajustes por edad (auditoría 2026-07-31)', () => {
it('el cachorro no sale hiperfosforémico con un fósforo propio de su edad', () => {
// Fundamentals p. 831: cachorros < 12 sem 5,7–10,8 mg/dL frente a 2,5–5,5 del adulto.
const valor = referencias.canino.fosf.superior * 1.5;
const adulto = analizarResultados({ fosf: valor }, paciente({ edadMeses: 48 }), referencias, alteraciones, ajustes);
const cachorro = analizarResultados({ fosf: valor }, paciente({ edadMeses: 3 }), referencias, alteraciones, ajustes);
expect(adulto.hallazgos.some((h) => h.clave === 'fosf')).toBe(true);
expect(cachorro.hallazgos.some((h) => h.clave === 'fosf')).toBe(false);
});
});
describe('correlación calcio-fósforo (auditoría 2026-07-31)', () => {
const descripcionHipercalcemia = (valores: Record<string, number>) =>
analizarResultados(valores, paciente(), referencias, alteraciones, ajustes)
.patrones.find((p) => p.nombre === alteraciones.hipercalcemia.nombre)?.descripcion ?? '';
it('con fósforo bajo prioriza malignidad e hiperparatiroidismo primario', () => {
const d = descripcionHipercalcemia({ calc: referencias.canino.calc.superior * 1.4, fosf: 1.5 });
// La entidad explica la regla; el patrón añade la conclusión APLICADA a este paciente.
expect(d).toContain('Con el fósforo bajo, los dos diferenciales probables');
expect(d).not.toContain('Con el fósforo alto');
});
it('con fósforo alto prioriza fallo renal y las causas que cursan con fósforo alto', () => {
const d = descripcionHipercalcemia({ calc: referencias.canino.calc.superior * 1.4, fosf: 12 });
expect(d).toContain('Con el fósforo alto, priorizar fallo renal');
expect(d).not.toContain('Con el fósforo bajo');
});
});
describe('estadiaje IRIS de ERC (guía IRIS 2026 + Fundamentals Tabla 8.4)', () => {
const patronIris = (valores: Record<string, number>, p = paciente()) =>
analizarResultados(valores, p, referencias, alteraciones, ajustes)
.patrones.find((x) => x.nombre.startsWith(alteraciones.erc_iris.nombre));
it('estadia por creatinina y escala la gravedad con el estadio', () => {
// Gato: estadio 2 = 1,6–2,8 · estadio 3 = 2,9–5,0 · estadio 4 = > 5,0 mg/dL
const felino = paciente({ especie: 'felino' });
expect(patronIris({ creat: 2.5 }, felino)?.gravedad).toBe('leve');
expect(patronIris({ creat: 4.8 }, felino)?.nombre).toContain('estadio 3');
expect(patronIris({ creat: 6.0 }, felino)?.gravedad).toBe('grave');
});
it('una creatinina dentro de rango no genera estadio', () => {
// La guía estadia pacientes YA diagnosticados: sin hallazgo no se inventa un estadio.
expect(patronIris({ creat: 1.2 })).toBeUndefined();
});
it('la SDMA discrepante sube de estadio y lo deja dicho', () => {
// Perro en estadio 2 por creatinina (1,4–2,8) con SDMA > 35 → tratar como estadio 3.
const p = patronIris({ creat: 2.0, sdma: 40 });
expect(p?.nombre).toContain('estadio 3');
expect(p?.descripcion).toContain('SDMA discrepante');
});
it('subestadia la proteinuria con los cortes de cada especie', () => {
// Perro: proteinúrico > 0,5 · limítrofe 0,2–0,5 || Gato: proteinúrico > 0,4
expect(patronIris({ creat: 3.0, upc: 0.8 })?.nombre).toContain('proteinúrico');
expect(patronIris({ creat: 3.0, upc: 0.3 })?.nombre).toContain('limítrofe');
expect(patronIris({ creat: 3.0, upc: 0.45 }, paciente({ especie: 'felino' }))?.nombre)
.toContain('proteinúrico');
});
it('la gravedad de la creatinina sigue los estadios IRIS, no el ancho de rango', () => {
const grav = (v: number, p = paciente()) =>
analizarResultados({ creat: v }, p, referencias, alteraciones, ajustes)
.hallazgos.find((h) => h.clave === 'creat')?.gravedad;
expect(grav(2.0)).toBe('leve');
expect(grav(4.0)).toBe('moderado');
expect(grav(6.0)).toBe('grave');
});
});
describe('las reglas clínicas son datos, no código', () => {
// Simétrico de test_gravedad_servidor.py: si alguien volviera a incrustar los umbrales en el
// código, estos dos seguirían dando el veredicto viejo y fallarían.
const conAjustes = (mutar: (a: AjustesClinicos) => void): AjustesClinicos => {
const copia = JSON.parse(JSON.stringify(ajustes)) as AjustesClinicos;
mutar(copia);
return copia;
};
it('los cortes de gravedad salen del JSON', () => {
const relajado = conAjustes((a) => {
a.cortes_gravedad_bajo.hct.canino = { leve_hasta: 5, moderado_hasta: 1 };
});
const r = analizarResultados({ hct: 12 }, paciente(), referencias, alteraciones, relajado);
expect(r.hallazgos.find((h) => h.clave === 'hct')?.gravedad).toBe('leve');
});
it('los cortes IRIS salen del JSON', () => {
// Se desplaza el estadio 2 canino de 1,4 a 4,0: una creatinina de 2,0 deja de ser estadio 2.
const desplazado = conAjustes((a) => {
a.iris.cortes_creatinina.canino = [4.0, 6.0, 8.0];
});
const p = analizarResultados({ creat: 2.0 }, paciente(), referencias, alteraciones, desplazado)
.patrones.find((x) => x.nombre.startsWith(alteraciones.erc_iris.nombre));
// Estadio 1 con los cortes nuevos → no se informa estadio.
expect(p).toBeUndefined();
});
it('los límites de edad salen del JSON', () => {
const adelantado = conAjustes((a) => {
a.limites_edad_meses.canino!.cachorro = 1;
});
const cachorro = paciente({ edadMeses: 4 });
// Con el límite en 1 mes ya es adulto y pierde el ajuste de fósforo del animal en crecimiento.
const r = analizarResultados({ fosf: 8 }, cachorro, referencias, alteraciones, adelantado);
expect(r.hallazgos.map((h) => h.clave)).toContain('fosf');
});
});
|