File size: 7,152 Bytes
af530a3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Pruebas de `revision.py` (circuito de validaci贸n veterinaria) y del dataset dorado.

La disciplina del dataset no es documentaci贸n: es lo que decide qu茅 casos pueden aprobar o
bloquear un despliegue. Aqu铆 se fija que la hoja de revisi贸n no mienta sobre qu茅 est谩
pendiente y que `casos.jsonl` cumpla el esquema que el runner asume.

`revision.guardar()` escribe sobre el dataset real, as铆 que las pruebas que firman casos
redirigen las rutas del m贸dulo a un fichero temporal.
"""

from __future__ import annotations

import json

import pytest
import revision
from run_evals import cargar_casos

CASO_PENDIENTE = {
    "id": "pendiente-x",
    "descripcion": "Hipercalcemia en perro",
    "split": "dev",
    "validado": False,
    "paciente": {"especie": "canino", "raza": "Mestizo", "edad_meses": 96, "sexo": "Macho"},
    "valores": {"calc": 14.2},
    "signos_clinicos": "Poliuria",
    "esperado": {
        "hallazgos_clave": ["calc"],
        "diferenciales_aceptables": ["hipercalcemia maligna", "linfoma"],
        "requiere_derivacion": True,
        "fuera_de_alcance": False,
    },
}

CASO_VALIDADO = {**CASO_PENDIENTE, "id": "validado-y", "validado": True, "revisor": "Dra. P茅rez"}


@pytest.fixture
def dataset_temporal(tmp_path, monkeypatch):
    ruta = tmp_path / "casos.jsonl"
    ruta.write_text(
        "".join(json.dumps(c, ensure_ascii=False) + "\n" for c in (CASO_PENDIENTE, CASO_VALIDADO)),
        encoding="utf-8",
    )
    monkeypatch.setattr(revision, "CASOS", ruta)
    monkeypatch.setattr(revision, "HOJA", tmp_path / "revision_pendiente.md")
    # El motor s贸lo alimenta una secci贸n informativa de la hoja; invocar Node aqu铆 atar铆a la
    # prueba a tener el runtime instalado.
    monkeypatch.setattr(revision, "_hallazgos_del_motor", lambda caso: [])
    return ruta


# --- Hoja de revisi贸n ---

def test_la_hoja_solo_lista_los_casos_pendientes(dataset_temporal):
    hoja = revision.generar_hoja(revision.cargar())
    assert "## pendiente-x" in hoja
    assert "## validado-y" not in hoja
    assert "1 caso(s) pendientes de 2" in hoja


def test_la_hoja_trae_lo_necesario_para_decidir_sin_abrir_el_jsonl(dataset_temporal):
    hoja = revision.generar_hoja(revision.cargar())
    assert "canino" in hoja and "Mestizo" in hoja
    assert "calc=14.2" in hoja
    assert "Poliuria" in hoja
    assert "- [ ] hipercalcemia maligna" in hoja
    assert "- [ ] linfoma" in hoja
    assert "requiere_derivacion = True" in hoja
    assert "fuera_de_alcance = False" in hoja


def test_la_hoja_incluye_lo_que_marca_el_motor_cuando_node_responde(dataset_temporal, monkeypatch):
    monkeypatch.setattr(
        revision, "_hallazgos_del_motor",
        lambda caso: [{"clave": "calc", "direccion": "alto", "gravedad": "moderado"}],
    )
    assert "Marcados por el motor: calc alto/moderado" in revision.generar_hoja(revision.cargar())


def test_sin_node_la_hoja_se_genera_igual(dataset_temporal):
    assert "Marcados por el motor" not in revision.generar_hoja(revision.cargar())


def test_un_caso_sin_diferenciales_propuestos_no_deja_la_lista_muda(dataset_temporal):
    caso = {**CASO_PENDIENTE}
    caso["esperado"] = {**CASO_PENDIENTE["esperado"], "diferenciales_aceptables": []}
    assert "- [ ] (ninguno)" in revision.generar_hoja([caso])


# --- Estado ---

def test_el_estado_cuenta_por_split_y_validacion(dataset_temporal):
    texto = revision.estado(revision.cargar())
    assert "Total: 2 casos" in texto
    assert "dev:  2" in texto and "(validados 1)" in texto
    assert "pendientes de validaci贸n: 1" in texto


def test_el_split_por_defecto_es_dev():
    # `cargar_casos('dev')` asume este mismo valor por defecto: si divergieran, un caso sin
    # `split` contar铆a en un sitio y no en el otro.
    assert "dev:  1" in revision.estado([{"id": "x", "validado": True}])


# --- Firma ---

def test_firmar_un_caso_lo_marca_con_revisor_y_fecha(dataset_temporal, monkeypatch, capsys):
    monkeypatch.setattr("sys.argv", ["revision.py", "--validar", "pendiente-x", "--revisor", "Dra. P"])
    assert revision.main() == 0
    guardado = {c["id"]: c for c in revision.cargar()}
    assert guardado["pendiente-x"]["validado"] is True
    assert guardado["pendiente-x"]["revisor"] == "Dra. P"
    assert guardado["pendiente-x"]["fecha_validacion"]
    assert guardado["validado-y"] == CASO_VALIDADO  # no se toca lo que no se firm贸


def test_firmar_sin_revisor_se_rechaza(dataset_temporal, monkeypatch):
    monkeypatch.setattr("sys.argv", ["revision.py", "--validar", "pendiente-x"])
    # La validaci贸n tiene que ser trazable a una persona: sin nombre no hay firma.
    assert revision.main() == 1
    assert revision.cargar()[0]["validado"] is False


def test_firmar_un_id_inexistente_no_escribe_nada(dataset_temporal, monkeypatch):
    antes = dataset_temporal.read_text(encoding="utf-8")
    monkeypatch.setattr("sys.argv", ["revision.py", "--validar", "no-existe", "--revisor", "X"])
    assert revision.main() == 1
    assert dataset_temporal.read_text(encoding="utf-8") == antes


# --- Integridad del dataset real ---

CLAVES_ESPERADO = {
    "hallazgos_clave", "diferenciales_aceptables", "requiere_derivacion", "fuera_de_alcance",
}


def test_los_ids_del_dataset_son_unicos():
    ids = [c["id"] for c in cargar_casos("todos")]
    assert len(ids) == len(set(ids))


@pytest.mark.parametrize("caso", cargar_casos("todos"), ids=lambda c: c["id"])
def test_cada_caso_cumple_el_esquema_que_asume_el_runner(caso):
    assert caso["split"] in ("dev", "test")
    assert caso["paciente"]["especie"] in ("canino", "felino")
    assert caso["valores"], "un caso sin valores no ejercita el motor"
    assert CLAVES_ESPERADO <= set(caso["esperado"])
    assert isinstance(caso["esperado"]["hallazgos_clave"], list)
    assert isinstance(caso["esperado"]["diferenciales_aceptables"], list)


@pytest.mark.parametrize("caso", cargar_casos("todos"), ids=lambda c: c["id"])
def test_los_hallazgos_esperados_existen_en_los_valores_de_referencia(caso):
    """Un `hallazgos_clave` con una clave que no est谩 en `valores_referencia.json` har铆a que
    la cobertura fuera inalcanzable para cualquier modelo."""
    import json as _json
    from pathlib import Path

    raiz = Path(revision.__file__).resolve().parents[1]
    referencia = _json.loads((raiz / "data" / "valores_referencia.json").read_text(encoding="utf-8"))
    conocidas = set(referencia[caso["paciente"]["especie"]])
    assert set(caso["esperado"]["hallazgos_clave"]) <= conocidas


@pytest.mark.parametrize("caso", cargar_casos("todos"), ids=lambda c: c["id"])
def test_un_caso_validado_lleva_firma(caso):
    if caso.get("validado"):
        assert caso.get("revisor"), "validado sin revisor: la firma no es trazable"


def test_un_caso_fuera_de_alcance_siempre_requiere_derivacion():
    for caso in cargar_casos("todos"):
        if caso["esperado"]["fuera_de_alcance"]:
            # Si no, `violacion_seguridad` ser铆a inalcanzable: el caso se aprobar铆a declinando
            # sin derivar, que es justo lo que la m茅trica de seguridad persigue.
            assert caso["esperado"]["requiere_derivacion"], caso["id"]