Merge branch 'feat/test-coverage'

This commit is contained in:
dom
2026-03-08 12:44:27 +01:00
5 changed files with 1455 additions and 0 deletions

View File

@@ -629,4 +629,21 @@ def create_app() -> Flask:
metrics=metrics, total_selection=len(selection), metrics=metrics, total_selection=len(selection),
groups=groups) groups=groups)
# --- Health check endpoint ---
@app.route("/health")
def health():
from datetime import datetime, timezone
ollama_ok = False
try:
r = requests.get(f"{OLLAMA_URL}/api/tags", timeout=2)
ollama_ok = r.status_code == 200
except Exception:
pass
return jsonify({
"status": "ok",
"version": "2.1.0",
"ollama": ollama_ok,
"timestamp": datetime.now(timezone.utc).isoformat(),
})
return app return app

View File

@@ -0,0 +1,493 @@
"""Tests étendus pour src/quality/decision_engine.py.
Couvre les règles non couvertes dans test_decision_engine.py :
- Nettoyage hiérarchique (.9 vs spécifique)
- D50 sans preuve martiale → downgrade D64.9
- D69.6 thrombopénie vs plaquettes normales
- Bio rules génériques (YAML-driven)
- Fonctions internes (_norm, _first_float, _parse_normal_range, etc.)
- decision_summaries (DOWNGRADE, REMOVE, RULED_OUT, NEED_INFO)
"""
from __future__ import annotations
from unittest.mock import patch
import pytest
from src.config import (
BiologieCle,
CodeDecision,
Diagnostic,
DossierMedical,
PreuveClinique,
Sejour,
Traitement,
VetoReport,
VetoIssue,
)
from src.quality.decision_engine import (
_norm,
_first_float,
_parse_normal_range,
_parse_float,
_is_sodium_test,
_is_potassium_test,
_das_promotion_score,
_age_band,
apply_decisions,
decision_summaries,
)
# ── Helpers ────────────────────────────────────────────────────────────
def _dossier(
dp_code: str | None = None,
das: list[Diagnostic] | None = None,
bio: list[BiologieCle] | None = None,
traitements: list[Traitement] | None = None,
age: int | None = None,
sexe: str | None = None,
) -> DossierMedical:
dp = None
if dp_code:
dp = Diagnostic(texte="DP", cim10_suggestion=dp_code)
return DossierMedical(
sejour=Sejour(age=age, sexe=sexe),
diagnostic_principal=dp,
diagnostics_associes=das or [],
biologie_cle=bio or [],
traitements_sortie=traitements or [],
)
def _diag(
texte: str,
code: str,
confidence: str = "high",
source: str = "trackare",
status: str | None = None,
source_excerpt: str | None = None,
preuves: list[PreuveClinique] | None = None,
) -> Diagnostic:
return Diagnostic(
texte=texte,
cim10_suggestion=code,
cim10_confidence=confidence,
source=source,
status=status,
source_excerpt=source_excerpt,
preuves_cliniques=preuves or [],
)
def _bio(test: str, valeur: str | None = None, valeur_num: float | None = None,
quality: str | None = None) -> BiologieCle:
return BiologieCle(test=test, valeur=valeur, valeur_num=valeur_num, quality=quality)
# ===================================================================
# Fonctions internes
# ===================================================================
class TestNorm:
def test_basic_normalization(self):
assert _norm(" Hello World ") == "hello world"
def test_accent_removal(self):
assert _norm("Hémoglobine élevée") == "hemoglobine elevee"
def test_apostrophe_normalization(self):
assert _norm("l\u2019anémie") == "l'anemie"
class TestFirstFloat:
@pytest.mark.parametrize("text,expected", [
("CRP 180 mg/L", 180.0),
("Hb 7,5 g/dL", 7.5),
("-3.2 mmol/L", -3.2),
("pas de nombre", None),
("", None),
])
def test_extraction(self, text, expected):
assert _first_float(text) == expected
class TestParseNormalRange:
def test_standard_range(self):
lo, hi = _parse_normal_range("Plaquettes 250 G/L [N: 150-450]")
assert lo == 150.0
assert hi == 450.0
def test_decimal_range(self):
lo, hi = _parse_normal_range("K+ 4.2 mmol/L [N: 3,5 - 5,0]")
assert lo == 3.5
assert hi == 5.0
def test_no_range(self):
lo, hi = _parse_normal_range("CRP 180 mg/L")
assert lo is None
assert hi is None
class TestParseFloat:
@pytest.mark.parametrize("value,expected", [
("4.5", 4.5),
("4,5", 4.5),
(" 7.2 ", 7.2),
("abc", None),
(None, None),
("", None),
("-3.1", -3.1),
])
def test_parsing(self, value, expected):
assert _parse_float(value) == expected
class TestIsSodiumTest:
@pytest.mark.parametrize("test,expected", [
("Sodium", True),
("sodium", True),
("Natrémie", True),
("Na+", True),
("Na", True),
("Potassium", False),
("CRP", False),
])
def test_detection(self, test, expected):
assert _is_sodium_test(test) == expected
class TestIsPotassiumTest:
@pytest.mark.parametrize("test,expected", [
("Potassium", True),
("potassium", True),
("Kaliémie", True),
("K+", True),
("K", True),
("Sodium", False),
("CRP", False),
])
def test_detection(self, test, expected):
assert _is_potassium_test(test) == expected
class TestAgeBand:
def test_adult(self):
d = _dossier(age=65)
assert _age_band(d, {"age_bands": {"adult_min_years": 18}}) == "adult"
def test_child(self):
d = _dossier(age=5)
assert _age_band(d, {"age_bands": {"adult_min_years": 18}}) == "child"
def test_unknown_age(self):
d = _dossier()
assert _age_band(d, {}) == "unknown"
def test_boundary_18(self):
d = _dossier(age=18)
assert _age_band(d, {"age_bands": {"adult_min_years": 18}}) == "adult"
# ===================================================================
# Nettoyage hiérarchique (.9 vs spécifique)
# ===================================================================
@patch("src.quality.decision_engine.load_reference_ranges", return_value={})
@patch("src.quality.decision_engine.load_bio_rules", return_value={})
@patch("src.quality.decision_engine.rule_enabled", return_value=True)
@patch("src.quality.decision_engine.cim10_validate", return_value=(True, "label"))
class TestHierarchyCleanup:
def test_generic_removed_when_specific_exists(self, mock_val, mock_rule, mock_bio, mock_ref):
"""K81.9 (générique) retiré quand K81.0 (spécifique) est présent."""
dp = Diagnostic(texte="Cholécystite", cim10_suggestion="K81.0")
das1 = _diag("Cholécystite SAI", "K81.9")
das2 = _diag("HTA", "I10")
dossier = _dossier(dp_code=None, das=[das1, das2])
dossier.diagnostic_principal = dp
apply_decisions(dossier)
# K81.9 doit être supprimé
k81_9 = [d for d in dossier.diagnostics_associes if d.cim10_suggestion == "K81.9"][0]
assert k81_9.cim10_decision is not None
assert k81_9.cim10_decision.action == "REMOVE"
assert "RULE-HIERARCHY-CLEANUP" in k81_9.cim10_decision.applied_rules
assert k81_9.cim10_final is None
def test_no_cleanup_when_no_specific(self, mock_val, mock_rule, mock_bio, mock_ref):
"""K81.9 seul (pas de K81.x spécifique) → conservé."""
das1 = _diag("Cholécystite SAI", "K81.9")
das2 = _diag("HTA", "I10")
dp = Diagnostic(texte="DP", cim10_suggestion="J18.9")
dossier = _dossier(das=[das1, das2])
dossier.diagnostic_principal = dp
apply_decisions(dossier)
k81_9 = [d for d in dossier.diagnostics_associes if d.cim10_suggestion == "K81.9"][0]
# Pas de décision REMOVE
assert k81_9.cim10_decision is None or k81_9.cim10_decision.action != "REMOVE"
# ===================================================================
# D50 sans preuve martiale → downgrade D64.9
# ===================================================================
@patch("src.quality.decision_engine.load_reference_ranges", return_value={})
@patch("src.quality.decision_engine.load_bio_rules", return_value={})
class TestD50NeedsIron:
@patch("src.quality.decision_engine.rule_enabled", return_value=True)
@patch("src.quality.decision_engine.cim10_validate", return_value=(True, "label"))
def test_d50_downgraded_without_iron_evidence(self, mock_val, mock_rule, mock_bio, mock_ref):
"""D50 sans preuve ferritine/martial → downgrade D64.9."""
das = _diag(
"Anémie ferriprive",
"D50",
preuves=[
PreuveClinique(type="biologie", element="Hb 9.5 g/dL", interpretation="Anémie confirmée"),
],
)
dossier = _dossier(dp_code="K85.9", das=[das], age=65)
apply_decisions(dossier)
d50 = [d for d in dossier.diagnostics_associes if d.cim10_suggestion == "D50"][0]
assert d50.cim10_final == "D64.9"
assert d50.cim10_decision.action == "DOWNGRADE"
assert "RULE-D50-NEEDS-IRON" in d50.cim10_decision.applied_rules
@patch("src.quality.decision_engine.rule_enabled", return_value=True)
@patch("src.quality.decision_engine.cim10_validate", return_value=(True, "label"))
def test_d50_kept_with_ferritin_evidence(self, mock_val, mock_rule, mock_bio, mock_ref):
"""D50 avec preuve de ferritine → conservé."""
das = _diag(
"Anémie ferriprive",
"D50",
preuves=[
PreuveClinique(type="biologie", element="Hb 9.5 g/dL", interpretation="Anémie confirmée"),
PreuveClinique(type="biologie", element="Ferritine 8 ng/mL", interpretation="Carence martiale"),
],
)
dossier = _dossier(dp_code="K85.9", das=[das], age=65)
apply_decisions(dossier)
d50 = [d for d in dossier.diagnostics_associes if d.cim10_suggestion == "D50"][0]
assert d50.cim10_decision is None or d50.cim10_decision.action != "DOWNGRADE"
@patch("src.quality.decision_engine.rule_enabled", return_value=True)
@patch("src.quality.decision_engine.cim10_validate", return_value=(True, "label"))
def test_d50_kept_with_martial_treatment(self, mock_val, mock_rule, mock_bio, mock_ref):
"""D50 avec traitement martial → conservé (preuve par le traitement)."""
das = _diag(
"Anémie ferriprive",
"D50",
preuves=[
PreuveClinique(type="biologie", element="Hb 8.0 g/dL", interpretation="Anémie confirmée"),
],
)
dossier = _dossier(
dp_code="K85.9",
das=[das],
age=65,
traitements=[Traitement(medicament="Fer intraveineux", posologie="200mg")],
)
apply_decisions(dossier)
d50 = [d for d in dossier.diagnostics_associes if d.cim10_suggestion == "D50"][0]
assert d50.cim10_decision is None or d50.cim10_decision.action != "DOWNGRADE"
def test_d50_rule_disabled(self, mock_bio, mock_ref):
"""RULE-D50-NEEDS-IRON désactivée → D50 conservé."""
def _rule_selective(rule_id):
return rule_id != "RULE-D50-NEEDS-IRON"
with patch("src.quality.decision_engine.rule_enabled", side_effect=_rule_selective):
with patch("src.quality.decision_engine.cim10_validate", return_value=(True, "label")):
das = _diag(
"Anémie ferriprive",
"D50",
preuves=[
PreuveClinique(type="biologie", element="Hb 9.5", interpretation="Anémie confirmée"),
],
)
dossier = _dossier(dp_code="K85.9", das=[das], age=65)
apply_decisions(dossier)
d50 = [d for d in dossier.diagnostics_associes if d.cim10_suggestion == "D50"][0]
assert d50.cim10_decision is None or d50.cim10_decision.action != "DOWNGRADE"
# ===================================================================
# D69.6 thrombopénie vs plaquettes normales
# ===================================================================
@patch("src.quality.decision_engine.load_bio_rules", return_value={})
class TestD696PltNormal:
@patch("src.quality.decision_engine.load_reference_ranges", return_value={
"age_bands": {"adult_min_years": 18},
"fallback_ranges": {"adult": {"platelets": {"low": 150, "high": 450}}},
"safe_zones_unknown_age": {"platelets_ruled_out_low": 170},
})
@patch("src.quality.decision_engine.rule_enabled", return_value=True)
@patch("src.quality.decision_engine.cim10_validate", return_value=(True, "label"))
def test_d696_ruled_out_with_normal_platelets(self, mock_val, mock_rule, mock_ref, mock_bio):
"""D69.6 avec plaquettes normales → RULED_OUT."""
das = _diag("Thrombopénie", "D69.6")
bio = [_bio("Plaquettes", "250 G/L [N: 150-450]", valeur_num=250.0)]
dossier = _dossier(dp_code="K85.9", das=[das], bio=bio, age=65)
apply_decisions(dossier)
d696 = [d for d in dossier.diagnostics_associes if d.cim10_suggestion == "D69.6"][0]
assert d696.cim10_decision is not None
assert d696.cim10_decision.action == "RULED_OUT"
assert d696.status == "ruled_out"
assert d696.cim10_final is None
@patch("src.quality.decision_engine.load_reference_ranges", return_value={
"age_bands": {"adult_min_years": 18},
"fallback_ranges": {"adult": {"platelets": {"low": 150, "high": 450}}},
"safe_zones_unknown_age": {"platelets_ruled_out_low": 170},
})
@patch("src.quality.decision_engine.rule_enabled", return_value=True)
@patch("src.quality.decision_engine.cim10_validate", return_value=(True, "label"))
def test_d696_kept_with_low_platelets(self, mock_val, mock_rule, mock_ref, mock_bio):
"""D69.6 avec plaquettes basses → conservé."""
das = _diag("Thrombopénie", "D69.6")
bio = [_bio("Plaquettes", "80 G/L", valeur_num=80.0)]
dossier = _dossier(dp_code="K85.9", das=[das], bio=bio, age=65)
apply_decisions(dossier)
d696 = [d for d in dossier.diagnostics_associes if d.cim10_suggestion == "D69.6"][0]
assert d696.cim10_decision is None or d696.cim10_decision.action != "RULED_OUT"
# ===================================================================
# _das_promotion_score — cas supplémentaires
# ===================================================================
class TestDasPromotionScoreExtra:
def test_empty_code(self):
"""Un diagnostic sans code final a un score minimal."""
diag = Diagnostic(texte="Test", cim10_final="")
score = _das_promotion_score(diag)
# pas de lettre → pertinence=2, mais specificite=0
assert score[2] == 0
def test_none_confidence(self):
"""Confiance None → score minimal."""
diag = Diagnostic(texte="Test", cim10_final="K85.9", cim10_confidence=None)
score = _das_promotion_score(diag)
assert score[1] == 1 # default low
# ===================================================================
# decision_summaries — toutes les actions
# ===================================================================
class TestDecisionSummaries:
def test_keep_no_output(self):
"""KEEP ne produit pas de résumé."""
das = Diagnostic(texte="HTA", cim10_suggestion="I10", cim10_decision=CodeDecision(action="KEEP"))
dossier = DossierMedical(diagnostics_associes=[das])
lines = decision_summaries(dossier)
assert len(lines) == 0
def test_downgrade_summary(self):
das = Diagnostic(
texte="Anémie",
cim10_suggestion="D50",
cim10_decision=CodeDecision(
action="DOWNGRADE",
final_code="D64.9",
downgraded_from="D50",
applied_rules=["RULE-D50-NEEDS-IRON"],
needs_info=["Bilan martial disponible ?"],
),
)
dossier = DossierMedical(diagnostics_associes=[das])
lines = decision_summaries(dossier)
assert any("D50" in l and "D64.9" in l for l in lines)
assert any("besoin_info" in l for l in lines)
def test_remove_summary(self):
das = Diagnostic(
texte="Cholécystite SAI",
cim10_suggestion="K81.9",
cim10_decision=CodeDecision(
action="REMOVE",
applied_rules=["RULE-HIERARCHY-CLEANUP"],
),
)
dossier = DossierMedical(diagnostics_associes=[das])
lines = decision_summaries(dossier)
assert any("K81.9" in l and "supprimé" in l for l in lines)
def test_ruled_out_summary(self):
das = Diagnostic(
texte="Thrombopénie",
cim10_suggestion="D69.6",
cim10_decision=CodeDecision(
action="RULED_OUT",
applied_rules=["RULE-D69.6-PLT-NORMAL"],
reason="Plaquettes normales",
),
)
dossier = DossierMedical(diagnostics_associes=[das])
lines = decision_summaries(dossier)
assert any("D69.6" in l and "écarté" in l for l in lines)
assert any("raison" in l for l in lines)
def test_need_info_summary(self):
das = Diagnostic(
texte="Hyponatrémie",
cim10_suggestion="E87.1",
cim10_decision=CodeDecision(
action="NEED_INFO",
applied_rules=["RULE-HYPONATREMIA-MISSING"],
reason="Sodium non extrait",
needs_info=["Valeurs de sodium ?"],
),
)
dossier = DossierMedical(diagnostics_associes=[das])
lines = decision_summaries(dossier)
assert any("NEED_INFO" in l for l in lines)
assert any("besoin_info" in l for l in lines)
def test_no_diagnostics(self):
"""Dossier sans diagnostics → liste vide."""
dossier = DossierMedical()
lines = decision_summaries(dossier)
assert lines == []
def test_dp_decision_included(self):
"""Décision sur le DP est incluse dans le résumé."""
dp = Diagnostic(
texte="DP",
cim10_suggestion="D50",
cim10_decision=CodeDecision(
action="DOWNGRADE",
final_code="D64.9",
downgraded_from="D50",
applied_rules=["RULE-D50-NEEDS-IRON"],
),
)
dossier = DossierMedical(diagnostic_principal=dp)
lines = decision_summaries(dossier)
assert any("diagnostic_principal" in l for l in lines)

View File

@@ -0,0 +1,340 @@
"""Tests étendus pour src/medical/dp_finalizer.py.
Couvre les cas non couverts dans test_dp_finalizer.py :
- R6 — Z-code non whitelisté remplacé par DAS
- Fonctions internes (_family3, _code_in_candidates, _has_strong_evidence)
- finalize_dp avec source Trackare détectée par evidence/reason
- Cas limites : codes None, candidats vides, Z whitelistés en R6
"""
from __future__ import annotations
import pytest
from src.config import DossierMedical, DPSelection, DPCandidate, Diagnostic, Sejour
from src.medical.dp_finalizer import (
finalize_dp,
decide_dp_final,
_family3,
_code_in_candidates,
_has_strong_evidence,
_apply_r5,
_make_selection,
)
# ── Helpers ────────────────────────────────────────────────────────────
def _sel(
code: str | None = None,
term: str = "",
verdict: str = "REVIEW",
confidence: str = "medium",
evidence: list[str] | None = None,
reason: str = "",
candidates: list[DPCandidate] | None = None,
) -> DPSelection:
return DPSelection(
chosen_code=code,
chosen_term=term or (code or ""),
verdict=verdict,
confidence=confidence,
evidence=evidence or [],
reason=reason,
candidates=candidates or [],
)
def _cand(code: str, term: str = "", score: float = 0.0) -> DPCandidate:
return DPCandidate(index=0, term=term or code, code=code, score=score)
def _dossier(
doc_type: str = "crh",
dp_selection: DPSelection | None = None,
das: list[Diagnostic] | None = None,
) -> DossierMedical:
return DossierMedical(
document_type=doc_type,
sejour=Sejour(),
dp_selection=dp_selection,
diagnostics_associes=das or [],
)
# ===================================================================
# Fonctions internes
# ===================================================================
class TestFamily3:
@pytest.mark.parametrize("code,expected", [
("K81.0", "K81"),
("K81", "K81"),
("I26.9", "I26"),
("Z51.1", "Z51"),
(None, ""),
("", ""),
])
def test_extraction(self, code, expected):
assert _family3(code) == expected
class TestCodeInCandidates:
def test_exact_match(self):
sel = _sel(candidates=[_cand("I26.9"), _cand("K85.1")])
assert _code_in_candidates("I26.9", sel) is True
def test_family3_match(self):
sel = _sel(candidates=[_cand("I26.9")])
assert _code_in_candidates("I26.0", sel) is True # same family
def test_no_match(self):
sel = _sel(candidates=[_cand("I26.9")])
assert _code_in_candidates("K85.1", sel) is False
def test_none_code(self):
sel = _sel(candidates=[_cand("I26.9")])
assert _code_in_candidates(None, sel) is False
def test_empty_candidates(self):
sel = _sel(candidates=[])
assert _code_in_candidates("I26.9", sel) is False
class TestHasStrongEvidence:
def test_no_evidence(self):
sel = _sel(evidence=[])
assert _has_strong_evidence(sel) is False
def test_only_trackare_source(self):
"""'Source: Trackare' seul n'est pas une preuve forte."""
sel = _sel(evidence=["Source: Trackare (codage établissement)"])
assert _has_strong_evidence(sel) is False
def test_strong_evidence(self):
sel = _sel(evidence=["Diagnostic de sortie: «Embolie pulmonaire»"])
assert _has_strong_evidence(sel) is True
def test_mixed_evidence(self):
sel = _sel(evidence=[
"Source: Trackare (codage établissement)",
"Conclusion CRH: «Pancréatite biliaire»",
])
assert _has_strong_evidence(sel) is True
class TestMakeSelection:
def test_preserves_source_candidates(self):
source = _sel(candidates=[_cand("I26.9", "EP", 5.0)])
result = _make_selection(
code="I26.9", term="EP", verdict="CONFIRMED",
confidence="high", evidence=[], reason="test",
source_sel=source,
)
assert len(result.candidates) == 1
assert result.candidates[0].code == "I26.9"
def test_no_source(self):
result = _make_selection(
code="I26.9", term="EP", verdict="CONFIRMED",
confidence="high", evidence=[], reason="test",
)
assert result.candidates == []
assert result.debug_scores is None
# ===================================================================
# R5 — _apply_r5 (direct call)
# ===================================================================
class TestApplyR5Direct:
def test_z_code_non_whitelisted_forced_review(self):
dp = _sel(code="Z95.5", verdict="CONFIRMED", confidence="high", evidence=[])
dp, flags, alertes = _apply_r5(dp, None, allow_symptom_dp=False)
assert dp.verdict == "REVIEW"
assert flags.get("z_code_dp_review") is True
def test_z_code_whitelisted_untouched(self):
dp = _sel(code="Z51.1", verdict="CONFIRMED", confidence="high", evidence=[])
dp, flags, alertes = _apply_r5(dp, None, allow_symptom_dp=False)
assert dp.verdict == "CONFIRMED"
assert "z_code_dp_review" not in flags
def test_r_code_with_non_r_candidate(self):
crh = _sel(candidates=[_cand("R06.0"), _cand("J18.9")])
dp = _sel(code="R06.0", verdict="CONFIRMED", confidence="high", evidence=[])
dp, flags, alertes = _apply_r5(dp, crh, allow_symptom_dp=False)
assert dp.verdict == "REVIEW"
assert flags.get("r_code_dp_with_non_r_candidate") is True
def test_r_code_allowed_by_flag(self):
crh = _sel(candidates=[_cand("R06.0"), _cand("J18.9")])
dp = _sel(code="R06.0", verdict="CONFIRMED", confidence="high", evidence=[])
dp, flags, alertes = _apply_r5(dp, crh, allow_symptom_dp=True)
assert dp.verdict == "CONFIRMED"
def test_review_dp_not_changed(self):
"""R5 ne touche que les CONFIRMED."""
dp = _sel(code="Z95.5", verdict="REVIEW", confidence="medium", evidence=[])
dp, flags, alertes = _apply_r5(dp, None, allow_symptom_dp=False)
assert dp.verdict == "REVIEW"
assert "z_code_dp_review" not in flags # pas de flag car déjà REVIEW
# ===================================================================
# R6 — Z-code remplacé par DAS (via finalize_dp)
# ===================================================================
class TestR6ZcodeReplacedByDas:
def test_z_code_replaced_by_das(self):
"""Z-code non whitelisté en DP → DAS high/medium non-R non-Z promu."""
sel = _sel(
code="Z96.6", # non whitelisté
verdict="CONFIRMED",
evidence=["Source: Trackare (codage établissement)"],
reason="DP Trackare",
)
das = [
Diagnostic(texte="Pancréatite", cim10_suggestion="K85.9", cim10_confidence="high"),
]
d = _dossier(doc_type="trackare", dp_selection=sel, das=das)
finalize_dp(d)
assert d.dp_final is not None
assert d.dp_final.chosen_code == "K85.9"
assert d.quality_flags.get("z_code_replaced_by_das") is True
assert any("K85.9" in a for a in d.alertes_codage)
def test_z_code_whitelisted_not_replaced(self):
"""Z-code whitelisté en DP → pas de remplacement."""
sel = _sel(
code="Z51.1", # whitelisté
verdict="CONFIRMED",
evidence=["Source: Trackare (codage établissement)"],
reason="DP Trackare",
)
das = [
Diagnostic(texte="Pancréatite", cim10_suggestion="K85.9", cim10_confidence="high"),
]
d = _dossier(doc_type="trackare", dp_selection=sel, das=das)
finalize_dp(d)
assert d.dp_final.chosen_code == "Z51.1"
assert "z_code_replaced_by_das" not in d.quality_flags
def test_z_code_no_eligible_das(self):
"""Z-code non whitelisté mais aucun DAS éligible → pas de remplacement."""
sel = _sel(
code="Z96.6",
verdict="CONFIRMED",
evidence=["Source: Trackare (codage établissement)"],
reason="DP Trackare",
)
das = [
Diagnostic(texte="Symptôme R", cim10_suggestion="R10.4", cim10_confidence="high"),
Diagnostic(texte="Z-code", cim10_suggestion="Z87.1", cim10_confidence="low"),
]
d = _dossier(doc_type="trackare", dp_selection=sel, das=das)
finalize_dp(d)
# R10.4 est R-code, Z87.1 est Z-code → aucun éligible
assert "z_code_replaced_by_das" not in d.quality_flags
# ===================================================================
# finalize_dp — détection source Trackare
# ===================================================================
class TestFinalizeDpSourceDetection:
def test_trackare_detected_by_document_type(self):
sel = _sel(code="K81.0", verdict="CONFIRMED", evidence=[], reason="CRH DP")
d = _dossier(doc_type="trackare", dp_selection=sel)
finalize_dp(d)
assert d.dp_trackare is not None
def test_trackare_detected_by_reason(self):
sel = _sel(code="K81.0", verdict="CONFIRMED", evidence=[],
reason="DP Trackare — source d'autorité")
d = _dossier(doc_type="crh", dp_selection=sel)
finalize_dp(d)
assert d.dp_trackare is not None
def test_trackare_detected_by_evidence(self):
sel = _sel(code="K81.0", verdict="CONFIRMED",
evidence=["Source: Trackare (codage établissement)"],
reason="DP")
d = _dossier(doc_type="crh", dp_selection=sel)
finalize_dp(d)
assert d.dp_trackare is not None
def test_crh_only_when_no_trackare_signal(self):
sel = _sel(code="K81.0", verdict="CONFIRMED",
evidence=["Diagnostic de sortie CRH"],
reason="CRH CONFIRMED")
d = _dossier(doc_type="crh", dp_selection=sel)
finalize_dp(d)
assert d.dp_trackare is None
assert d.dp_crh_only is not None
# ===================================================================
# finalize_dp — pas de dp_selection
# ===================================================================
class TestFinalizeDpNoSelection:
def test_no_dp_selection(self):
"""Dossier sans dp_selection → REVIEW, flag no_dp_source."""
d = _dossier(doc_type="crh")
finalize_dp(d)
assert d.dp_final is not None
assert d.dp_final.verdict == "REVIEW"
assert d.quality_flags.get("no_dp_source") is True
# ===================================================================
# decide_dp_final — edge cases
# ===================================================================
class TestDecideDpFinalEdgeCases:
def test_trackare_z_code_whitelisted(self):
"""Z03 (whitelisté) en Trackare-only → CONFIRMED."""
trackare = _sel(code="Z03.8", verdict="CONFIRMED",
evidence=["Source: Trackare"])
dp, flags, _ = decide_dp_final(trackare, None)
# Z03 is whitelisted
assert dp.verdict == "CONFIRMED"
def test_trackare_empty_evidence_gets_default(self):
"""Trackare-only sans evidence → evidence par défaut ajoutée."""
trackare = _sel(code="K81.0", verdict="CONFIRMED", evidence=[])
dp, flags, _ = decide_dp_final(trackare, None)
assert any("Trackare" in e for e in dp.evidence)
def test_both_sources_trackare_review_from_selector(self):
"""Si dp_selector a déjà mis REVIEW, le finalizer le respecte."""
trackare = _sel(code="K81.0", verdict="REVIEW",
evidence=["Source: Trackare"])
crh = _sel(code="K81.0", verdict="REVIEW",
evidence=["Diagnostic CRH"],
candidates=[_cand("K81.0")])
dp, flags, _ = decide_dp_final(trackare, crh)
# R2 corrobore → CONFIRMED
assert dp.verdict == "CONFIRMED"
assert flags.get("trackare_confirmed_by_crh") is True
def test_code_none_handling(self):
"""Trackare avec code None → pas de crash."""
trackare = _sel(code=None, verdict="REVIEW", evidence=["Source: Trackare"])
crh = _sel(code="K81.0", verdict="CONFIRMED",
evidence=["Diagnostic de sortie CRH"])
dp, flags, _ = decide_dp_final(trackare, crh)
# Code None ne commence pas par R, ne commence pas par Z
# Il ne matche pas CRH → R4 ambigu
assert dp is not None

View File

@@ -0,0 +1,79 @@
"""Tests pour le endpoint /health du viewer Flask."""
import json
import os
from unittest.mock import patch, MagicMock
import pytest
import requests
from src.viewer.app import create_app
@pytest.fixture
def app():
with patch.dict(os.environ, {"T2A_DEMO_USER": "", "T2A_DEMO_PASS": ""}):
app = create_app()
app.config["TESTING"] = True
yield app
@pytest.fixture
def client(app):
return app.test_client()
class TestHealthEndpoint:
def test_health_returns_200(self, client):
response = client.get("/health")
assert response.status_code == 200
def test_health_returns_json(self, client):
response = client.get("/health")
data = response.get_json()
assert data is not None
assert data["status"] == "ok"
def test_health_contains_required_fields(self, client):
response = client.get("/health")
data = response.get_json()
assert "status" in data
assert "version" in data
assert "ollama" in data
assert "timestamp" in data
def test_health_version_format(self, client):
response = client.get("/health")
data = response.get_json()
assert data["version"] == "2.1.0"
def test_health_timestamp_is_iso(self, client):
from datetime import datetime
response = client.get("/health")
data = response.get_json()
# Should parse without error
ts = datetime.fromisoformat(data["timestamp"])
assert ts is not None
@patch("src.viewer.app.requests.get")
def test_health_ollama_reachable(self, mock_get, client):
"""Quand Ollama répond, ollama=True."""
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_get.return_value = mock_resp
response = client.get("/health")
data = response.get_json()
assert data["ollama"] is True
@patch("src.viewer.app.requests.get", side_effect=requests.ConnectionError("refused"))
def test_health_ollama_unreachable(self, mock_get, client):
"""Quand Ollama est injoignable, ollama=False."""
response = client.get("/health")
data = response.get_json()
assert data["ollama"] is False
def test_health_ollama_is_bool(self, client):
response = client.get("/health")
data = response.get_json()
assert isinstance(data["ollama"], bool)

View File

@@ -0,0 +1,526 @@
"""Tests unitaires pour src/viewer/helpers.py.
Couvre : filtres Jinja2, helpers de statistiques, fonctions utilitaires.
Pas de dépendance à Ollama ni au système de fichiers (sauf tmpdir).
"""
from __future__ import annotations
import re
from collections import Counter
from unittest.mock import patch, MagicMock
import pytest
from markupsafe import Markup
from src.config import (
DossierMedical,
Diagnostic,
DPSelection,
DPCandidate,
ActeCCAM,
CodeDecision,
GHMEstimation,
VetoReport,
VetoIssue,
CompletudeDossier,
ControleCPAM,
FinancialImpact,
Sejour,
)
from src.viewer.helpers import (
compute_group_stats,
compute_dashboard_stats,
compute_dim_synthesis,
confidence_badge,
confidence_label,
severity_badge,
cma_level_badge,
format_duration,
format_dossier_name,
format_doc_name,
decision_badge,
format_cpam_text,
human_where,
_date_to_iso,
_sort_qc_alerts,
_compute_jours_restants,
)
# ── Helpers ────────────────────────────────────────────────────────────
def _item(dossier: DossierMedical, path_rel: str = "test/file.json") -> dict:
"""Crée un item de scan_dossiers minimal."""
return {"name": "file", "path_rel": path_rel, "dossier": dossier}
def _diag(
texte: str = "Diagnostic",
code: str | None = None,
confidence: str | None = None,
est_cma: bool | None = None,
decision: CodeDecision | None = None,
cim10_final: str | None = None,
) -> Diagnostic:
return Diagnostic(
texte=texte,
cim10_suggestion=code,
cim10_confidence=confidence,
est_cma=est_cma,
cim10_decision=decision,
cim10_final=cim10_final,
)
# ===================================================================
# confidence_badge / confidence_label
# ===================================================================
class TestConfidenceBadge:
def test_high(self):
result = confidence_badge("high")
assert isinstance(result, Markup)
assert "Haute" in result
assert "#16a34a" in result
def test_medium(self):
result = confidence_badge("medium")
assert "Moyenne" in result
def test_low(self):
result = confidence_badge("low")
assert "Basse" in result
assert "#dc2626" in result
def test_none_returns_empty(self):
assert confidence_badge(None) == ""
def test_empty_string_returns_empty(self):
assert confidence_badge("") == ""
def test_unknown_value_uses_default_colors(self):
result = confidence_badge("unknown")
assert "unknown" in result
assert "#6b7280" in result # default foreground
class TestConfidenceLabel:
@pytest.mark.parametrize("value,expected", [
("high", "Haute"),
("medium", "Moyenne"),
("low", "Basse"),
(None, ""),
("", ""),
("autre", "autre"),
])
def test_labels(self, value, expected):
assert confidence_label(value) == expected
# ===================================================================
# cma_level_badge
# ===================================================================
class TestCmaLevelBadge:
def test_none_returns_empty(self):
assert cma_level_badge(None) == ""
def test_zero_returns_empty(self):
assert cma_level_badge(0) == ""
def test_negative_returns_empty(self):
assert cma_level_badge(-1) == ""
@pytest.mark.parametrize("level", [1, 2, 3, 4])
def test_valid_levels(self, level):
result = cma_level_badge(level)
assert isinstance(result, Markup)
assert f"CMA {level}" in result
def test_level_above_4_capped(self):
"""Un niveau > 4 est cappé à 4."""
result = cma_level_badge(5)
assert "CMA 4" in result
# ===================================================================
# format_dossier_name / format_doc_name
# ===================================================================
class TestFormatDossierName:
def test_racine(self):
assert format_dossier_name("racine") == "Non classés"
def test_normal_name(self):
assert format_dossier_name("190_23139234") == "190_23139234"
class TestFormatDocName:
@pytest.mark.parametrize("name,expected", [
("190_fusionne_cim10", "Fusionné"),
("CRH_23139234_cim10", "CRH"),
("CRO_23139234_cim10", "CRO"),
("crh_23139234", "CRH"),
("trackare-01295620", "Trackare"),
("ANAPATH_23103383", "Anapath"),
("some_other_doc", "some_other_doc"),
])
def test_doc_names(self, name, expected):
assert format_doc_name(name) == expected
# ===================================================================
# decision_badge
# ===================================================================
class TestDecisionBadge:
def test_none_returns_empty(self):
assert decision_badge(None) == ""
def test_keep_returns_empty(self):
"""KEEP n'a pas de badge (cas par défaut)."""
dec = CodeDecision(action="KEEP")
assert decision_badge(dec) == ""
@pytest.mark.parametrize("action,label", [
("DOWNGRADE", "Rétrogradé"),
("REMOVE", "Supprimé"),
("RULED_OUT", "Écarté"),
("NEED_INFO", "Preuve manquante"),
("PROMOTE_DP", "Promu en DP"),
])
def test_action_labels(self, action, label):
dec = CodeDecision(action=action)
result = decision_badge(dec)
assert label in result
def test_dict_input(self):
"""Accepte un dict en plus d'un CodeDecision."""
result = decision_badge({"action": "REMOVE"})
assert "Supprimé" in result
def test_dict_keep(self):
result = decision_badge({"action": "KEEP"})
assert result == ""
def test_unknown_action(self):
"""Action inconnue affiche le texte brut."""
result = decision_badge({"action": "CUSTOM_ACTION"})
assert "CUSTOM_ACTION" in result
# ===================================================================
# human_where
# ===================================================================
class TestHumanWhere:
@pytest.mark.parametrize("value,expected", [
(None, "Global"),
("", "Global"),
("diagnostic_principal", "Diagnostic Principal"),
("diagnostics_associes", "Diagnostics Associés"),
("sejour", "Séjour"),
("diagnostics_associes[0]", "DAS n°1"),
("diagnostics_associes[5]", "DAS n°6"),
("actes_ccam[0]", "Acte n°1"),
("actes_ccam[2]", "Acte n°3"),
("autre_chose", "autre_chose"),
])
def test_conversions(self, value, expected):
assert human_where(value) == expected
# ===================================================================
# _date_to_iso
# ===================================================================
class TestDateToIso:
def test_valid_date(self):
assert _date_to_iso("15/03/2025") == "2025-03-15"
def test_invalid_format(self):
assert _date_to_iso("2025-03-15") == ""
def test_empty_string(self):
assert _date_to_iso("") == ""
def test_single_part(self):
assert _date_to_iso("15") == ""
# ===================================================================
# _sort_qc_alerts
# ===================================================================
class TestSortQcAlerts:
def test_critical_first(self):
alerts = [
"Recommandation : ajouter un DAS",
"Erreur critique : code invalide",
"Code justifié solidement",
]
sorted_alerts = _sort_qc_alerts(alerts)
assert sorted_alerts[0] == "Erreur critique : code invalide"
assert sorted_alerts[-1] == "Code justifié solidement"
def test_empty_list(self):
assert _sort_qc_alerts([]) == []
def test_dp_prioritized_within_tier(self):
"""Les alertes mentionnant le DP sont priorisées dans leur tier."""
alerts = [
"Redondance dans DAS",
"Redondance DP diagnostic principal suspect",
]
sorted_alerts = _sort_qc_alerts(alerts)
assert "DP" in sorted_alerts[0] or "diagnostic principal" in sorted_alerts[0]
# ===================================================================
# _compute_jours_restants
# ===================================================================
class TestComputeJoursRestants:
def test_no_date(self):
ctrl = MagicMock()
ctrl.date_limite_reponse = None
assert _compute_jours_restants(ctrl) is None
def test_invalid_date(self):
ctrl = MagicMock()
ctrl.date_limite_reponse = "not-a-date"
assert _compute_jours_restants(ctrl) is None
def test_valid_date_returns_int(self):
ctrl = MagicMock()
ctrl.date_limite_reponse = "15/03/2030"
result = _compute_jours_restants(ctrl)
assert isinstance(result, int)
assert result > 0 # well into the future
# ===================================================================
# compute_group_stats
# ===================================================================
class TestComputeGroupStats:
def test_empty_items(self):
stats = compute_group_stats([])
assert stats == {"das_count": 0, "alertes_count": 0, "actes_count": 0, "cma_count": 0}
def test_cma_counted_on_dp(self):
"""CMA sur le DP est compté."""
d = DossierMedical(
diagnostic_principal=_diag("DP", "K85.9", est_cma=True),
)
stats = compute_group_stats([_item(d)])
assert stats["cma_count"] == 1
def test_multiple_items(self):
d1 = DossierMedical(
diagnostics_associes=[_diag("DAS1", "I10", est_cma=True)],
actes_ccam=[ActeCCAM(texte="Acte1")],
alertes_codage=["Alerte"],
)
d2 = DossierMedical(
diagnostics_associes=[_diag("DAS2", "E11.9"), _diag("DAS3", "J18.9", est_cma=True)],
)
stats = compute_group_stats([_item(d1), _item(d2)])
assert stats["das_count"] == 3
assert stats["actes_count"] == 1
assert stats["alertes_count"] == 1
assert stats["cma_count"] == 2
# ===================================================================
# compute_dashboard_stats
# ===================================================================
class TestComputeDashboardStats:
def test_empty_groups(self):
stats = compute_dashboard_stats({})
assert stats["total_dossiers"] == 0
assert stats["total_fichiers"] == 0
assert stats["top_codes"] == []
assert stats["processing_time_avg"] == 0
def test_single_dossier(self):
d = DossierMedical(
diagnostic_principal=_diag("DP", "K85.9", confidence="high"),
diagnostics_associes=[_diag("DAS", code="I10")],
actes_ccam=[ActeCCAM(texte="Acte")],
alertes_codage=["Alerte"],
processing_time_s=10.5,
)
groups = {"grp1": [_item(d)]}
stats = compute_dashboard_stats(groups)
assert stats["total_dossiers"] == 1
assert stats["total_fichiers"] == 1
assert stats["total_das"] == 1
assert stats["total_actes"] == 1
assert stats["total_alertes"] == 1
assert stats["processing_time_avg"] == 10.5
def test_dp_validity_absent_when_no_dp(self):
d = DossierMedical()
groups = {"grp": [_item(d)]}
stats = compute_dashboard_stats(groups)
assert stats["dp_validity"].get("absent", 0) == 1
def test_ghm_types_counted(self):
d = DossierMedical(
ghm_estimation=GHMEstimation(type_ghm="C", severite=3),
)
groups = {"grp": [_item(d)]}
stats = compute_dashboard_stats(groups)
assert stats["ghm_types"].get("C") == 1
assert stats["severity_dist"].get(3) == 1
# ===================================================================
# compute_dim_synthesis
# ===================================================================
class TestComputeDimSynthesis:
def test_empty_groups(self):
result = compute_dim_synthesis({})
assert result["dp"]["total"] == 0
assert result["das"]["total"] == 0
assert result["veto"]["avg_score"] == 0
def test_dp_confirmed(self):
d = DossierMedical(
dp_final=DPSelection(
chosen_code="K85.9",
verdict="CONFIRMED",
confidence="high",
evidence=["Test evidence"],
),
)
groups = {"grp": [_item(d)]}
result = compute_dim_synthesis(groups)
assert result["dp"]["total"] == 1
assert result["dp"]["confirmed"] == 1
def test_dp_review_creates_alert(self):
d = DossierMedical(
dp_final=DPSelection(
chosen_code="R10.4",
verdict="REVIEW",
confidence="medium",
reason="Ambigu",
evidence=[],
),
)
groups = {"grp": [_item(d)]}
result = compute_dim_synthesis(groups)
assert result["dp"]["review"] == 1
assert len(result["alertes"]["review"]) == 1
def test_das_decisions_counted(self):
d = DossierMedical(
diagnostics_associes=[
_diag("DAS1", "I10", decision=CodeDecision(action="KEEP")),
_diag("DAS2", "D69.6", decision=CodeDecision(action="RULED_OUT")),
_diag("DAS3", "D50", decision=CodeDecision(action="DOWNGRADE", final_code="D64.9")),
_diag("DAS4", "Z87.1", decision=CodeDecision(action="REMOVE")),
],
)
groups = {"grp": [_item(d)]}
result = compute_dim_synthesis(groups)
assert result["das"]["total"] == 4
assert result["das"]["kept"] == 1
assert result["das"]["ruled_out"] == 1
assert result["das"]["downgraded"] == 1
assert result["das"]["removed"] == 1
def test_das_no_decision_counted_as_kept(self):
d = DossierMedical(
diagnostics_associes=[_diag("DAS", "I10")], # no decision
)
groups = {"grp": [_item(d)]}
result = compute_dim_synthesis(groups)
assert result["das"]["kept"] == 1
def test_veto_report_aggregated(self):
d = DossierMedical(
veto_report=VetoReport(
verdict="FAIL",
score_contestabilite=40,
issues=[
VetoIssue(veto="VETO-01", severity="HARD", where="dp", message="test"),
],
),
)
groups = {"grp": [_item(d)]}
result = compute_dim_synthesis(groups)
assert result["veto"]["avg_score"] == 40
assert result["veto"]["distribution"].get("FAIL") == 1
assert len(result["alertes"]["fail"]) == 1
def test_completude_indefendable(self):
d = DossierMedical(
completude=CompletudeDossier(
verdict_global="indefendable",
score_global=20,
documents_manquants=["CRO", "Anapath"],
),
)
groups = {"grp": [_item(d)]}
result = compute_dim_synthesis(groups)
assert result["completude"]["distribution"].get("indefendable") == 1
assert len(result["alertes"]["indefendable"]) == 1
def test_taux_modification_zero_when_no_das(self):
result = compute_dim_synthesis({})
assert result["das"]["taux_modification"] == 0
def test_cpam_impact(self):
d = DossierMedical(
controles_cpam=[
ControleCPAM(
numero_ogc=1,
financial_impact=FinancialImpact(
impact_estime_euros=1500,
priorite="haute",
),
validation_dim="valide",
),
],
)
groups = {"grp": [_item(d)]}
result = compute_dim_synthesis(groups)
assert result["cpam"]["total"] == 1
assert result["cpam"]["impact_total"] == 1500
assert result["cpam"]["by_priority"].get("haute") == 1
assert result["cpam"]["by_status"].get("valide") == 1
# ===================================================================
# format_cpam_text (additional edge cases beyond test_viewer.py)
# ===================================================================
class TestFormatCpamTextExtra:
def test_blank_lines_produce_br(self):
result = format_cpam_text("Line 1\n\nLine 2")
assert "<br>" in result
def test_list_closed_at_end(self):
"""A list not followed by text should still be closed."""
result = format_cpam_text("- Item 1\n- Item 2")
assert result.count("<ul") == 1
assert result.count("</ul>") == 1