533 lines
18 KiB
Python
533 lines
18 KiB
Python
"""
|
|
Tests unitaires pour le GroupageValidator.
|
|
|
|
Ces tests vérifient:
|
|
- L'intégration de la fonction de groupage ATIH (mock pour POC)
|
|
- La transformation CIM-10/CCAM → GHM/GHS
|
|
- La vérification des dates de réalisation CCAM (erreur bloquante 2026)
|
|
- La vérification de la version FG
|
|
- L'enregistrement de la version FG dans l'audit
|
|
"""
|
|
|
|
import uuid
|
|
from datetime import datetime
|
|
|
|
import pytest
|
|
|
|
from pipeline_mco_pmsi.models.clinical import ClinicalFact, Evidence, Qualifier, Span
|
|
from pipeline_mco_pmsi.models.coding import Code, CodingProposal
|
|
from pipeline_mco_pmsi.models.metadata import ModelVersion, StayMetadata
|
|
from pipeline_mco_pmsi.validators.groupage_validator import GroupageValidator
|
|
|
|
|
|
@pytest.fixture
|
|
def stay_metadata():
|
|
"""Fixture pour les métadonnées de séjour."""
|
|
return StayMetadata(
|
|
stay_id="stay_001",
|
|
admission_date=datetime(2026, 1, 15),
|
|
discharge_date=datetime(2026, 1, 20),
|
|
age=45,
|
|
sex="M",
|
|
specialty="Chirurgie",
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def model_version():
|
|
"""Fixture pour la version du modèle."""
|
|
return ModelVersion(
|
|
model_name="test-model",
|
|
model_tag="v1.0",
|
|
model_digest="a" * 64, # SHA-256 hash (64 caractères hexadécimaux)
|
|
quantization="q4_0",
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def sample_evidence():
|
|
"""Fixture pour une preuve."""
|
|
return Evidence(
|
|
document_id="doc_001",
|
|
span=Span(start=100, end=120),
|
|
text="Gastrite aiguë",
|
|
context="Le patient présente une gastrite aiguë confirmée par endoscopie.",
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def sample_dp_code(sample_evidence):
|
|
"""Fixture pour un code DP."""
|
|
return Code(
|
|
code="K29.1",
|
|
label="Gastrite aiguë",
|
|
type="dp",
|
|
evidence=[sample_evidence],
|
|
confidence=0.9,
|
|
reasoning="Diagnostic principal basé sur l'endoscopie",
|
|
referentiel_version="2026",
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def sample_das_code(sample_evidence):
|
|
"""Fixture pour un code DAS."""
|
|
return Code(
|
|
code="E11.9",
|
|
label="Diabète de type 2",
|
|
type="das",
|
|
evidence=[sample_evidence],
|
|
confidence=0.85,
|
|
reasoning="Antécédent de diabète mentionné",
|
|
referentiel_version="2026",
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def sample_ccam_code_with_date(sample_evidence):
|
|
"""Fixture pour un code CCAM avec date."""
|
|
evidence_with_date = Evidence(
|
|
document_id="doc_001",
|
|
span=Span(start=200, end=250),
|
|
text="Endoscopie réalisée le 15/01/2026",
|
|
context="Endoscopie digestive haute réalisée le 15/01/2026 sous anesthésie.",
|
|
)
|
|
return Code(
|
|
code="HGQE002",
|
|
label="Endoscopie œsogastroduodénale",
|
|
type="ccam",
|
|
evidence=[evidence_with_date],
|
|
confidence=0.95,
|
|
reasoning="Acte réalisé le 15/01/2026 selon le compte rendu opératoire",
|
|
referentiel_version="2026",
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def sample_ccam_code_without_date(sample_evidence):
|
|
"""Fixture pour un code CCAM sans date."""
|
|
return Code(
|
|
code="YYYY001",
|
|
label="Acte sans date",
|
|
type="ccam",
|
|
evidence=[sample_evidence],
|
|
confidence=0.8,
|
|
reasoning="Acte mentionné dans le dossier",
|
|
referentiel_version="2026",
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def coding_proposal_complete(
|
|
sample_dp_code, sample_das_code, sample_ccam_code_with_date, model_version
|
|
):
|
|
"""Fixture pour une proposition de codage complète."""
|
|
return CodingProposal(
|
|
stay_id="stay_001",
|
|
dp=sample_dp_code,
|
|
dr=None,
|
|
das=[sample_das_code],
|
|
ccam=[sample_ccam_code_with_date],
|
|
reasoning="Codage basé sur les documents cliniques",
|
|
model_version=model_version,
|
|
prompt_version="v1.0",
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def coding_proposal_missing_ccam_date(
|
|
sample_dp_code, sample_das_code, sample_ccam_code_without_date, model_version
|
|
):
|
|
"""Fixture pour une proposition avec CCAM sans date."""
|
|
return CodingProposal(
|
|
stay_id="stay_001",
|
|
dp=sample_dp_code,
|
|
dr=None,
|
|
das=[sample_das_code],
|
|
ccam=[sample_ccam_code_without_date],
|
|
reasoning="Codage basé sur les documents cliniques",
|
|
model_version=model_version,
|
|
prompt_version="v1.0",
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def coding_proposal_no_dp(sample_das_code, sample_ccam_code_with_date, model_version):
|
|
"""Fixture pour une proposition sans DP."""
|
|
return CodingProposal(
|
|
stay_id="stay_001",
|
|
dp=None,
|
|
dr=None,
|
|
das=[sample_das_code],
|
|
ccam=[sample_ccam_code_with_date],
|
|
reasoning="Codage incomplet",
|
|
model_version=model_version,
|
|
prompt_version="v1.0",
|
|
)
|
|
|
|
|
|
class TestGroupageValidatorInitialization:
|
|
"""Tests d'initialisation du GroupageValidator."""
|
|
|
|
def test_init_default_version(self):
|
|
"""Test l'initialisation avec la version par défaut."""
|
|
validator = GroupageValidator()
|
|
assert validator.groupage_version == "2026"
|
|
|
|
def test_init_custom_version(self):
|
|
"""Test l'initialisation avec une version personnalisée."""
|
|
validator = GroupageValidator(groupage_version="2025")
|
|
assert validator.groupage_version == "2025"
|
|
|
|
def test_get_version_info(self):
|
|
"""Test la récupération des informations de version."""
|
|
validator = GroupageValidator(groupage_version="2026")
|
|
version_info = validator.get_version_info()
|
|
|
|
assert version_info["groupage_version"] == "2026"
|
|
assert version_info["implementation"] == "mock_poc"
|
|
assert "library_version" in version_info
|
|
|
|
|
|
class TestCheckCCAMDates:
|
|
"""Tests de la vérification des dates CCAM."""
|
|
|
|
def test_ccam_with_date_in_reasoning(self, sample_ccam_code_with_date):
|
|
"""Test qu'un code CCAM avec date dans le reasoning est accepté."""
|
|
validator = GroupageValidator()
|
|
missing_dates = validator.check_ccam_dates([sample_ccam_code_with_date])
|
|
|
|
assert len(missing_dates) == 0
|
|
|
|
def test_ccam_without_date(self, sample_ccam_code_without_date):
|
|
"""Test qu'un code CCAM sans date est détecté."""
|
|
validator = GroupageValidator()
|
|
missing_dates = validator.check_ccam_dates([sample_ccam_code_without_date])
|
|
|
|
assert len(missing_dates) == 1
|
|
assert missing_dates[0] == "YYYY001"
|
|
|
|
def test_multiple_ccam_mixed_dates(
|
|
self, sample_ccam_code_with_date, sample_ccam_code_without_date
|
|
):
|
|
"""Test avec plusieurs codes CCAM, certains avec date, d'autres sans."""
|
|
validator = GroupageValidator()
|
|
missing_dates = validator.check_ccam_dates(
|
|
[sample_ccam_code_with_date, sample_ccam_code_without_date]
|
|
)
|
|
|
|
assert len(missing_dates) == 1
|
|
assert missing_dates[0] == "YYYY001"
|
|
|
|
def test_empty_ccam_list(self):
|
|
"""Test avec une liste vide de codes CCAM."""
|
|
validator = GroupageValidator()
|
|
missing_dates = validator.check_ccam_dates([])
|
|
|
|
assert len(missing_dates) == 0
|
|
|
|
def test_ccam_with_date_in_evidence(self, sample_evidence):
|
|
"""Test qu'un code CCAM avec date dans les preuves est accepté."""
|
|
evidence_with_date = Evidence(
|
|
document_id="doc_001",
|
|
span=Span(start=200, end=250),
|
|
text="Intervention du 15/01/2026",
|
|
context="Chirurgie effectuée le 15/01/2026.",
|
|
)
|
|
ccam_code = Code(
|
|
code="HGQE002",
|
|
label="Endoscopie",
|
|
type="ccam",
|
|
evidence=[evidence_with_date],
|
|
confidence=0.95,
|
|
reasoning="Acte chirurgical",
|
|
referentiel_version="2026",
|
|
)
|
|
|
|
validator = GroupageValidator()
|
|
missing_dates = validator.check_ccam_dates([ccam_code])
|
|
|
|
assert len(missing_dates) == 0
|
|
|
|
|
|
class TestValidateGroupage:
|
|
"""Tests de la validation de groupage complète."""
|
|
|
|
def test_validate_groupage_success(self, coding_proposal_complete, stay_metadata):
|
|
"""Test une validation de groupage réussie."""
|
|
validator = GroupageValidator(groupage_version="2026")
|
|
result = validator.validate_groupage(coding_proposal_complete, stay_metadata)
|
|
|
|
assert result.stay_id == "stay_001"
|
|
assert result.ghm is not None
|
|
assert result.ghs is not None
|
|
assert len(result.ccam_date_errors) == 0
|
|
assert result.groupage_version == "2026"
|
|
assert isinstance(result.groupage_date, datetime)
|
|
|
|
def test_validate_groupage_missing_ccam_date(
|
|
self, coding_proposal_missing_ccam_date, stay_metadata
|
|
):
|
|
"""Test la détection d'une date CCAM manquante."""
|
|
validator = GroupageValidator(groupage_version="2026")
|
|
result = validator.validate_groupage(
|
|
coding_proposal_missing_ccam_date, stay_metadata
|
|
)
|
|
|
|
assert result.stay_id == "stay_001"
|
|
assert len(result.ccam_date_errors) == 1
|
|
assert result.ccam_date_errors[0] == "YYYY001"
|
|
|
|
# Vérifier qu'une erreur bloquante est générée
|
|
blocking_errors = [
|
|
err for err in result.groupage_errors if err.severity == "bloquant"
|
|
]
|
|
assert len(blocking_errors) >= 1
|
|
assert any("Date de réalisation manquante" in err.message for err in blocking_errors)
|
|
|
|
def test_validate_groupage_no_dp(self, coding_proposal_no_dp, stay_metadata):
|
|
"""Test la détection d'un DP manquant."""
|
|
validator = GroupageValidator(groupage_version="2026")
|
|
result = validator.validate_groupage(coding_proposal_no_dp, stay_metadata)
|
|
|
|
assert result.stay_id == "stay_001"
|
|
assert result.ghm is None
|
|
assert result.ghs is None
|
|
|
|
# Vérifier qu'une erreur bloquante est générée
|
|
blocking_errors = [
|
|
err for err in result.groupage_errors if err.severity == "bloquant"
|
|
]
|
|
assert len(blocking_errors) >= 1
|
|
assert any("Aucun Diagnostic Principal" in err.message for err in blocking_errors)
|
|
|
|
def test_validate_groupage_version_mismatch(
|
|
self, coding_proposal_complete, stay_metadata
|
|
):
|
|
"""Test la détection d'une version FG incorrecte."""
|
|
# Créer un validateur avec une version différente de l'année du séjour
|
|
validator = GroupageValidator(groupage_version="2025")
|
|
|
|
with pytest.raises(ValueError) as exc_info:
|
|
validator.validate_groupage(coding_proposal_complete, stay_metadata)
|
|
|
|
assert "Version FG" in str(exc_info.value)
|
|
assert "ne correspond pas" in str(exc_info.value)
|
|
|
|
def test_validate_groupage_many_das(
|
|
self, sample_dp_code, sample_das_code, sample_ccam_code_with_date, model_version, stay_metadata
|
|
):
|
|
"""Test avec un nombre élevé de DAS."""
|
|
# Créer 25 DAS (au-dessus de la limite de 20)
|
|
many_das = [sample_das_code] * 25
|
|
|
|
proposal = CodingProposal(
|
|
stay_id="stay_001",
|
|
dp=sample_dp_code,
|
|
dr=None,
|
|
das=many_das,
|
|
ccam=[sample_ccam_code_with_date],
|
|
reasoning="Codage avec beaucoup de DAS",
|
|
model_version=model_version,
|
|
prompt_version="v1.0",
|
|
)
|
|
|
|
validator = GroupageValidator(groupage_version="2026")
|
|
result = validator.validate_groupage(proposal, stay_metadata)
|
|
|
|
# Vérifier qu'un avertissement est généré
|
|
review_errors = [
|
|
err for err in result.groupage_errors if err.severity == "a_revoir"
|
|
]
|
|
assert len(review_errors) >= 1
|
|
assert any("Nombre élevé de DAS" in err.message for err in review_errors)
|
|
|
|
def test_validate_groupage_many_ccam(
|
|
self, sample_dp_code, sample_das_code, sample_ccam_code_with_date, model_version, stay_metadata
|
|
):
|
|
"""Test avec un nombre élevé d'actes CCAM."""
|
|
# Créer 35 actes CCAM (au-dessus de la limite de 30)
|
|
many_ccam = [sample_ccam_code_with_date] * 35
|
|
|
|
proposal = CodingProposal(
|
|
stay_id="stay_001",
|
|
dp=sample_dp_code,
|
|
dr=None,
|
|
das=[sample_das_code],
|
|
ccam=many_ccam,
|
|
reasoning="Codage avec beaucoup d'actes",
|
|
model_version=model_version,
|
|
prompt_version="v1.0",
|
|
)
|
|
|
|
validator = GroupageValidator(groupage_version="2026")
|
|
result = validator.validate_groupage(proposal, stay_metadata)
|
|
|
|
# Vérifier qu'un avertissement est généré
|
|
review_errors = [
|
|
err for err in result.groupage_errors if err.severity == "a_revoir"
|
|
]
|
|
assert len(review_errors) >= 1
|
|
assert any("Nombre élevé d'actes CCAM" in err.message for err in review_errors)
|
|
|
|
|
|
class TestGHMGHSGeneration:
|
|
"""Tests de la génération de GHM/GHS (mock)."""
|
|
|
|
def test_ghm_format(self, coding_proposal_complete, stay_metadata):
|
|
"""Test que le GHM généré a le bon format."""
|
|
validator = GroupageValidator(groupage_version="2026")
|
|
result = validator.validate_groupage(coding_proposal_complete, stay_metadata)
|
|
|
|
# Format GHM: 2 chiffres + 1 lettre + 2 chiffres (ex: "05K02")
|
|
assert result.ghm is not None
|
|
assert len(result.ghm) == 5
|
|
assert result.ghm[:2].isdigit()
|
|
assert result.ghm[2].isalpha()
|
|
assert result.ghm[3:].isdigit()
|
|
|
|
def test_ghs_generated(self, coding_proposal_complete, stay_metadata):
|
|
"""Test que le GHS est généré."""
|
|
validator = GroupageValidator(groupage_version="2026")
|
|
result = validator.validate_groupage(coding_proposal_complete, stay_metadata)
|
|
|
|
assert result.ghs is not None
|
|
assert len(result.ghs) > 0
|
|
|
|
def test_ghm_deterministic(self, coding_proposal_complete, stay_metadata):
|
|
"""Test que le GHM est déterministe pour les mêmes codes."""
|
|
validator = GroupageValidator(groupage_version="2026")
|
|
|
|
result1 = validator.validate_groupage(coding_proposal_complete, stay_metadata)
|
|
result2 = validator.validate_groupage(coding_proposal_complete, stay_metadata)
|
|
|
|
# Le GHM devrait être identique pour les mêmes codes
|
|
assert result1.ghm == result2.ghm
|
|
assert result1.ghs == result2.ghs
|
|
|
|
|
|
class TestVersionVerification:
|
|
"""Tests de la vérification de version FG."""
|
|
|
|
def test_version_matches_year(self, coding_proposal_complete):
|
|
"""Test que la version FG correspond à l'année du séjour."""
|
|
stay_metadata = StayMetadata(
|
|
stay_id="stay_001",
|
|
admission_date=datetime(2026, 1, 15),
|
|
discharge_date=datetime(2026, 1, 20),
|
|
age=45,
|
|
sex="M",
|
|
specialty="Chirurgie",
|
|
)
|
|
|
|
validator = GroupageValidator(groupage_version="2026")
|
|
result = validator.validate_groupage(coding_proposal_complete, stay_metadata)
|
|
|
|
# Pas d'exception levée, la version correspond
|
|
assert result.groupage_version == "2026"
|
|
|
|
def test_version_mismatch_raises_error(self, coding_proposal_complete):
|
|
"""Test qu'une version FG incorrecte lève une erreur."""
|
|
stay_metadata = StayMetadata(
|
|
stay_id="stay_001",
|
|
admission_date=datetime(2026, 1, 15),
|
|
discharge_date=datetime(2026, 1, 20),
|
|
age=45,
|
|
sex="M",
|
|
specialty="Chirurgie",
|
|
)
|
|
|
|
validator = GroupageValidator(groupage_version="2025")
|
|
|
|
with pytest.raises(ValueError) as exc_info:
|
|
validator.validate_groupage(coding_proposal_complete, stay_metadata)
|
|
|
|
assert "2025" in str(exc_info.value)
|
|
assert "2026" in str(exc_info.value)
|
|
|
|
def test_version_recorded_in_result(self, coding_proposal_complete, stay_metadata):
|
|
"""Test que la version FG est enregistrée dans le résultat."""
|
|
validator = GroupageValidator(groupage_version="2026")
|
|
result = validator.validate_groupage(coding_proposal_complete, stay_metadata)
|
|
|
|
assert result.groupage_version == "2026"
|
|
assert isinstance(result.groupage_date, datetime)
|
|
|
|
|
|
class TestEdgeCases:
|
|
"""Tests des cas limites."""
|
|
|
|
def test_empty_proposal(self, model_version, stay_metadata):
|
|
"""Test avec une proposition vide."""
|
|
proposal = CodingProposal(
|
|
stay_id="stay_001",
|
|
dp=None,
|
|
dr=None,
|
|
das=[],
|
|
ccam=[],
|
|
reasoning="Proposition vide",
|
|
model_version=model_version,
|
|
prompt_version="v1.0",
|
|
)
|
|
|
|
validator = GroupageValidator(groupage_version="2026")
|
|
result = validator.validate_groupage(proposal, stay_metadata)
|
|
|
|
# Devrait générer une erreur bloquante pour DP manquant
|
|
assert result.ghm is None
|
|
assert result.ghs is None
|
|
blocking_errors = [
|
|
err for err in result.groupage_errors if err.severity == "bloquant"
|
|
]
|
|
assert len(blocking_errors) >= 1
|
|
|
|
def test_only_dp(self, sample_dp_code, model_version, stay_metadata):
|
|
"""Test avec seulement un DP."""
|
|
proposal = CodingProposal(
|
|
stay_id="stay_001",
|
|
dp=sample_dp_code,
|
|
dr=None,
|
|
das=[],
|
|
ccam=[],
|
|
reasoning="Seulement DP",
|
|
model_version=model_version,
|
|
prompt_version="v1.0",
|
|
)
|
|
|
|
validator = GroupageValidator(groupage_version="2026")
|
|
result = validator.validate_groupage(proposal, stay_metadata)
|
|
|
|
# Devrait réussir avec seulement un DP
|
|
assert result.ghm is not None
|
|
assert result.ghs is not None
|
|
assert len(result.ccam_date_errors) == 0
|
|
|
|
def test_multiple_ccam_some_without_dates(
|
|
self, sample_ccam_code_with_date, sample_ccam_code_without_date
|
|
):
|
|
"""Test avec plusieurs codes CCAM, certains sans dates."""
|
|
validator = GroupageValidator()
|
|
|
|
# Créer un deuxième code sans date
|
|
ccam_without_date_2 = Code(
|
|
code="ZZZZ999",
|
|
label="Autre acte sans date",
|
|
type="ccam",
|
|
evidence=[sample_ccam_code_without_date.evidence[0]],
|
|
confidence=0.7,
|
|
reasoning="Acte mentionné",
|
|
referentiel_version="2026",
|
|
)
|
|
|
|
missing_dates = validator.check_ccam_dates(
|
|
[
|
|
sample_ccam_code_with_date,
|
|
sample_ccam_code_without_date,
|
|
ccam_without_date_2,
|
|
]
|
|
)
|
|
|
|
assert len(missing_dates) == 2
|
|
assert "YYYY001" in missing_dates
|
|
assert "ZZZZ999" in missing_dates
|