""" 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 # ── Fixtures ────────────────────────────────────────────────────────────────── 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, # 10 seconds × 125 Hz = 1250 samples "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) # ── PPGSignal Tests ─────────────────────────────────────────────────────────── class TestPPGSignalValidation: def test_valid_signal_passes_validation(self) -> None: signal = make_signal() signal.validate() # should not raise 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: # duration_seconds=0 → also fails sample count consistency with pytest.raises(InvalidSignalError): make_signal(duration_seconds=0.0, ppg_values=[0.1] * 10).validate() def test_sample_count_inconsistency_raises(self) -> None: # 1 second at 125 Hz → expect 125 samples, give 500 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: # 10% tolerance: 125 samples expected, 130 is within range signal = make_signal( duration_seconds=1.0, sampling_rate=125.0, ppg_values=[0.1] * 130, ) signal.validate() # should not raise 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 # ── BPPrediction Tests ──────────────────────────────────────────────────────── class TestBPPredictionValidation: def test_valid_prediction_passes(self) -> None: make_prediction().validate() # should not raise 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) # MAP = 80 + (120 - 80) / 3 = 80 + 13.33 = 93.33 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"