""" 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