# pylint: disable=missing-docstring """Tests for the CRC-PRO colorectal cancer risk model. Web calculator available at: https://riskcalc.org/ColorectalCancer/ """ import pytest from sentinel.risk_models import CRCProRiskModel from sentinel.user_input import ( AlcoholConsumption, Anthropometrics, AspirinUse, CancerType, Demographics, Ethnicity, FamilyMemberCancer, FamilyRelation, FamilySide, FemaleSpecific, HormoneUse, HormoneUseHistory, Lifestyle, NSAIDUse, PersonalMedicalHistory, RelationshipDegree, Sex, SmokingHistory, SmokingStatus, UserInput, ) GROUND_TRUTH_CASES = [ { "name": "low_risk", "input": UserInput( demographics=Demographics( age_years=55, sex=Sex.FEMALE, ethnicity=Ethnicity.ASIAN, anthropometrics=Anthropometrics(height_cm=152.4, weight_kg=45.4), education_level=3, ), lifestyle=Lifestyle( smoking=SmokingHistory(status=SmokingStatus.NEVER, pack_years=0.0), alcohol_consumption=AlcoholConsumption.MODERATE, multivitamin_use=True, ), personal_medical_history=PersonalMedicalHistory( chronic_conditions=[], nsaid_use=NSAIDUse.NEVER, ), family_history=[], female_specific=FemaleSpecific( hormone_use=HormoneUseHistory(estrogen_use=HormoneUse.NEVER), ), ), "expected": 1.0, }, { "name": "medium_risk", "input": UserInput( demographics=Demographics( age_years=65, sex=Sex.MALE, ethnicity=Ethnicity.PACIFIC_ISLANDER, anthropometrics=Anthropometrics(height_cm=177.8, weight_kg=81.6), education_level=1, ), lifestyle=Lifestyle( smoking=SmokingHistory(status=SmokingStatus.NEVER, pack_years=0.0), alcohol_consumption=AlcoholConsumption.HEAVY, multivitamin_use=True, moderate_physical_activity_hours_per_day=0.0, red_meat_consumption_oz_per_day=1.0, ), personal_medical_history=PersonalMedicalHistory( chronic_conditions=[], aspirin_use=AspirinUse.NEVER, ), family_history=[ FamilyMemberCancer( relation=FamilyRelation.FATHER, cancer_type=CancerType.COLORECTAL, age_at_diagnosis=60, degree=RelationshipDegree.FIRST, side=FamilySide.PATERNAL, ) ], ), "expected": 3.8, }, { "name": "high_risk", "input": UserInput( demographics=Demographics( age_years=75, sex=Sex.MALE, ethnicity=Ethnicity.WHITE, anthropometrics=Anthropometrics(height_cm=177.8, weight_kg=158.8), education_level=5, ), lifestyle=Lifestyle( smoking=SmokingHistory(status=SmokingStatus.FORMER, pack_years=25.0), alcohol_consumption=AlcoholConsumption.HEAVY, multivitamin_use=True, moderate_physical_activity_hours_per_day=0.0, red_meat_consumption_oz_per_day=1.0, ), personal_medical_history=PersonalMedicalHistory( chronic_conditions=[], aspirin_use=AspirinUse.NEVER, ), family_history=[ FamilyMemberCancer( relation=FamilyRelation.MOTHER, cancer_type=CancerType.COLORECTAL, age_at_diagnosis=70, degree=RelationshipDegree.FIRST, side=FamilySide.MATERNAL, ) ], ), "expected": 8.9, }, ] def _base_user(sex: Sex, **overrides): demographics = Demographics( age_years=50, sex=sex, ethnicity=Ethnicity.WHITE, anthropometrics=Anthropometrics(height_cm=170.0, weight_kg=75.0), education_level=4, ) lifestyle = Lifestyle( smoking=SmokingHistory(status=SmokingStatus.FORMER, pack_years=10.0), alcohol_consumption=AlcoholConsumption.MODERATE, multivitamin_use=True, ) personal_history = PersonalMedicalHistory(chronic_conditions=[]) if sex == Sex.FEMALE: lifestyle.moderate_physical_activity_hours_per_day = None lifestyle.red_meat_consumption_oz_per_day = None personal_history.aspirin_use = None personal_history.nsaid_use = NSAIDUse.NEVER female_specific = FemaleSpecific( hormone_use=HormoneUseHistory(estrogen_use=HormoneUse.NEVER) ) else: lifestyle.moderate_physical_activity_hours_per_day = 1.0 lifestyle.red_meat_consumption_oz_per_day = 1.5 personal_history.aspirin_use = AspirinUse.NEVER personal_history.nsaid_use = None female_specific = None family_history = overrides.get("family_history", []) return UserInput( demographics=demographics, lifestyle=lifestyle, personal_medical_history=personal_history, family_history=family_history, female_specific=female_specific, ) class TestCRCProRiskModel: def setup_method(self): self.model = CRCProRiskModel() @pytest.mark.parametrize("case", GROUND_TRUTH_CASES, ids=lambda case: case["name"]) def test_ground_truth_validation(self, case): user = case["input"] score = self.model.compute_score(user) # scores return a string; ensure numeric and close to expected calculated = float(score) assert calculated == pytest.approx(case["expected"], abs=0.5) def test_metadata(self): assert self.model.name == "crc_pro" assert self.model.cancer_type() == "colorectal" assert "CRC-PRO" in self.model.description() assert "%" in self.model.interpretation() refs = self.model.references() assert isinstance(refs, list) and refs def test_validation_errors(self): """Test that model raises ValueError for invalid inputs.""" # Test missing required field user_input = UserInput( demographics=Demographics( age_years=50, sex=Sex.FEMALE, ethnicity=Ethnicity.WHITE, anthropometrics=Anthropometrics(height_cm=165.0, weight_kg=65.0), ), lifestyle=Lifestyle( smoking=SmokingHistory( status=SmokingStatus.NEVER, pack_years=None ), # Missing pack_years multivitamin_use=True, ), personal_medical_history=PersonalMedicalHistory( nsaid_use=NSAIDUse.NEVER, ), family_history=[], female_specific=FemaleSpecific( hormone_use=HormoneUseHistory(estrogen_use=HormoneUse.NEVER), ), ) with pytest.raises(ValueError, match=r"Invalid inputs for CRC-PRO:"): self.model.compute_score(user_input) def test_inapplicable_sex(self): """Test unsupported sex returns N/A.""" user_input = UserInput( demographics=Demographics( age_years=50, sex=Sex.UNKNOWN, # Unsupported sex ethnicity=Ethnicity.WHITE, anthropometrics=Anthropometrics(height_cm=175.0, weight_kg=70.0), ), lifestyle=Lifestyle( smoking=SmokingHistory(status=SmokingStatus.NEVER, pack_years=0.0), multivitamin_use=True, ), personal_medical_history=PersonalMedicalHistory(), family_history=[], ) score = self.model.compute_score(user_input) assert "N/A" in score def test_age_out_of_range(self): """Test age outside validated range raises ValueError.""" user = _base_user(Sex.MALE) user.demographics.age_years = 44 # Below minimum with pytest.raises(ValueError, match=r"Invalid inputs for CRC-PRO:"): self.model.compute_score(user) def test_missing_ethnicity(self): """Test missing ethnicity returns N/A.""" user = _base_user(Sex.MALE) user.demographics.ethnicity = None result = self.model.compute_score(user) assert "Ethnicity" in result @pytest.mark.parametrize("sex", [Sex.MALE, Sex.FEMALE]) def test_valid_score(self, sex): """Test that valid inputs produce numeric scores. Args: sex: Sex enum value to test. """ user = _base_user(sex) score = self.model.compute_score(user) assert score not in ("N/A: Missing required data:") assert float(score) >= 0 def test_family_history_detection(self): """Test that family history increases risk score.""" user = _base_user( Sex.FEMALE, family_history=[ FamilyMemberCancer( relation=FamilyRelation.MOTHER, cancer_type=CancerType.COLORECTAL, age_at_diagnosis=60, degree=RelationshipDegree.FIRST, side=FamilySide.MATERNAL, ), ], ) score = float(self.model.compute_score(user)) user_without_family_history = _base_user(Sex.FEMALE) score_no_family = float(self.model.compute_score(user_without_family_history)) assert score > score_no_family