Spaces:
Runtime error
Runtime error
| # 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, | |
| ) | |
| def canrisk_client_mock(mocker) -> CanRiskClient: | |
| return mocker.create_autospec(CanRiskClient, instance=True) | |
| 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()) | |
| 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 | |
| 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 | |
| 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 | |