# pylint: disable=missing-docstring """Lean coverage for the BOADICEA breast cancer risk model.""" from typing import Any import pytest from sentinel.api_clients.canrisk import BOADICEAInput, CanRiskAPIError, CanRiskClient from sentinel.risk_models.boadicea import BOADICEARiskModel from sentinel.user_input import ( Anthropometrics, BreastHealthHistory, CancerType, Demographics, Ethnicity, FamilyMemberCancer, FamilyRelation, FamilySide, FemaleSpecific, GeneticMutation, HormoneUse, HormoneUseHistory, Lifestyle, MenstrualHistory, ParityHistory, PersonalMedicalHistory, RelationshipDegree, Sex, SmokingHistory, SmokingStatus, UserInput, ) @pytest.fixture def canrisk_client_mock(mocker) -> CanRiskClient: return mocker.create_autospec(CanRiskClient, instance=True) @pytest.fixture def boadicea_model(canrisk_client_mock: CanRiskClient) -> BOADICEARiskModel: return BOADICEARiskModel(client=canrisk_client_mock) def _canrisk_payload(percent: float) -> dict[str, Any]: return { "pedigree_result": [ { "ten_yr_cancer_risk": [ { "age": 50, "breast cancer risk": { "decimal": percent / 100, "percent": percent, }, } ], "cancer_risks": [ { "age": 50, "breast cancer risk": { "decimal": percent / 100, "percent": percent, }, } ], } ] } def _baseline_user( mutations: list[GeneticMutation] | None = None, ethnicity: Ethnicity | None = Ethnicity.WHITE, ) -> UserInput: return UserInput( demographics=Demographics( age_years=45, sex=Sex.FEMALE, ethnicity=ethnicity, anthropometrics=Anthropometrics(height_cm=165.0, weight_kg=65.0), ), lifestyle=Lifestyle( smoking=SmokingHistory(status=SmokingStatus.NEVER), ), personal_medical_history=PersonalMedicalHistory( genetic_mutations=mutations or [] ), female_specific=FemaleSpecific( menstrual=MenstrualHistory(age_at_menarche=13), parity=ParityHistory( num_live_births=2, age_at_first_live_birth=28, ), breast_health=BreastHealthHistory(), ), family_history=[ FamilyMemberCancer( relation=FamilyRelation.MOTHER, cancer_type=CancerType.BREAST, age_at_diagnosis=52, degree=RelationshipDegree.FIRST, side=FamilySide.MATERNAL, ) ], ) def test_model_metadata(boadicea_model: BOADICEARiskModel) -> None: assert boadicea_model.name == "boadicea" assert boadicea_model.cancer_type() == "breast" description = boadicea_model.description().lower() interpretation = boadicea_model.interpretation().lower() assert "boadicea" in description assert "genetic" in description and "genetic" in interpretation assert any("CanRisk" in ref for ref in boadicea_model.references()) @pytest.mark.parametrize( "user, expected", [ ( UserInput( demographics=Demographics( age_years=40, sex=Sex.MALE, anthropometrics=Anthropometrics(height_cm=175.0, weight_kg=70.0), ), lifestyle=Lifestyle( smoking=SmokingHistory(status=SmokingStatus.NEVER), ), personal_medical_history=PersonalMedicalHistory(), ), "N/A: BOADICEA model is only applicable to female patients.", ), ( UserInput( demographics=Demographics( age_years=40, sex=Sex.FEMALE, anthropometrics=Anthropometrics(height_cm=165.0, weight_kg=65.0), ), lifestyle=Lifestyle( smoking=SmokingHistory(status=SmokingStatus.NEVER), ), personal_medical_history=PersonalMedicalHistory( genetic_mutations=[GeneticMutation.BRCA1] ), ), "N/A: Missing female-specific information required for BOADICEA.", ), ], ) def test_ineligible_patients_return_messages( boadicea_model: BOADICEARiskModel, user: UserInput, expected: str ) -> None: # For male patients, validation now raises ValueError instead of returning N/A if user.demographics.sex == Sex.MALE: with pytest.raises(ValueError) as exc_info: boadicea_model.compute_score(user) assert "Invalid inputs for BOADICEA" in str(exc_info.value) assert "must be FEMALE" in str(exc_info.value) else: assert boadicea_model.compute_score(user) == expected @pytest.mark.parametrize( "mutations, expected_brca1, expected_brca2", [ ([GeneticMutation.BRCA1], True, False), ([GeneticMutation.BRCA2], False, True), ([], False, False), ], ) def test_brca_flag_detection( boadicea_model: BOADICEARiskModel, canrisk_client_mock: CanRiskClient, mutations: list[GeneticMutation], expected_brca1: bool, expected_brca2: bool, ) -> None: canrisk_client_mock.submit_boadicea_assessment.return_value = _canrisk_payload(12.5) user = _baseline_user(mutations) score = boadicea_model.compute_score(user) assert score == "12.5%" boadicea_input = canrisk_client_mock.submit_boadicea_assessment.call_args.args[0] assert boadicea_input.brca1_mutation is expected_brca1 assert boadicea_input.brca2_mutation is expected_brca2 def test_successful_request_populates_payload( boadicea_model: BOADICEARiskModel, canrisk_client_mock: CanRiskClient, ) -> None: canrisk_client_mock.submit_boadicea_assessment.return_value = _canrisk_payload(18.0) user = UserInput( demographics=Demographics( age_years=42, sex=Sex.FEMALE, ethnicity=Ethnicity.ASHKENAZI_JEWISH, anthropometrics=Anthropometrics(height_cm=165.0, weight_kg=65.0), ), lifestyle=Lifestyle( smoking=SmokingHistory(status=SmokingStatus.NEVER), ), personal_medical_history=PersonalMedicalHistory( genetic_mutations=[GeneticMutation.BRCA1, GeneticMutation.BRCA2] ), female_specific=FemaleSpecific( menstrual=MenstrualHistory(age_at_menarche=13), parity=ParityHistory( num_live_births=1, age_at_first_live_birth=28, ), breast_health=BreastHealthHistory(), ), family_history=[ FamilyMemberCancer( relation=FamilyRelation.MOTHER, cancer_type=CancerType.BREAST, age_at_diagnosis=52, degree=RelationshipDegree.FIRST, side=FamilySide.MATERNAL, ), FamilyMemberCancer( relation=FamilyRelation.SISTER, cancer_type=CancerType.OVARIAN, age_at_diagnosis=48, degree=RelationshipDegree.FIRST, side=FamilySide.MATERNAL, ), ], ) assert boadicea_model.compute_score(user) == "18.0%" boadicea_input = canrisk_client_mock.submit_boadicea_assessment.call_args.args[0] assert boadicea_input.age == 42 assert boadicea_input.ashkenazi_ancestry is True assert boadicea_input.height == 1.65 and boadicea_input.weight == 65.0 assert boadicea_input.bmi == pytest.approx(65.0 / (1.65**2), rel=1e-2) assert len(boadicea_input.family_history_breast) == 1 assert len(boadicea_input.family_history_ovarian) == 1 assert len(boadicea_input.family_history) == 2 @pytest.mark.parametrize( "exception, prefix", [ (CanRiskAPIError("service unavailable"), "N/A: API error"), (ValueError("unexpected"), "N/A: Calculation error"), ], ) def test_errors_are_surface_as_strings( boadicea_model: BOADICEARiskModel, canrisk_client_mock: CanRiskClient, exception: Exception, prefix: str, ) -> None: canrisk_client_mock.submit_boadicea_assessment.side_effect = exception user = _baseline_user([GeneticMutation.BRCA1]) score = boadicea_model.compute_score(user) assert score.startswith(prefix) assert str(exception) in score def test_response_parsing_handles_missing_ten_year_risk( boadicea_model: BOADICEARiskModel, canrisk_client_mock: CanRiskClient, ) -> None: responses = [ _canrisk_payload(9.1), { "pedigree_result": [ {"lifetime_cancer_risk": [{"breast cancer risk": {"percent": 42.0}}]} ] }, {}, ] expected = [ "9.1%", "N/A: 10-year risk not available from API response.", "N/A: 10-year risk not available from API response.", ] user = _baseline_user([GeneticMutation.BRCA1]) for response, outcome in zip(responses, expected, strict=True): canrisk_client_mock.submit_boadicea_assessment.return_value = response assert boadicea_model.compute_score(user) == outcome def test_boadicea_input_from_user_input() -> None: user = UserInput( demographics=Demographics( age_years=40, sex=Sex.FEMALE, ethnicity=Ethnicity.ASHKENAZI_JEWISH, anthropometrics=Anthropometrics(height_cm=170.0, weight_kg=60.0), ), lifestyle=Lifestyle( smoking=SmokingHistory(status=SmokingStatus.NEVER), ), personal_medical_history=PersonalMedicalHistory( genetic_mutations=[GeneticMutation.BRCA1] ), female_specific=FemaleSpecific( menstrual=MenstrualHistory( age_at_menarche=12, age_at_menopause=50, ), parity=ParityHistory( num_live_births=3, age_at_first_live_birth=25, ), hormone_use=HormoneUseHistory( estrogen_use=HormoneUse.CURRENT, ), breast_health=BreastHealthHistory(), ), family_history=[ FamilyMemberCancer( relation=FamilyRelation.MOTHER, cancer_type=CancerType.BREAST, age_at_diagnosis=45, degree=RelationshipDegree.FIRST, side=FamilySide.MATERNAL, ), FamilyMemberCancer( relation=FamilyRelation.MATERNAL_AUNT, cancer_type=CancerType.OVARIAN, age_at_diagnosis=55, degree=RelationshipDegree.SECOND, side=FamilySide.MATERNAL, ), ], ) boadicea_input = BOADICEAInput.from_user_input(user) assert boadicea_input.brca1_mutation is True assert boadicea_input.ashkenazi_ancestry is True assert boadicea_input.bmi == pytest.approx(60.0 / (1.70**2), rel=1e-2) assert len(boadicea_input.family_history) == 2 assert boadicea_input.age_at_menopause == 50 assert boadicea_input.hormone_therapy_use == "current" def test_bmi_property_handles_missing_values() -> None: assert BOADICEAInput(age=30, height=1.75, weight=70.0).bmi == pytest.approx( 22.86, rel=1e-2 ) assert BOADICEAInput(age=30).bmi is None