| """ |
| tests/unit/test_entities.py |
| ββββββββββββββββββββββββββββ |
| Unit tests for domain entities: PPGSignal and BPPrediction. |
| |
| These tests require ZERO external dependencies (no DB, no broker, no HTTP). |
| They validate pure business rules and domain validation logic. |
| """ |
| from __future__ import annotations |
|
|
| import pytest |
|
|
| from src.domain.entities.ppg_signal import PPGSignal |
| from src.domain.entities.prediction import BPPrediction |
| from src.domain.exceptions.domain_exceptions import InvalidSignalError, PredictionOutOfRangeError |
|
|
|
|
| |
|
|
| def make_signal(**overrides) -> PPGSignal: |
| """Build a valid PPGSignal with optional field overrides.""" |
| defaults = { |
| "device_id": "sensor-001", |
| "user_id": "user-123", |
| "sampling_rate": 125.0, |
| "ppg_values": [0.1] * 1250, |
| "duration_seconds": 10.0, |
| } |
| defaults.update(overrides) |
| return PPGSignal(**defaults) |
|
|
|
|
| def make_prediction(**overrides) -> BPPrediction: |
| """Build a valid BPPrediction with optional field overrides.""" |
| defaults = { |
| "ppg_signal_id": "test-signal-id", |
| "predicted_sbp": 120.0, |
| "predicted_dbp": 80.0, |
| "model_version": "test-v1", |
| "inference_time_ms": 100.0, |
| } |
| defaults.update(overrides) |
| return BPPrediction(**defaults) |
|
|
|
|
| |
|
|
| class TestPPGSignalValidation: |
| def test_valid_signal_passes_validation(self) -> None: |
| signal = make_signal() |
| signal.validate() |
|
|
| def test_empty_device_id_raises(self) -> None: |
| with pytest.raises(InvalidSignalError) as exc_info: |
| make_signal(device_id="").validate() |
| assert exc_info.value.field == "device_id" |
|
|
| def test_empty_user_id_raises(self) -> None: |
| with pytest.raises(InvalidSignalError) as exc_info: |
| make_signal(user_id=" ").validate() |
| assert exc_info.value.field == "user_id" |
|
|
| def test_sampling_rate_too_low_raises(self) -> None: |
| with pytest.raises(InvalidSignalError) as exc_info: |
| make_signal(sampling_rate=10.0, ppg_values=[0.1] * 100, duration_seconds=10.0).validate() |
| assert exc_info.value.field == "sampling_rate" |
|
|
| def test_sampling_rate_too_high_raises(self) -> None: |
| with pytest.raises(InvalidSignalError) as exc_info: |
| make_signal( |
| sampling_rate=2000.0, |
| ppg_values=[0.1] * 20000, |
| duration_seconds=10.0, |
| ).validate() |
| assert exc_info.value.field == "sampling_rate" |
|
|
| def test_empty_ppg_values_raises(self) -> None: |
| with pytest.raises(InvalidSignalError) as exc_info: |
| make_signal(ppg_values=[]).validate() |
| assert exc_info.value.field == "ppg_values" |
|
|
| def test_zero_duration_raises(self) -> None: |
| |
| with pytest.raises(InvalidSignalError): |
| make_signal(duration_seconds=0.0, ppg_values=[0.1] * 10).validate() |
|
|
| def test_sample_count_inconsistency_raises(self) -> None: |
| |
| with pytest.raises(InvalidSignalError) as exc_info: |
| make_signal( |
| duration_seconds=1.0, |
| sampling_rate=125.0, |
| ppg_values=[0.1] * 500, |
| ).validate() |
| assert exc_info.value.field == "ppg_values" |
|
|
| def test_sample_count_within_tolerance_passes(self) -> None: |
| |
| signal = make_signal( |
| duration_seconds=1.0, |
| sampling_rate=125.0, |
| ppg_values=[0.1] * 130, |
| ) |
| signal.validate() |
|
|
|
|
| class TestPPGSignalProperties: |
| def test_num_samples(self) -> None: |
| signal = make_signal(ppg_values=[0.1] * 500) |
| assert signal.num_samples == 500 |
|
|
| def test_mean_value(self) -> None: |
| signal = make_signal(ppg_values=[1.0, 2.0, 3.0] * 500) |
| assert abs(signal.mean_value - 2.0) < 1e-6 |
|
|
| def test_is_valid_true_for_valid_signal(self) -> None: |
| assert make_signal().is_valid is True |
|
|
| def test_is_valid_false_for_invalid_signal(self) -> None: |
| assert make_signal(device_id="").is_valid is False |
|
|
| def test_to_dict_round_trip(self) -> None: |
| signal = make_signal() |
| d = signal.to_dict() |
| restored = PPGSignal.from_dict(d) |
| assert restored.device_id == signal.device_id |
| assert restored.user_id == signal.user_id |
| assert restored.sampling_rate == signal.sampling_rate |
| assert restored.ppg_values == signal.ppg_values |
|
|
|
|
| |
|
|
| class TestBPPredictionValidation: |
| def test_valid_prediction_passes(self) -> None: |
| make_prediction().validate() |
|
|
| def test_sbp_too_low_raises(self) -> None: |
| with pytest.raises(PredictionOutOfRangeError): |
| make_prediction(predicted_sbp=50.0).validate() |
|
|
| def test_sbp_too_high_raises(self) -> None: |
| with pytest.raises(PredictionOutOfRangeError): |
| make_prediction(predicted_sbp=270.0).validate() |
|
|
| def test_dbp_too_low_raises(self) -> None: |
| with pytest.raises(PredictionOutOfRangeError): |
| make_prediction(predicted_dbp=20.0).validate() |
|
|
| def test_dbp_too_high_raises(self) -> None: |
| with pytest.raises(PredictionOutOfRangeError): |
| make_prediction(predicted_dbp=165.0).validate() |
|
|
| def test_sbp_less_than_dbp_raises(self) -> None: |
| with pytest.raises(PredictionOutOfRangeError): |
| make_prediction(predicted_sbp=70.0, predicted_dbp=80.0).validate() |
|
|
|
|
| class TestBPPredictionProperties: |
| def test_mean_arterial_pressure(self) -> None: |
| p = make_prediction(predicted_sbp=120.0, predicted_dbp=80.0) |
| |
| assert abs(p.mean_arterial_pressure - 93.33) < 0.1 |
|
|
| def test_pulse_pressure(self) -> None: |
| p = make_prediction(predicted_sbp=120.0, predicted_dbp=80.0) |
| assert p.pulse_pressure == pytest.approx(40.0) |
|
|
| def test_hypertension_stage_normal(self) -> None: |
| assert make_prediction(predicted_sbp=115.0, predicted_dbp=75.0).hypertension_stage == "Normal" |
|
|
| def test_hypertension_stage_elevated(self) -> None: |
| assert make_prediction(predicted_sbp=125.0, predicted_dbp=75.0).hypertension_stage == "Elevated" |
|
|
| def test_hypertension_stage_stage1(self) -> None: |
| assert make_prediction(predicted_sbp=132.0, predicted_dbp=82.0).hypertension_stage == "Stage 1" |
|
|
| def test_hypertension_stage_stage2(self) -> None: |
| assert make_prediction(predicted_sbp=145.0, predicted_dbp=92.0).hypertension_stage == "Stage 2" |
|
|
| def test_hypertension_stage_crisis(self) -> None: |
| assert make_prediction(predicted_sbp=185.0, predicted_dbp=125.0).hypertension_stage == "Crisis" |
|
|