morphos / frontend /tests /analisis.test.ts
Jose Salazar
Los cortes IRIS también pasan a data/ajustes_clinicos.json
e6acd07
Raw
History Blame Contribute Delete
19.5 kB
// 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');
});
});