sentinel / tests /test_risk_models /test_boadicea_model.py
jeuko's picture
Sync from GitHub (main)
7638cbd verified
# 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