"""Tests for the MRAT Melanoma Risk Model. Ground truth values collected from: https://mrisktool.cancer.gov/calculator.html. All scenarios assume the patient is Non-Hispanic white, matching the published MRAT scope. """ import pytest from sentinel.risk_models import MRATRiskModel from sentinel.user_input import ( Anthropometrics, ComplexionLevel, Demographics, DermatologicProfile, FemaleSmallMolesCategory, FemaleTanResponse, FrecklingIntensity, Lifestyle, MaleSmallMolesCategory, PersonalMedicalHistory, Sex, SmokingHistory, SmokingStatus, UserInput, USGeographicRegion, ) GROUND_TRUTH_CASES = [ { "name": "male_light_complexion_high_damage", "input": UserInput( demographics=Demographics( age_years=30, sex=Sex.MALE, anthropometrics=Anthropometrics(height_cm=175.0, weight_kg=70.0), ), lifestyle=Lifestyle( smoking=SmokingHistory(status=SmokingStatus.NEVER), ), personal_medical_history=PersonalMedicalHistory(), dermatologic=DermatologicProfile( region=USGeographicRegion.NORTHERN, complexion=ComplexionLevel.LIGHT, freckling=FrecklingIntensity.MILD, male_sunburn=True, # True=YES, had sunburn male_has_two_or_more_big_moles=False, # False=<2 moles male_small_moles=MaleSmallMolesCategory.LESS_THAN_SEVEN, solar_damage=False, # False=NO damage ), ), "expected": 0.02, }, { "name": "male_medium_complexion_average_moles", "input": UserInput( demographics=Demographics( age_years=45, sex=Sex.MALE, anthropometrics=Anthropometrics(height_cm=175.0, weight_kg=70.0), ), lifestyle=Lifestyle( smoking=SmokingHistory(status=SmokingStatus.NEVER), ), personal_medical_history=PersonalMedicalHistory(), dermatologic=DermatologicProfile( region=USGeographicRegion.CENTRAL, complexion=ComplexionLevel.MEDIUM, freckling=FrecklingIntensity.MODERATE, male_sunburn=False, # False=NO sunburn male_has_two_or_more_big_moles=True, # True=≥2 moles male_small_moles=MaleSmallMolesCategory.SEVEN_TO_SIXTEEN, solar_damage=True, # True=YES damage ), ), "expected": 0.54, }, { "name": "female_central_region_moderate_features", "input": UserInput( demographics=Demographics( age_years=50, sex=Sex.FEMALE, anthropometrics=Anthropometrics(height_cm=165.0, weight_kg=65.0), ), lifestyle=Lifestyle( smoking=SmokingHistory(status=SmokingStatus.NEVER), ), personal_medical_history=PersonalMedicalHistory(), dermatologic=DermatologicProfile( region=USGeographicRegion.SOUTHERN, complexion=ComplexionLevel.MEDIUM, freckling=FrecklingIntensity.MODERATE, female_tan=FemaleTanResponse.MODERATE, female_small_moles=FemaleSmallMolesCategory.FIVE_TO_ELEVEN, ), ), "expected": 0.16, }, { "name": "female_northern_region_severe_freckling", "input": UserInput( demographics=Demographics( age_years=65, sex=Sex.FEMALE, anthropometrics=Anthropometrics(height_cm=165.0, weight_kg=65.0), ), lifestyle=Lifestyle( smoking=SmokingHistory(status=SmokingStatus.NEVER), ), personal_medical_history=PersonalMedicalHistory(), dermatologic=DermatologicProfile( region=USGeographicRegion.NORTHERN, complexion=ComplexionLevel.LIGHT, freckling=FrecklingIntensity.SEVERE, female_tan=FemaleTanResponse.NONE, female_small_moles=FemaleSmallMolesCategory.TWELVE_OR_MORE, ), ), "expected": 1.19, }, { "name": "male_dark_complexion_extensive_moles", "input": UserInput( demographics=Demographics( age_years=55, sex=Sex.MALE, anthropometrics=Anthropometrics(height_cm=175.0, weight_kg=70.0), ), lifestyle=Lifestyle( smoking=SmokingHistory(status=SmokingStatus.NEVER), ), personal_medical_history=PersonalMedicalHistory(), dermatologic=DermatologicProfile( region=USGeographicRegion.SOUTHERN, complexion=ComplexionLevel.DARK, freckling=FrecklingIntensity.ABSENT, male_sunburn=False, # False=NO sunburn male_has_two_or_more_big_moles=False, # False=<2 moles male_small_moles=MaleSmallMolesCategory.SEVENTEEN_OR_MORE, solar_damage=False, # False=NO damage ), ), "expected": 0.52, }, ] class TestMRATModel: """Test suite for MRATRiskModel.""" def setup_method(self) -> None: """Initialise the MRAT model instance for each test.""" self.model = MRATRiskModel() @pytest.mark.parametrize("case", GROUND_TRUTH_CASES, ids=lambda x: x["name"]) def test_ground_truth_placeholders(self, case): """Check that absolute risk calculation returns a float for each scenario. Args: case (dict[str, MRATInput | float | str]): Test scenario definition. """ result = self.model.absolute_risk(case["input"]) assert isinstance(result, float) def test_compute_score_male_user(self): """Ensure male user profiles yield a percentage string from compute_score.""" user = UserInput( demographics=Demographics( age_years=42, sex=Sex.MALE, anthropometrics=Anthropometrics(height_cm=175.0, weight_kg=70.0), ), lifestyle=Lifestyle( smoking=SmokingHistory(status=SmokingStatus.NEVER), ), personal_medical_history=PersonalMedicalHistory(), dermatologic=DermatologicProfile( region=USGeographicRegion.NORTHERN, complexion=ComplexionLevel.LIGHT, freckling=FrecklingIntensity.MILD, male_sunburn=True, male_has_two_or_more_big_moles=True, male_small_moles=MaleSmallMolesCategory.SEVEN_TO_SIXTEEN, solar_damage=False, ), ) score = self.model.compute_score(user) assert score.endswith("%") def test_compute_score_female_user(self): """Ensure female user profiles yield a percentage string from compute_score.""" user = UserInput( demographics=Demographics( age_years=37, sex=Sex.FEMALE, anthropometrics=Anthropometrics(height_cm=165.0, weight_kg=65.0), ), lifestyle=Lifestyle( smoking=SmokingHistory(status=SmokingStatus.NEVER), ), personal_medical_history=PersonalMedicalHistory(), dermatologic=DermatologicProfile( region=USGeographicRegion.SOUTHERN, complexion=ComplexionLevel.MEDIUM, freckling=FrecklingIntensity.MODERATE, female_tan=FemaleTanResponse.MODERATE, female_small_moles=FemaleSmallMolesCategory.FIVE_TO_ELEVEN, ), ) score = self.model.compute_score(user) assert score.endswith("%") def test_missing_dermatologic(self): """Verify missing dermatologic information raises ValueError.""" user = UserInput( demographics=Demographics( age_years=30, sex=Sex.MALE, anthropometrics=Anthropometrics(height_cm=175.0, weight_kg=70.0), ), lifestyle=Lifestyle( smoking=SmokingHistory(status=SmokingStatus.NEVER), ), personal_medical_history=PersonalMedicalHistory(), dermatologic=None, ) with pytest.raises(ValueError, match=r"Invalid inputs for MRAT:"): self.model.compute_score(user) def test_validation_errors(self): """Test that model raises ValueError for invalid inputs.""" user_input = UserInput( demographics=Demographics( age_years=15, # Below minimum sex=Sex.MALE, anthropometrics=Anthropometrics(height_cm=175.0, weight_kg=70.0), ), lifestyle=Lifestyle( smoking=SmokingHistory(status=SmokingStatus.NEVER), ), personal_medical_history=PersonalMedicalHistory(), dermatologic=DermatologicProfile( region=USGeographicRegion.NORTHERN, complexion=ComplexionLevel.LIGHT, freckling=FrecklingIntensity.MILD, ), ) with pytest.raises(ValueError, match=r"Invalid inputs for MRAT:"): self.model.compute_score(user_input)