Initial commit
This commit is contained in:
532
tests/test_groupage_validator.py
Normal file
532
tests/test_groupage_validator.py
Normal file
@@ -0,0 +1,532 @@
|
||||
"""
|
||||
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
|
||||
Reference in New Issue
Block a user