| """ |
| tests/unit/test_signal_processor.py |
| βββββββββββββββββββββββββββββββββββββ |
| Unit tests for ScipySignalProcessor. |
| |
| No external services required β SciPy + NumPy only. |
| """ |
| from __future__ import annotations |
|
|
| import numpy as np |
| import pytest |
|
|
| from src.infrastructure.processing.scipy_signal_processor import ScipySignalProcessor |
| from src.shared.constants import PPG_SEGMENT_DURATION_SEC |
|
|
|
|
| @pytest.fixture |
| def processor() -> ScipySignalProcessor: |
| return ScipySignalProcessor() |
|
|
|
|
| @pytest.fixture |
| def clean_signal() -> tuple[np.ndarray, float]: |
| """A clean 30-second sine wave at 125 Hz (2 Hz fundamental).""" |
| fs = 125.0 |
| duration = 30.0 |
| t = np.linspace(0, duration, int(fs * duration), endpoint=False) |
| signal = np.sin(2 * np.pi * 2.0 * t) |
| return signal, fs |
|
|
|
|
| |
|
|
| class TestFilterSignal: |
| def test_output_same_length_as_input( |
| self, processor: ScipySignalProcessor, clean_signal: tuple |
| ) -> None: |
| sig, fs = clean_signal |
| filtered = processor.filter_signal(sig, fs) |
| assert filtered.shape == sig.shape |
|
|
| def test_output_is_numpy_array( |
| self, processor: ScipySignalProcessor, clean_signal: tuple |
| ) -> None: |
| sig, fs = clean_signal |
| filtered = processor.filter_signal(sig, fs) |
| assert isinstance(filtered, np.ndarray) |
|
|
| def test_dc_component_removed(self, processor: ScipySignalProcessor) -> None: |
| """DC offset (0 Hz) should be removed by the bandpass filter.""" |
| fs = 125.0 |
| t = np.linspace(0, 10, int(fs * 10), endpoint=False) |
| signal = np.ones_like(t) * 5.0 |
| filtered = processor.filter_signal(signal, fs) |
| |
| assert abs(np.mean(filtered)) < 0.1 |
|
|
|
|
| |
|
|
| class TestNormalize: |
| def test_mean_near_zero( |
| self, processor: ScipySignalProcessor, clean_signal: tuple |
| ) -> None: |
| sig, _ = clean_signal |
| normed = processor.normalize(sig) |
| assert abs(np.mean(normed)) < 1e-6 |
|
|
| def test_std_near_one( |
| self, processor: ScipySignalProcessor, clean_signal: tuple |
| ) -> None: |
| sig, _ = clean_signal |
| normed = processor.normalize(sig) |
| assert abs(np.std(normed) - 1.0) < 1e-6 |
|
|
| def test_constant_signal_returns_zeros(self, processor: ScipySignalProcessor) -> None: |
| constant = np.ones(100) * 3.0 |
| result = processor.normalize(constant) |
| assert np.allclose(result, 0.0) |
|
|
|
|
| |
|
|
| class TestSegment: |
| def test_segment_shape( |
| self, processor: ScipySignalProcessor, clean_signal: tuple |
| ) -> None: |
| sig, fs = clean_signal |
| segments = processor.segment(sig, fs) |
| window_size = int(PPG_SEGMENT_DURATION_SEC * fs) |
| expected_n = len(sig) // window_size |
| assert segments.shape == (expected_n, window_size) |
|
|
| def test_too_short_signal_returns_empty(self, processor: ScipySignalProcessor) -> None: |
| fs = 125.0 |
| short_signal = np.zeros(10) |
| segments = processor.segment(short_signal, fs) |
| assert segments.shape[0] == 0 |
|
|
| def test_segments_are_contiguous( |
| self, processor: ScipySignalProcessor, clean_signal: tuple |
| ) -> None: |
| sig, fs = clean_signal |
| segments = processor.segment(sig, fs) |
| window_size = int(PPG_SEGMENT_DURATION_SEC * fs) |
| |
| np.testing.assert_array_equal(segments[0], sig[:window_size]) |
|
|
|
|
| |
|
|
| class TestProcessPipeline: |
| def test_process_returns_2d_array( |
| self, processor: ScipySignalProcessor, clean_signal: tuple |
| ) -> None: |
| sig, fs = clean_signal |
| result = processor.process(sig.tolist(), fs) |
| assert result.ndim == 2 |
|
|
| def test_process_list_input(self, processor: ScipySignalProcessor) -> None: |
| fs = 125.0 |
| raw = [0.1] * int(30 * fs) |
| result = processor.process(raw, fs) |
| assert isinstance(result, np.ndarray) |
|
|