""" 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) # 2 Hz sine return signal, fs # ── filter_signal ───────────────────────────────────────────────────────────── 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 # constant DC filtered = processor.filter_signal(signal, fs) # After bandpass filtering, mean should be near zero assert abs(np.mean(filtered)) < 0.1 # ── normalize ───────────────────────────────────────────────────────────────── 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) # ── segment ─────────────────────────────────────────────────────────────────── 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) # much less than 8s × 125 = 1000 samples 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) # Check that first segment matches first window_size elements np.testing.assert_array_equal(segments[0], sig[:window_size]) # ── process (full pipeline) ─────────────────────────────────────────────────── 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)