"""TDD — Moteur de qualification des décisions. Source de vérité exécutable des règles métier du tunnel "Décider". Règles testées : R1 within_mandate → individual + consultation_avis R2 within_mandate → aucune modalité de vote + consignation Observatoire R4 2 ≤ affected_count ≤ small_group_max → individual recommandé, collectif disponible R5 small_group_max < affected_count ≤ collective_wot_min → collective recommandé R6 affected_count > collective_wot_min → collective recommandé (WoT applicable, non obligatoire) R7 is_structural → recommend_onchain + raison explicite R8 is_structural=False → recommend_onchain=False GARDE-FOUS (invariants internes qui ne doivent jamais régresser) : G1 decision_type est toujours dans l'enum autorisé G2 individual n'expose jamais de modalités de vote G3 collective expose au moins une modalité G4 les seuils sont lus depuis QualificationConfig (configurables) """ from __future__ import annotations import pytest from app.engine.qualifier import ( DecisionType, QualificationConfig, QualificationInput, QualificationResult, qualify, ) DEFAULT_CONFIG = QualificationConfig() # --------------------------------------------------------------------------- # R1 — within_mandate → individual + consultation_avis # --------------------------------------------------------------------------- def test_r1_within_mandate_gives_individual(): result = qualify(QualificationInput(within_mandate=True), DEFAULT_CONFIG) assert result.decision_type == DecisionType.INDIVIDUAL def test_r1_within_mandate_gives_consultation_avis(): result = qualify(QualificationInput(within_mandate=True), DEFAULT_CONFIG) assert result.process == "consultation_avis" def test_r1_within_mandate_overrides_large_affected_count(): """Même si de nombreuses personnes sont concernées, un mandat impose individual.""" result = qualify( QualificationInput(within_mandate=True, affected_count=500), DEFAULT_CONFIG, ) assert result.decision_type == DecisionType.INDIVIDUAL assert result.process == "consultation_avis" # --------------------------------------------------------------------------- # R2 — within_mandate → aucune modalité de vote + consignation Observatoire # --------------------------------------------------------------------------- def test_r2_within_mandate_no_vote_modalities(): """Le mandataire décide seul après consultation — pas de vote collectif.""" result = qualify(QualificationInput(within_mandate=True), DEFAULT_CONFIG) assert result.recommended_modalities == [] def test_r2_within_mandate_records_in_observatory(): """Une décision dans un mandat doit être consignée dans l'Observatoire.""" result = qualify(QualificationInput(within_mandate=True), DEFAULT_CONFIG) assert result.record_in_observatory is True def test_r2_out_of_mandate_does_not_force_observatory(): """Hors mandat, la consignation dans l'Observatoire n'est pas imposée.""" result = qualify( QualificationInput(within_mandate=False, affected_count=10), DEFAULT_CONFIG, ) assert result.record_in_observatory is False # --------------------------------------------------------------------------- # R4 — 2 ≤ affected_count ≤ small_group_max → individual recommandé # --------------------------------------------------------------------------- def test_r4_small_group_recommends_individual(): for count in range(2, DEFAULT_CONFIG.small_group_max + 1): result = qualify( QualificationInput(within_mandate=False, affected_count=count), DEFAULT_CONFIG, ) assert result.decision_type == DecisionType.INDIVIDUAL, ( f"affected_count={count} devrait recommander individual" ) def test_r4_small_group_collective_is_available(): result = qualify( QualificationInput(within_mandate=False, affected_count=3), DEFAULT_CONFIG, ) assert result.collective_available is True def test_r4_small_group_confidence_is_recommended(): result = qualify( QualificationInput(within_mandate=False, affected_count=3), DEFAULT_CONFIG, ) assert result.confidence == "recommended" # --------------------------------------------------------------------------- # R5 — small_group_max < affected_count ≤ collective_wot_min → collective recommandé # --------------------------------------------------------------------------- def test_r5_medium_group_recommends_collective(): mid = DEFAULT_CONFIG.small_group_max + 1 result = qualify( QualificationInput(within_mandate=False, affected_count=mid), DEFAULT_CONFIG, ) assert result.decision_type == DecisionType.COLLECTIVE def test_r5_medium_group_confidence_is_recommended(): mid = DEFAULT_CONFIG.small_group_max + 1 result = qualify( QualificationInput(within_mandate=False, affected_count=mid), DEFAULT_CONFIG, ) assert result.confidence == "recommended" # --------------------------------------------------------------------------- # R6 — affected_count > collective_wot_min → collective recommandé (pas obligatoire) # --------------------------------------------------------------------------- def test_r6_large_group_recommends_collective(): result = qualify( QualificationInput(within_mandate=False, affected_count=DEFAULT_CONFIG.collective_wot_min + 1), DEFAULT_CONFIG, ) assert result.decision_type == DecisionType.COLLECTIVE def test_r6_large_group_confidence_is_recommended_not_required(): """Au-delà du seuil WoT, le vote collectif est recommandé — pas imposé.""" result = qualify( QualificationInput(within_mandate=False, affected_count=DEFAULT_CONFIG.collective_wot_min + 1), DEFAULT_CONFIG, ) assert result.confidence == "recommended" def test_r6_large_group_includes_vote_wot_modality(): result = qualify( QualificationInput(within_mandate=False, affected_count=DEFAULT_CONFIG.collective_wot_min + 1), DEFAULT_CONFIG, ) assert "vote_wot" in result.recommended_modalities # --------------------------------------------------------------------------- # R7 — is_structural=True → recommend_onchain + raison # --------------------------------------------------------------------------- def test_r7_structural_recommends_onchain(): result = qualify( QualificationInput(within_mandate=False, affected_count=100, is_structural=True), DEFAULT_CONFIG, ) assert result.recommend_onchain is True def test_r7_structural_provides_onchain_reason(): result = qualify( QualificationInput(within_mandate=False, affected_count=100, is_structural=True), DEFAULT_CONFIG, ) assert result.onchain_reason is not None and len(result.onchain_reason) > 0 def test_r7_structural_within_mandate_can_also_recommend_onchain(): """Même une décision dans un mandat peut être gravée si structurante.""" result = qualify( QualificationInput(within_mandate=True, is_structural=True), DEFAULT_CONFIG, ) assert result.recommend_onchain is True # --------------------------------------------------------------------------- # R8 — is_structural=False → recommend_onchain=False # --------------------------------------------------------------------------- def test_r8_non_structural_never_recommends_onchain(): for count in [2, 3, 10, 100]: result = qualify( QualificationInput(within_mandate=False, affected_count=count, is_structural=False), DEFAULT_CONFIG, ) assert result.recommend_onchain is False, ( f"affected_count={count} non structurant : on-chain ne doit pas être proposé" ) # --------------------------------------------------------------------------- # GARDE-FOUS internes (régressions silencieuses) # --------------------------------------------------------------------------- def test_g1_decision_type_always_valid(): valid_types = set(DecisionType) for inp in [ QualificationInput(within_mandate=True), QualificationInput(within_mandate=False, affected_count=2), QualificationInput(within_mandate=False, affected_count=10), QualificationInput(within_mandate=False, affected_count=100), ]: result = qualify(inp, DEFAULT_CONFIG) assert result.decision_type in valid_types def test_g2_individual_never_has_vote_modalities(): for inp in [ QualificationInput(within_mandate=True), QualificationInput(within_mandate=False, affected_count=2), QualificationInput(within_mandate=False, affected_count=3), ]: result = qualify(inp, DEFAULT_CONFIG) if result.decision_type == DecisionType.INDIVIDUAL: assert result.recommended_modalities == [], ( f"Individual ne doit pas exposer de modalités : {inp}" ) def test_g3_collective_has_at_least_one_modality(): result = qualify( QualificationInput(within_mandate=False, affected_count=20), DEFAULT_CONFIG, ) assert result.decision_type == DecisionType.COLLECTIVE assert len(result.recommended_modalities) >= 1 def test_g4_custom_config_overrides_thresholds(): """Les seuils viennent de QualificationConfig — pas de constantes hardcodées.""" custom = QualificationConfig(small_group_max=2, collective_wot_min=10) result = qualify( QualificationInput(within_mandate=False, affected_count=3), custom, ) assert result.decision_type == DecisionType.COLLECTIVE def test_g4_default_thresholds_are_stable(): cfg = QualificationConfig() assert cfg.small_group_max == 5 assert cfg.collective_wot_min == 50