mirror of
https://github.com/ruvnet/RuView
synced 2026-08-07 20:01:43 +00:00
chore(repo): move v1/ → archive/v1/ + add archive/README.md (#430)
The Rust port at v2/ has been the primary codebase since the rename in #427. The Python implementation at v1/ is no longer the active target; the only load-bearing path is the deterministic proof bundle at v1/data/proof/ (per ADR-011 / ADR-028 witness verification). Move the whole Python tree into archive/v1/ and document the policy in archive/README.md: no new features, bug fixes only when they affect a still-load-bearing path (currently just the proof), CI continues to verify the proof on every push and PR. Path references updated in 26 files via path-pattern sed (only matches v1/<known-child> patterns, never bare v1 or API URLs like /api/v1/). Two double-prefix typos (archive/archive/v1/) caught and hand-fixed in verify-pipeline.yml and ADR-011. Validated: - Python proof verify.py imports cleanly at archive/v1/data/proof/ (numpy/scipy still required; CI installs requirements-lock.txt from archive/v1/ now) - cargo test --workspace --no-default-features → 1,539 passed, 0 failed, 8 ignored (unaffected by Python tree relocation) - ESP32-S3 on COM7 untouched (no firmware paths changed) After-merge: contributors should re-run any local `python v1/...` commands as `python archive/v1/...` (CLAUDE.md and CHANGELOG already updated).
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
"""Shared fixtures for unit tests."""
|
||||
|
||||
import os
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, AsyncMock, patch
|
||||
|
||||
# Set SECRET_KEY before any settings import
|
||||
os.environ.setdefault("SECRET_KEY", "test-secret-key-for-unit-tests-only")
|
||||
os.environ.setdefault("JWT_SECRET_KEY", "test-secret-key-for-unit-tests-only")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_settings():
|
||||
"""Create a mock Settings object."""
|
||||
settings = MagicMock()
|
||||
settings.secret_key = "test-secret-key-for-unit-tests-only"
|
||||
settings.jwt_algorithm = "HS256"
|
||||
settings.jwt_expire_hours = 24
|
||||
settings.app_name = "test-app"
|
||||
settings.version = "0.1.0"
|
||||
settings.is_production = False
|
||||
settings.enable_rate_limiting = False
|
||||
settings.enable_authentication = False
|
||||
settings.rate_limit_requests = 100
|
||||
settings.rate_limit_window = 60
|
||||
settings.rate_limit_authenticated_requests = 1000
|
||||
settings.allowed_hosts = ["*"]
|
||||
settings.csi_buffer_size = 100
|
||||
settings.stream_buffer_size = 100
|
||||
settings.mock_hardware = True
|
||||
settings.mock_pose_data = True
|
||||
settings.enable_real_time_processing = False
|
||||
settings.trusted_proxies = ["127.0.0.1"]
|
||||
return settings
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_domain_config():
|
||||
"""Create a mock DomainConfig object."""
|
||||
config = MagicMock()
|
||||
config.pose_estimation = MagicMock()
|
||||
config.streaming = MagicMock()
|
||||
config.hardware = MagicMock()
|
||||
return config
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_redis():
|
||||
"""Provide a mock Redis client."""
|
||||
with patch("redis.Redis") as mock:
|
||||
client = MagicMock()
|
||||
client.ping.return_value = True
|
||||
client.get.return_value = None
|
||||
client.set.return_value = True
|
||||
mock.return_value = client
|
||||
yield client
|
||||
@@ -0,0 +1,137 @@
|
||||
"""Tests for AuthMiddleware and TokenManager."""
|
||||
|
||||
import pytest
|
||||
import os
|
||||
from unittest.mock import MagicMock, AsyncMock, patch
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
|
||||
class TestTokenManager:
|
||||
def test_create_token(self, mock_settings):
|
||||
from src.middleware.auth import TokenManager
|
||||
tm = TokenManager(mock_settings)
|
||||
token = tm.create_access_token({"sub": "user1"})
|
||||
assert isinstance(token, str)
|
||||
assert len(token) > 0
|
||||
|
||||
def test_verify_valid_token(self, mock_settings):
|
||||
from src.middleware.auth import TokenManager
|
||||
tm = TokenManager(mock_settings)
|
||||
token = tm.create_access_token({"sub": "user1", "role": "admin"})
|
||||
payload = tm.verify_token(token)
|
||||
assert payload["sub"] == "user1"
|
||||
assert payload["role"] == "admin"
|
||||
|
||||
def test_verify_invalid_token(self, mock_settings):
|
||||
from src.middleware.auth import TokenManager, AuthenticationError
|
||||
tm = TokenManager(mock_settings)
|
||||
with pytest.raises(AuthenticationError):
|
||||
tm.verify_token("invalid.token.here")
|
||||
|
||||
def test_decode_claims(self, mock_settings):
|
||||
from src.middleware.auth import TokenManager
|
||||
tm = TokenManager(mock_settings)
|
||||
token = tm.create_access_token({"sub": "user1"})
|
||||
claims = tm.decode_token_claims(token)
|
||||
assert claims is not None
|
||||
assert claims["sub"] == "user1"
|
||||
|
||||
def test_decode_claims_invalid(self, mock_settings):
|
||||
from src.middleware.auth import TokenManager
|
||||
tm = TokenManager(mock_settings)
|
||||
claims = tm.decode_token_claims("bad-token")
|
||||
assert claims is None
|
||||
|
||||
def test_token_has_expiry(self, mock_settings):
|
||||
from src.middleware.auth import TokenManager
|
||||
tm = TokenManager(mock_settings)
|
||||
token = tm.create_access_token({"sub": "user1"})
|
||||
payload = tm.verify_token(token)
|
||||
assert "exp" in payload
|
||||
assert "iat" in payload
|
||||
|
||||
|
||||
class TestUserManager:
|
||||
def test_create_user(self):
|
||||
from src.middleware.auth import UserManager
|
||||
um = UserManager()
|
||||
assert um.get_user("nonexistent") is None
|
||||
|
||||
def test_hash_password(self):
|
||||
from src.middleware.auth import UserManager
|
||||
hashed = UserManager.hash_password("secret123")
|
||||
assert hashed != "secret123"
|
||||
assert len(hashed) > 20
|
||||
|
||||
def test_verify_password(self):
|
||||
from src.middleware.auth import UserManager
|
||||
hashed = UserManager.hash_password("secret123")
|
||||
assert UserManager.verify_password("secret123", hashed) is True
|
||||
assert UserManager.verify_password("wrong", hashed) is False
|
||||
|
||||
|
||||
class TestTokenBlacklist:
|
||||
def test_add_and_check(self):
|
||||
from src.api.middleware.auth import TokenBlacklist
|
||||
bl = TokenBlacklist()
|
||||
bl.add_token("tok123")
|
||||
assert bl.is_blacklisted("tok123") is True
|
||||
assert bl.is_blacklisted("tok456") is False
|
||||
|
||||
def test_blacklisted_token_rejected(self, mock_settings):
|
||||
from src.middleware.auth import TokenManager, AuthenticationError
|
||||
from src.api.middleware.auth import token_blacklist
|
||||
|
||||
tm = TokenManager(mock_settings)
|
||||
token = tm.create_access_token({"sub": "user1"})
|
||||
# Token should be valid
|
||||
tm.verify_token(token)
|
||||
# Blacklist it
|
||||
token_blacklist.add_token(token)
|
||||
with pytest.raises(AuthenticationError, match="revoked"):
|
||||
tm.verify_token(token)
|
||||
# Cleanup
|
||||
token_blacklist._blacklisted_tokens.discard(token)
|
||||
|
||||
|
||||
class TestAuthMiddleware:
|
||||
def test_public_paths(self, mock_settings):
|
||||
with patch("src.api.middleware.auth.get_settings", return_value=mock_settings):
|
||||
from src.api.middleware.auth import AuthMiddleware
|
||||
app = MagicMock()
|
||||
mw = AuthMiddleware(app)
|
||||
assert mw._is_public_path("/health") is True
|
||||
assert mw._is_public_path("/docs") is True
|
||||
assert mw._is_public_path("/api/v1/pose/analyze") is False
|
||||
|
||||
def test_protected_paths(self, mock_settings):
|
||||
with patch("src.api.middleware.auth.get_settings", return_value=mock_settings):
|
||||
from src.api.middleware.auth import AuthMiddleware
|
||||
app = MagicMock()
|
||||
mw = AuthMiddleware(app)
|
||||
assert mw._is_protected_path("/api/v1/pose/analyze") is True
|
||||
assert mw._is_protected_path("/health") is False
|
||||
|
||||
def test_extract_token_from_header(self, mock_settings):
|
||||
with patch("src.api.middleware.auth.get_settings", return_value=mock_settings):
|
||||
from src.api.middleware.auth import AuthMiddleware
|
||||
app = MagicMock()
|
||||
mw = AuthMiddleware(app)
|
||||
request = MagicMock()
|
||||
request.headers = {"authorization": "Bearer mytoken123"}
|
||||
request.query_params = {}
|
||||
request.cookies = {}
|
||||
token = mw._extract_token(request)
|
||||
assert token == "mytoken123"
|
||||
|
||||
def test_extract_token_missing(self, mock_settings):
|
||||
with patch("src.api.middleware.auth.get_settings", return_value=mock_settings):
|
||||
from src.api.middleware.auth import AuthMiddleware
|
||||
app = MagicMock()
|
||||
mw = AuthMiddleware(app)
|
||||
request = MagicMock()
|
||||
request.headers = {}
|
||||
request.query_params = {}
|
||||
request.cookies = {}
|
||||
token = mw._extract_token(request)
|
||||
assert token is None
|
||||
@@ -0,0 +1,264 @@
|
||||
import pytest
|
||||
import numpy as np
|
||||
import torch
|
||||
from unittest.mock import Mock, patch, MagicMock
|
||||
from src.hardware.csi_extractor import CSIExtractor, CSIExtractionError
|
||||
|
||||
|
||||
class TestCSIExtractor:
|
||||
"""Test suite for CSI Extractor following London School TDD principles"""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_config(self):
|
||||
"""Configuration for CSI extractor"""
|
||||
return {
|
||||
'interface': 'wlan0',
|
||||
'channel': 6,
|
||||
'bandwidth': 20,
|
||||
'sample_rate': 1000,
|
||||
'buffer_size': 1024,
|
||||
'extraction_timeout': 5.0
|
||||
}
|
||||
|
||||
@pytest.fixture
|
||||
def mock_router_interface(self):
|
||||
"""Mock router interface for testing"""
|
||||
mock_router = Mock()
|
||||
mock_router.is_connected = True
|
||||
mock_router.execute_command = Mock()
|
||||
return mock_router
|
||||
|
||||
@pytest.fixture
|
||||
def csi_extractor(self, mock_config, mock_router_interface):
|
||||
"""Create CSI extractor instance for testing"""
|
||||
return CSIExtractor(mock_config, mock_router_interface)
|
||||
|
||||
@pytest.fixture
|
||||
def mock_csi_data(self):
|
||||
"""Generate synthetic CSI data for testing"""
|
||||
# Simulate CSI data: complex values for multiple subcarriers
|
||||
num_subcarriers = 56
|
||||
num_antennas = 3
|
||||
amplitude = np.random.uniform(0.1, 2.0, (num_antennas, num_subcarriers))
|
||||
phase = np.random.uniform(-np.pi, np.pi, (num_antennas, num_subcarriers))
|
||||
return amplitude * np.exp(1j * phase)
|
||||
|
||||
def test_extractor_initialization_creates_correct_configuration(self, mock_config, mock_router_interface):
|
||||
"""Test that CSI extractor initializes with correct configuration"""
|
||||
# Act
|
||||
extractor = CSIExtractor(mock_config, mock_router_interface)
|
||||
|
||||
# Assert
|
||||
assert extractor is not None
|
||||
assert extractor.interface == mock_config['interface']
|
||||
assert extractor.channel == mock_config['channel']
|
||||
assert extractor.bandwidth == mock_config['bandwidth']
|
||||
assert extractor.sample_rate == mock_config['sample_rate']
|
||||
assert extractor.buffer_size == mock_config['buffer_size']
|
||||
assert extractor.extraction_timeout == mock_config['extraction_timeout']
|
||||
assert extractor.router_interface == mock_router_interface
|
||||
assert not extractor.is_extracting
|
||||
|
||||
def test_start_extraction_configures_monitor_mode(self, csi_extractor, mock_router_interface):
|
||||
"""Test that start_extraction configures monitor mode"""
|
||||
# Arrange
|
||||
mock_router_interface.enable_monitor_mode.return_value = True
|
||||
mock_router_interface.execute_command.return_value = "CSI extraction started"
|
||||
|
||||
# Act
|
||||
result = csi_extractor.start_extraction()
|
||||
|
||||
# Assert
|
||||
assert result is True
|
||||
assert csi_extractor.is_extracting is True
|
||||
mock_router_interface.enable_monitor_mode.assert_called_once_with(csi_extractor.interface)
|
||||
|
||||
def test_start_extraction_handles_monitor_mode_failure(self, csi_extractor, mock_router_interface):
|
||||
"""Test that start_extraction handles monitor mode configuration failure"""
|
||||
# Arrange
|
||||
mock_router_interface.enable_monitor_mode.return_value = False
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(CSIExtractionError):
|
||||
csi_extractor.start_extraction()
|
||||
|
||||
assert csi_extractor.is_extracting is False
|
||||
|
||||
def test_stop_extraction_disables_monitor_mode(self, csi_extractor, mock_router_interface):
|
||||
"""Test that stop_extraction disables monitor mode"""
|
||||
# Arrange
|
||||
mock_router_interface.enable_monitor_mode.return_value = True
|
||||
mock_router_interface.disable_monitor_mode.return_value = True
|
||||
mock_router_interface.execute_command.return_value = "CSI extraction started"
|
||||
|
||||
csi_extractor.start_extraction()
|
||||
|
||||
# Act
|
||||
result = csi_extractor.stop_extraction()
|
||||
|
||||
# Assert
|
||||
assert result is True
|
||||
assert csi_extractor.is_extracting is False
|
||||
mock_router_interface.disable_monitor_mode.assert_called_once_with(csi_extractor.interface)
|
||||
|
||||
def test_extract_csi_data_returns_valid_format(self, csi_extractor, mock_router_interface, mock_csi_data):
|
||||
"""Test that extract_csi_data returns data in valid format"""
|
||||
# Arrange
|
||||
mock_router_interface.enable_monitor_mode.return_value = True
|
||||
mock_router_interface.execute_command.return_value = "CSI extraction started"
|
||||
|
||||
# Mock the CSI data extraction
|
||||
with patch.object(csi_extractor, '_parse_csi_output', return_value=mock_csi_data):
|
||||
csi_extractor.start_extraction()
|
||||
|
||||
# Act
|
||||
csi_data = csi_extractor.extract_csi_data()
|
||||
|
||||
# Assert
|
||||
assert csi_data is not None
|
||||
assert isinstance(csi_data, np.ndarray)
|
||||
assert csi_data.dtype == np.complex128
|
||||
assert csi_data.shape == mock_csi_data.shape
|
||||
|
||||
def test_extract_csi_data_requires_active_extraction(self, csi_extractor):
|
||||
"""Test that extract_csi_data requires active extraction"""
|
||||
# Act & Assert
|
||||
with pytest.raises(CSIExtractionError):
|
||||
csi_extractor.extract_csi_data()
|
||||
|
||||
def test_extract_csi_data_handles_timeout(self, csi_extractor, mock_router_interface):
|
||||
"""Test that extract_csi_data handles extraction timeout"""
|
||||
# Arrange
|
||||
mock_router_interface.enable_monitor_mode.return_value = True
|
||||
mock_router_interface.execute_command.side_effect = [
|
||||
"CSI extraction started",
|
||||
Exception("Timeout")
|
||||
]
|
||||
|
||||
csi_extractor.start_extraction()
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(CSIExtractionError):
|
||||
csi_extractor.extract_csi_data()
|
||||
|
||||
def test_convert_to_tensor_produces_correct_format(self, csi_extractor, mock_csi_data):
|
||||
"""Test that convert_to_tensor produces correctly formatted tensor"""
|
||||
# Act
|
||||
tensor = csi_extractor.convert_to_tensor(mock_csi_data)
|
||||
|
||||
# Assert
|
||||
assert isinstance(tensor, torch.Tensor)
|
||||
assert tensor.dtype == torch.float32
|
||||
assert tensor.shape[0] == mock_csi_data.shape[0] * 2 # Real and imaginary parts
|
||||
assert tensor.shape[1] == mock_csi_data.shape[1]
|
||||
|
||||
def test_convert_to_tensor_handles_invalid_input(self, csi_extractor):
|
||||
"""Test that convert_to_tensor handles invalid input"""
|
||||
# Arrange
|
||||
invalid_data = "not an array"
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(ValueError):
|
||||
csi_extractor.convert_to_tensor(invalid_data)
|
||||
|
||||
def test_get_extraction_stats_returns_valid_statistics(self, csi_extractor, mock_router_interface):
|
||||
"""Test that get_extraction_stats returns valid statistics"""
|
||||
# Arrange
|
||||
mock_router_interface.enable_monitor_mode.return_value = True
|
||||
mock_router_interface.execute_command.return_value = "CSI extraction started"
|
||||
|
||||
csi_extractor.start_extraction()
|
||||
|
||||
# Act
|
||||
stats = csi_extractor.get_extraction_stats()
|
||||
|
||||
# Assert
|
||||
assert stats is not None
|
||||
assert isinstance(stats, dict)
|
||||
assert 'samples_extracted' in stats
|
||||
assert 'extraction_rate' in stats
|
||||
assert 'buffer_utilization' in stats
|
||||
assert 'last_extraction_time' in stats
|
||||
|
||||
def test_set_channel_configures_wifi_channel(self, csi_extractor, mock_router_interface):
|
||||
"""Test that set_channel configures WiFi channel"""
|
||||
# Arrange
|
||||
new_channel = 11
|
||||
mock_router_interface.execute_command.return_value = f"Channel set to {new_channel}"
|
||||
|
||||
# Act
|
||||
result = csi_extractor.set_channel(new_channel)
|
||||
|
||||
# Assert
|
||||
assert result is True
|
||||
assert csi_extractor.channel == new_channel
|
||||
mock_router_interface.execute_command.assert_called()
|
||||
|
||||
def test_set_channel_validates_channel_range(self, csi_extractor):
|
||||
"""Test that set_channel validates channel range"""
|
||||
# Act & Assert
|
||||
with pytest.raises(ValueError):
|
||||
csi_extractor.set_channel(0) # Invalid channel
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
csi_extractor.set_channel(15) # Invalid channel
|
||||
|
||||
def test_extractor_supports_context_manager(self, csi_extractor, mock_router_interface):
|
||||
"""Test that CSI extractor supports context manager protocol"""
|
||||
# Arrange
|
||||
mock_router_interface.enable_monitor_mode.return_value = True
|
||||
mock_router_interface.disable_monitor_mode.return_value = True
|
||||
mock_router_interface.execute_command.return_value = "CSI extraction started"
|
||||
|
||||
# Act
|
||||
with csi_extractor as extractor:
|
||||
# Assert
|
||||
assert extractor.is_extracting is True
|
||||
|
||||
# Assert - extraction should be stopped after context
|
||||
assert csi_extractor.is_extracting is False
|
||||
|
||||
def test_extractor_validates_configuration(self, mock_router_interface):
|
||||
"""Test that CSI extractor validates configuration parameters"""
|
||||
# Arrange
|
||||
invalid_config = {
|
||||
'interface': '', # Invalid interface
|
||||
'channel': 6,
|
||||
'bandwidth': 20
|
||||
}
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(ValueError):
|
||||
CSIExtractor(invalid_config, mock_router_interface)
|
||||
|
||||
def test_parse_csi_output_processes_raw_data(self, csi_extractor):
|
||||
"""Test that _parse_csi_output processes raw CSI data correctly"""
|
||||
# Arrange
|
||||
raw_output = "CSI_DATA: 1.5+0.5j,2.0-1.0j,0.8+1.2j"
|
||||
|
||||
# Act
|
||||
parsed_data = csi_extractor._parse_csi_output(raw_output)
|
||||
|
||||
# Assert
|
||||
assert parsed_data is not None
|
||||
assert isinstance(parsed_data, np.ndarray)
|
||||
assert parsed_data.dtype == np.complex128
|
||||
|
||||
def test_buffer_management_handles_overflow(self, csi_extractor, mock_router_interface, mock_csi_data):
|
||||
"""Test that buffer management handles overflow correctly"""
|
||||
# Arrange
|
||||
mock_router_interface.enable_monitor_mode.return_value = True
|
||||
mock_router_interface.execute_command.return_value = "CSI extraction started"
|
||||
|
||||
with patch.object(csi_extractor, '_parse_csi_output', return_value=mock_csi_data):
|
||||
csi_extractor.start_extraction()
|
||||
|
||||
# Fill buffer beyond capacity
|
||||
for _ in range(csi_extractor.buffer_size + 10):
|
||||
csi_extractor._add_to_buffer(mock_csi_data)
|
||||
|
||||
# Act
|
||||
stats = csi_extractor.get_extraction_stats()
|
||||
|
||||
# Assert
|
||||
assert stats['buffer_utilization'] <= 1.0 # Should not exceed 100%
|
||||
@@ -0,0 +1,588 @@
|
||||
"""Direct tests for CSI extractor avoiding import issues."""
|
||||
|
||||
import pytest
|
||||
import numpy as np
|
||||
import sys
|
||||
import os
|
||||
from unittest.mock import Mock, patch, AsyncMock, MagicMock
|
||||
from typing import Dict, Any, Optional
|
||||
import asyncio
|
||||
from datetime import datetime, timezone
|
||||
|
||||
# Add src to path for direct import
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '../../'))
|
||||
|
||||
# Import the CSI extractor module directly
|
||||
from src.hardware.csi_extractor import (
|
||||
CSIExtractor,
|
||||
CSIParseError,
|
||||
CSIData,
|
||||
ESP32CSIParser,
|
||||
RouterCSIParser,
|
||||
CSIValidationError
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.tdd
|
||||
@pytest.mark.london
|
||||
class TestCSIExtractorDirect:
|
||||
"""Test CSI extractor with direct imports."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_logger(self):
|
||||
"""Mock logger for testing."""
|
||||
return Mock()
|
||||
|
||||
@pytest.fixture
|
||||
def esp32_config(self):
|
||||
"""ESP32 configuration for testing."""
|
||||
return {
|
||||
'hardware_type': 'esp32',
|
||||
'sampling_rate': 100,
|
||||
'buffer_size': 1024,
|
||||
'timeout': 5.0,
|
||||
'validation_enabled': True,
|
||||
'retry_attempts': 3
|
||||
}
|
||||
|
||||
@pytest.fixture
|
||||
def router_config(self):
|
||||
"""Router configuration for testing."""
|
||||
return {
|
||||
'hardware_type': 'router',
|
||||
'sampling_rate': 50,
|
||||
'buffer_size': 512,
|
||||
'timeout': 10.0,
|
||||
'validation_enabled': False,
|
||||
'retry_attempts': 1
|
||||
}
|
||||
|
||||
@pytest.fixture
|
||||
def sample_csi_data(self):
|
||||
"""Sample CSI data for testing."""
|
||||
return CSIData(
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
amplitude=np.random.rand(3, 56),
|
||||
phase=np.random.rand(3, 56),
|
||||
frequency=2.4e9,
|
||||
bandwidth=20e6,
|
||||
num_subcarriers=56,
|
||||
num_antennas=3,
|
||||
snr=15.5,
|
||||
metadata={'source': 'esp32', 'channel': 6}
|
||||
)
|
||||
|
||||
# Initialization tests
|
||||
def test_should_initialize_with_valid_config(self, esp32_config, mock_logger):
|
||||
"""Should initialize CSI extractor with valid configuration."""
|
||||
extractor = CSIExtractor(config=esp32_config, logger=mock_logger)
|
||||
|
||||
assert extractor.config == esp32_config
|
||||
assert extractor.logger == mock_logger
|
||||
assert extractor.is_connected == False
|
||||
assert extractor.hardware_type == 'esp32'
|
||||
|
||||
def test_should_create_esp32_parser(self, esp32_config, mock_logger):
|
||||
"""Should create ESP32 parser when hardware_type is esp32."""
|
||||
extractor = CSIExtractor(config=esp32_config, logger=mock_logger)
|
||||
|
||||
assert isinstance(extractor.parser, ESP32CSIParser)
|
||||
|
||||
def test_should_create_router_parser(self, router_config, mock_logger):
|
||||
"""Should create router parser when hardware_type is router."""
|
||||
extractor = CSIExtractor(config=router_config, logger=mock_logger)
|
||||
|
||||
assert isinstance(extractor.parser, RouterCSIParser)
|
||||
assert extractor.hardware_type == 'router'
|
||||
|
||||
def test_should_raise_error_for_unsupported_hardware(self, mock_logger):
|
||||
"""Should raise error for unsupported hardware type."""
|
||||
invalid_config = {
|
||||
'hardware_type': 'unsupported',
|
||||
'sampling_rate': 100,
|
||||
'buffer_size': 1024,
|
||||
'timeout': 5.0
|
||||
}
|
||||
|
||||
with pytest.raises(ValueError, match="Unsupported hardware type: unsupported"):
|
||||
CSIExtractor(config=invalid_config, logger=mock_logger)
|
||||
|
||||
# Configuration validation tests
|
||||
def test_config_validation_missing_fields(self, mock_logger):
|
||||
"""Should validate required configuration fields."""
|
||||
invalid_config = {'invalid': 'config'}
|
||||
|
||||
with pytest.raises(ValueError, match="Missing required configuration"):
|
||||
CSIExtractor(config=invalid_config, logger=mock_logger)
|
||||
|
||||
def test_config_validation_negative_sampling_rate(self, mock_logger):
|
||||
"""Should validate sampling_rate is positive."""
|
||||
invalid_config = {
|
||||
'hardware_type': 'esp32',
|
||||
'sampling_rate': -1,
|
||||
'buffer_size': 1024,
|
||||
'timeout': 5.0
|
||||
}
|
||||
|
||||
with pytest.raises(ValueError, match="sampling_rate must be positive"):
|
||||
CSIExtractor(config=invalid_config, logger=mock_logger)
|
||||
|
||||
def test_config_validation_zero_buffer_size(self, mock_logger):
|
||||
"""Should validate buffer_size is positive."""
|
||||
invalid_config = {
|
||||
'hardware_type': 'esp32',
|
||||
'sampling_rate': 100,
|
||||
'buffer_size': 0,
|
||||
'timeout': 5.0
|
||||
}
|
||||
|
||||
with pytest.raises(ValueError, match="buffer_size must be positive"):
|
||||
CSIExtractor(config=invalid_config, logger=mock_logger)
|
||||
|
||||
def test_config_validation_negative_timeout(self, mock_logger):
|
||||
"""Should validate timeout is positive."""
|
||||
invalid_config = {
|
||||
'hardware_type': 'esp32',
|
||||
'sampling_rate': 100,
|
||||
'buffer_size': 1024,
|
||||
'timeout': -1.0
|
||||
}
|
||||
|
||||
with pytest.raises(ValueError, match="timeout must be positive"):
|
||||
CSIExtractor(config=invalid_config, logger=mock_logger)
|
||||
|
||||
# Connection tests
|
||||
@pytest.mark.asyncio
|
||||
async def test_should_establish_connection_successfully(self, esp32_config, mock_logger):
|
||||
"""Should establish connection to hardware successfully."""
|
||||
extractor = CSIExtractor(config=esp32_config, logger=mock_logger)
|
||||
|
||||
with patch.object(extractor, '_establish_hardware_connection', new_callable=AsyncMock) as mock_connect:
|
||||
mock_connect.return_value = True
|
||||
|
||||
result = await extractor.connect()
|
||||
|
||||
assert result == True
|
||||
assert extractor.is_connected == True
|
||||
mock_connect.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_should_handle_connection_failure(self, esp32_config, mock_logger):
|
||||
"""Should handle connection failure gracefully."""
|
||||
extractor = CSIExtractor(config=esp32_config, logger=mock_logger)
|
||||
|
||||
with patch.object(extractor, '_establish_hardware_connection', new_callable=AsyncMock) as mock_connect:
|
||||
mock_connect.side_effect = ConnectionError("Hardware not found")
|
||||
|
||||
result = await extractor.connect()
|
||||
|
||||
assert result == False
|
||||
assert extractor.is_connected == False
|
||||
extractor.logger.error.assert_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_should_disconnect_properly(self, esp32_config, mock_logger):
|
||||
"""Should disconnect from hardware properly."""
|
||||
extractor = CSIExtractor(config=esp32_config, logger=mock_logger)
|
||||
extractor.is_connected = True
|
||||
|
||||
with patch.object(extractor, '_close_hardware_connection', new_callable=AsyncMock) as mock_disconnect:
|
||||
await extractor.disconnect()
|
||||
|
||||
assert extractor.is_connected == False
|
||||
mock_disconnect.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_disconnect_when_not_connected(self, esp32_config, mock_logger):
|
||||
"""Should handle disconnect when not connected."""
|
||||
extractor = CSIExtractor(config=esp32_config, logger=mock_logger)
|
||||
extractor.is_connected = False
|
||||
|
||||
with patch.object(extractor, '_close_hardware_connection', new_callable=AsyncMock) as mock_close:
|
||||
await extractor.disconnect()
|
||||
|
||||
# Should not call close when not connected
|
||||
mock_close.assert_not_called()
|
||||
assert extractor.is_connected == False
|
||||
|
||||
# Data extraction tests
|
||||
@pytest.mark.asyncio
|
||||
async def test_should_extract_csi_data_successfully(self, esp32_config, mock_logger, sample_csi_data):
|
||||
"""Should extract CSI data successfully from hardware."""
|
||||
extractor = CSIExtractor(config=esp32_config, logger=mock_logger)
|
||||
extractor.is_connected = True
|
||||
|
||||
with patch.object(extractor, '_read_raw_data', new_callable=AsyncMock) as mock_read:
|
||||
with patch.object(extractor.parser, 'parse', return_value=sample_csi_data) as mock_parse:
|
||||
mock_read.return_value = b"raw_csi_data"
|
||||
|
||||
result = await extractor.extract_csi()
|
||||
|
||||
assert result == sample_csi_data
|
||||
mock_read.assert_called_once()
|
||||
mock_parse.assert_called_once_with(b"raw_csi_data")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_should_handle_extraction_failure_when_not_connected(self, esp32_config, mock_logger):
|
||||
"""Should handle extraction failure when not connected."""
|
||||
extractor = CSIExtractor(config=esp32_config, logger=mock_logger)
|
||||
extractor.is_connected = False
|
||||
|
||||
with pytest.raises(CSIParseError, match="Not connected to hardware"):
|
||||
await extractor.extract_csi()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_should_retry_on_temporary_failure(self, esp32_config, mock_logger, sample_csi_data):
|
||||
"""Should retry extraction on temporary failure."""
|
||||
extractor = CSIExtractor(config=esp32_config, logger=mock_logger)
|
||||
extractor.is_connected = True
|
||||
|
||||
with patch.object(extractor, '_read_raw_data', new_callable=AsyncMock) as mock_read:
|
||||
with patch.object(extractor.parser, 'parse') as mock_parse:
|
||||
# First two calls fail, third succeeds
|
||||
mock_read.side_effect = [ConnectionError(), ConnectionError(), b"raw_data"]
|
||||
mock_parse.return_value = sample_csi_data
|
||||
|
||||
result = await extractor.extract_csi()
|
||||
|
||||
assert result == sample_csi_data
|
||||
assert mock_read.call_count == 3
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_extract_with_validation_disabled(self, esp32_config, mock_logger, sample_csi_data):
|
||||
"""Should skip validation when disabled."""
|
||||
esp32_config['validation_enabled'] = False
|
||||
extractor = CSIExtractor(config=esp32_config, logger=mock_logger)
|
||||
extractor.is_connected = True
|
||||
|
||||
with patch.object(extractor, '_read_raw_data', new_callable=AsyncMock) as mock_read:
|
||||
with patch.object(extractor.parser, 'parse', return_value=sample_csi_data) as mock_parse:
|
||||
with patch.object(extractor, 'validate_csi_data') as mock_validate:
|
||||
mock_read.return_value = b"raw_data"
|
||||
|
||||
result = await extractor.extract_csi()
|
||||
|
||||
assert result == sample_csi_data
|
||||
mock_validate.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_extract_max_retries_exceeded(self, esp32_config, mock_logger):
|
||||
"""Should raise error after max retries exceeded."""
|
||||
esp32_config['retry_attempts'] = 2
|
||||
extractor = CSIExtractor(config=esp32_config, logger=mock_logger)
|
||||
extractor.is_connected = True
|
||||
|
||||
with patch.object(extractor, '_read_raw_data', new_callable=AsyncMock) as mock_read:
|
||||
mock_read.side_effect = ConnectionError("Connection failed")
|
||||
|
||||
with pytest.raises(CSIParseError, match="Extraction failed after 2 attempts"):
|
||||
await extractor.extract_csi()
|
||||
|
||||
assert mock_read.call_count == 2
|
||||
|
||||
# Validation tests
|
||||
def test_should_validate_csi_data_successfully(self, esp32_config, mock_logger, sample_csi_data):
|
||||
"""Should validate CSI data successfully."""
|
||||
extractor = CSIExtractor(config=esp32_config, logger=mock_logger)
|
||||
|
||||
result = extractor.validate_csi_data(sample_csi_data)
|
||||
|
||||
assert result == True
|
||||
|
||||
def test_validation_empty_amplitude(self, esp32_config, mock_logger):
|
||||
"""Should raise validation error for empty amplitude."""
|
||||
extractor = CSIExtractor(config=esp32_config, logger=mock_logger)
|
||||
|
||||
invalid_data = CSIData(
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
amplitude=np.array([]),
|
||||
phase=np.random.rand(3, 56),
|
||||
frequency=2.4e9,
|
||||
bandwidth=20e6,
|
||||
num_subcarriers=56,
|
||||
num_antennas=3,
|
||||
snr=15.5,
|
||||
metadata={}
|
||||
)
|
||||
|
||||
with pytest.raises(CSIValidationError, match="Empty amplitude data"):
|
||||
extractor.validate_csi_data(invalid_data)
|
||||
|
||||
def test_validation_empty_phase(self, esp32_config, mock_logger):
|
||||
"""Should raise validation error for empty phase."""
|
||||
extractor = CSIExtractor(config=esp32_config, logger=mock_logger)
|
||||
|
||||
invalid_data = CSIData(
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
amplitude=np.random.rand(3, 56),
|
||||
phase=np.array([]),
|
||||
frequency=2.4e9,
|
||||
bandwidth=20e6,
|
||||
num_subcarriers=56,
|
||||
num_antennas=3,
|
||||
snr=15.5,
|
||||
metadata={}
|
||||
)
|
||||
|
||||
with pytest.raises(CSIValidationError, match="Empty phase data"):
|
||||
extractor.validate_csi_data(invalid_data)
|
||||
|
||||
def test_validation_invalid_frequency(self, esp32_config, mock_logger):
|
||||
"""Should raise validation error for invalid frequency."""
|
||||
extractor = CSIExtractor(config=esp32_config, logger=mock_logger)
|
||||
|
||||
invalid_data = CSIData(
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
amplitude=np.random.rand(3, 56),
|
||||
phase=np.random.rand(3, 56),
|
||||
frequency=0,
|
||||
bandwidth=20e6,
|
||||
num_subcarriers=56,
|
||||
num_antennas=3,
|
||||
snr=15.5,
|
||||
metadata={}
|
||||
)
|
||||
|
||||
with pytest.raises(CSIValidationError, match="Invalid frequency"):
|
||||
extractor.validate_csi_data(invalid_data)
|
||||
|
||||
def test_validation_invalid_bandwidth(self, esp32_config, mock_logger):
|
||||
"""Should raise validation error for invalid bandwidth."""
|
||||
extractor = CSIExtractor(config=esp32_config, logger=mock_logger)
|
||||
|
||||
invalid_data = CSIData(
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
amplitude=np.random.rand(3, 56),
|
||||
phase=np.random.rand(3, 56),
|
||||
frequency=2.4e9,
|
||||
bandwidth=0,
|
||||
num_subcarriers=56,
|
||||
num_antennas=3,
|
||||
snr=15.5,
|
||||
metadata={}
|
||||
)
|
||||
|
||||
with pytest.raises(CSIValidationError, match="Invalid bandwidth"):
|
||||
extractor.validate_csi_data(invalid_data)
|
||||
|
||||
def test_validation_invalid_subcarriers(self, esp32_config, mock_logger):
|
||||
"""Should raise validation error for invalid subcarriers."""
|
||||
extractor = CSIExtractor(config=esp32_config, logger=mock_logger)
|
||||
|
||||
invalid_data = CSIData(
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
amplitude=np.random.rand(3, 56),
|
||||
phase=np.random.rand(3, 56),
|
||||
frequency=2.4e9,
|
||||
bandwidth=20e6,
|
||||
num_subcarriers=0,
|
||||
num_antennas=3,
|
||||
snr=15.5,
|
||||
metadata={}
|
||||
)
|
||||
|
||||
with pytest.raises(CSIValidationError, match="Invalid number of subcarriers"):
|
||||
extractor.validate_csi_data(invalid_data)
|
||||
|
||||
def test_validation_invalid_antennas(self, esp32_config, mock_logger):
|
||||
"""Should raise validation error for invalid antennas."""
|
||||
extractor = CSIExtractor(config=esp32_config, logger=mock_logger)
|
||||
|
||||
invalid_data = CSIData(
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
amplitude=np.random.rand(3, 56),
|
||||
phase=np.random.rand(3, 56),
|
||||
frequency=2.4e9,
|
||||
bandwidth=20e6,
|
||||
num_subcarriers=56,
|
||||
num_antennas=0,
|
||||
snr=15.5,
|
||||
metadata={}
|
||||
)
|
||||
|
||||
with pytest.raises(CSIValidationError, match="Invalid number of antennas"):
|
||||
extractor.validate_csi_data(invalid_data)
|
||||
|
||||
def test_validation_snr_too_low(self, esp32_config, mock_logger):
|
||||
"""Should raise validation error for SNR too low."""
|
||||
extractor = CSIExtractor(config=esp32_config, logger=mock_logger)
|
||||
|
||||
invalid_data = CSIData(
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
amplitude=np.random.rand(3, 56),
|
||||
phase=np.random.rand(3, 56),
|
||||
frequency=2.4e9,
|
||||
bandwidth=20e6,
|
||||
num_subcarriers=56,
|
||||
num_antennas=3,
|
||||
snr=-100,
|
||||
metadata={}
|
||||
)
|
||||
|
||||
with pytest.raises(CSIValidationError, match="Invalid SNR value"):
|
||||
extractor.validate_csi_data(invalid_data)
|
||||
|
||||
def test_validation_snr_too_high(self, esp32_config, mock_logger):
|
||||
"""Should raise validation error for SNR too high."""
|
||||
extractor = CSIExtractor(config=esp32_config, logger=mock_logger)
|
||||
|
||||
invalid_data = CSIData(
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
amplitude=np.random.rand(3, 56),
|
||||
phase=np.random.rand(3, 56),
|
||||
frequency=2.4e9,
|
||||
bandwidth=20e6,
|
||||
num_subcarriers=56,
|
||||
num_antennas=3,
|
||||
snr=100,
|
||||
metadata={}
|
||||
)
|
||||
|
||||
with pytest.raises(CSIValidationError, match="Invalid SNR value"):
|
||||
extractor.validate_csi_data(invalid_data)
|
||||
|
||||
# Streaming tests
|
||||
@pytest.mark.asyncio
|
||||
async def test_should_start_streaming_successfully(self, esp32_config, mock_logger, sample_csi_data):
|
||||
"""Should start CSI data streaming successfully."""
|
||||
extractor = CSIExtractor(config=esp32_config, logger=mock_logger)
|
||||
extractor.is_connected = True
|
||||
callback = Mock()
|
||||
|
||||
with patch.object(extractor, 'extract_csi', new_callable=AsyncMock) as mock_extract:
|
||||
mock_extract.return_value = sample_csi_data
|
||||
|
||||
# Start streaming with limited iterations to avoid infinite loop
|
||||
streaming_task = asyncio.create_task(extractor.start_streaming(callback))
|
||||
await asyncio.sleep(0.1) # Let it run briefly
|
||||
extractor.stop_streaming()
|
||||
await streaming_task
|
||||
|
||||
callback.assert_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_should_stop_streaming_gracefully(self, esp32_config, mock_logger):
|
||||
"""Should stop streaming gracefully."""
|
||||
extractor = CSIExtractor(config=esp32_config, logger=mock_logger)
|
||||
extractor.is_streaming = True
|
||||
|
||||
extractor.stop_streaming()
|
||||
|
||||
assert extractor.is_streaming == False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_streaming_with_exception(self, esp32_config, mock_logger):
|
||||
"""Should handle exceptions during streaming."""
|
||||
extractor = CSIExtractor(config=esp32_config, logger=mock_logger)
|
||||
extractor.is_connected = True
|
||||
callback = Mock()
|
||||
|
||||
with patch.object(extractor, 'extract_csi', new_callable=AsyncMock) as mock_extract:
|
||||
mock_extract.side_effect = Exception("Extraction error")
|
||||
|
||||
# Start streaming and let it handle the exception
|
||||
streaming_task = asyncio.create_task(extractor.start_streaming(callback))
|
||||
await asyncio.sleep(0.1) # Let it run briefly and hit the exception
|
||||
await streaming_task
|
||||
|
||||
# Should log error and stop streaming
|
||||
assert extractor.is_streaming == False
|
||||
extractor.logger.error.assert_called()
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.tdd
|
||||
@pytest.mark.london
|
||||
class TestESP32CSIParserDirect:
|
||||
"""Test ESP32 CSI parser with direct imports."""
|
||||
|
||||
@pytest.fixture
|
||||
def parser(self):
|
||||
"""Create ESP32 CSI parser for testing."""
|
||||
return ESP32CSIParser()
|
||||
|
||||
@pytest.fixture
|
||||
def raw_esp32_data(self):
|
||||
"""Sample raw ESP32 CSI data."""
|
||||
return b"CSI_DATA:1234567890,3,56,2400,20,15.5,[1.0,2.0,3.0],[0.5,1.5,2.5]"
|
||||
|
||||
def test_should_parse_valid_esp32_data(self, parser, raw_esp32_data):
|
||||
"""Should parse valid ESP32 CSI data successfully."""
|
||||
result = parser.parse(raw_esp32_data)
|
||||
|
||||
assert isinstance(result, CSIData)
|
||||
assert result.num_antennas == 3
|
||||
assert result.num_subcarriers == 56
|
||||
assert result.frequency == 2400000000 # 2.4 GHz
|
||||
assert result.bandwidth == 20000000 # 20 MHz
|
||||
assert result.snr == 15.5
|
||||
|
||||
def test_should_handle_malformed_data(self, parser):
|
||||
"""Should handle malformed ESP32 data gracefully."""
|
||||
malformed_data = b"INVALID_DATA"
|
||||
|
||||
with pytest.raises(CSIParseError, match="Invalid ESP32 CSI data format"):
|
||||
parser.parse(malformed_data)
|
||||
|
||||
def test_should_handle_empty_data(self, parser):
|
||||
"""Should handle empty data gracefully."""
|
||||
with pytest.raises(CSIParseError, match="Empty data received"):
|
||||
parser.parse(b"")
|
||||
|
||||
def test_parse_with_value_error(self, parser):
|
||||
"""Should handle ValueError during parsing."""
|
||||
invalid_data = b"CSI_DATA:invalid_timestamp,3,56,2400,20,15.5"
|
||||
|
||||
with pytest.raises(CSIParseError, match="Failed to parse ESP32 data"):
|
||||
parser.parse(invalid_data)
|
||||
|
||||
def test_parse_with_index_error(self, parser):
|
||||
"""Should handle IndexError during parsing."""
|
||||
invalid_data = b"CSI_DATA:1234567890" # Missing fields
|
||||
|
||||
with pytest.raises(CSIParseError, match="Failed to parse ESP32 data"):
|
||||
parser.parse(invalid_data)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.tdd
|
||||
@pytest.mark.london
|
||||
class TestRouterCSIParserDirect:
|
||||
"""Test Router CSI parser with direct imports."""
|
||||
|
||||
@pytest.fixture
|
||||
def parser(self):
|
||||
"""Create Router CSI parser for testing."""
|
||||
return RouterCSIParser()
|
||||
|
||||
def test_should_parse_atheros_format(self, parser):
|
||||
"""Should parse Atheros CSI format successfully."""
|
||||
raw_data = b"ATHEROS_CSI:mock_data"
|
||||
|
||||
with patch.object(parser, '_parse_atheros_format', return_value=Mock(spec=CSIData)) as mock_parse:
|
||||
result = parser.parse(raw_data)
|
||||
|
||||
mock_parse.assert_called_once()
|
||||
assert result is not None
|
||||
|
||||
def test_should_handle_unknown_format(self, parser):
|
||||
"""Should handle unknown router format gracefully."""
|
||||
unknown_data = b"UNKNOWN_FORMAT:data"
|
||||
|
||||
with pytest.raises(CSIParseError, match="Unknown router CSI format"):
|
||||
parser.parse(unknown_data)
|
||||
|
||||
def test_parse_atheros_format_directly(self, parser):
|
||||
"""Should parse Atheros format directly."""
|
||||
raw_data = b"ATHEROS_CSI:mock_data"
|
||||
|
||||
result = parser.parse(raw_data)
|
||||
|
||||
assert isinstance(result, CSIData)
|
||||
assert result.metadata['source'] == 'atheros_router'
|
||||
|
||||
def test_should_handle_empty_data_router(self, parser):
|
||||
"""Should handle empty data gracefully."""
|
||||
with pytest.raises(CSIParseError, match="Empty data received"):
|
||||
parser.parse(b"")
|
||||
@@ -0,0 +1,279 @@
|
||||
"""Test-Driven Development tests for CSI extractor using London School approach."""
|
||||
|
||||
import pytest
|
||||
import numpy as np
|
||||
from unittest.mock import Mock, patch, AsyncMock, MagicMock
|
||||
from typing import Dict, Any, Optional
|
||||
import asyncio
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from src.hardware.csi_extractor import (
|
||||
CSIExtractor,
|
||||
CSIExtractionError,
|
||||
CSIParseError,
|
||||
CSIData,
|
||||
ESP32CSIParser,
|
||||
RouterCSIParser,
|
||||
CSIValidationError
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.tdd
|
||||
@pytest.mark.london
|
||||
class TestCSIExtractor:
|
||||
"""Test CSI extractor using London School TDD - focus on interactions and behavior."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_logger(self):
|
||||
"""Mock logger for testing."""
|
||||
return Mock()
|
||||
|
||||
@pytest.fixture
|
||||
def mock_config(self):
|
||||
"""Mock configuration for CSI extractor."""
|
||||
return {
|
||||
'hardware_type': 'esp32',
|
||||
'sampling_rate': 100,
|
||||
'buffer_size': 1024,
|
||||
'timeout': 5.0,
|
||||
'validation_enabled': True,
|
||||
'retry_attempts': 3
|
||||
}
|
||||
|
||||
@pytest.fixture
|
||||
def csi_extractor(self, mock_config, mock_logger):
|
||||
"""Create CSI extractor instance for testing."""
|
||||
return CSIExtractor(config=mock_config, logger=mock_logger)
|
||||
|
||||
@pytest.fixture
|
||||
def sample_csi_data(self):
|
||||
"""Sample CSI data for testing."""
|
||||
return CSIData(
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
amplitude=np.random.rand(3, 56),
|
||||
phase=np.random.rand(3, 56),
|
||||
frequency=2.4e9,
|
||||
bandwidth=20e6,
|
||||
num_subcarriers=56,
|
||||
num_antennas=3,
|
||||
snr=15.5,
|
||||
metadata={'source': 'esp32', 'channel': 6}
|
||||
)
|
||||
|
||||
def test_should_initialize_with_valid_config(self, mock_config, mock_logger):
|
||||
"""Should initialize CSI extractor with valid configuration."""
|
||||
extractor = CSIExtractor(config=mock_config, logger=mock_logger)
|
||||
|
||||
assert extractor.config == mock_config
|
||||
assert extractor.logger == mock_logger
|
||||
assert extractor.is_connected == False
|
||||
assert extractor.hardware_type == 'esp32'
|
||||
|
||||
def test_should_raise_error_with_invalid_config(self, mock_logger):
|
||||
"""Should raise error when initialized with invalid configuration."""
|
||||
invalid_config = {'invalid': 'config'}
|
||||
|
||||
with pytest.raises(ValueError, match="Missing required configuration"):
|
||||
CSIExtractor(config=invalid_config, logger=mock_logger)
|
||||
|
||||
def test_should_create_appropriate_parser(self, mock_config, mock_logger):
|
||||
"""Should create appropriate parser based on hardware type."""
|
||||
extractor = CSIExtractor(config=mock_config, logger=mock_logger)
|
||||
|
||||
assert isinstance(extractor.parser, ESP32CSIParser)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_should_establish_connection_successfully(self, csi_extractor):
|
||||
"""Should establish connection to hardware successfully."""
|
||||
with patch.object(csi_extractor, '_establish_hardware_connection', new_callable=AsyncMock) as mock_connect:
|
||||
mock_connect.return_value = True
|
||||
|
||||
result = await csi_extractor.connect()
|
||||
|
||||
assert result == True
|
||||
assert csi_extractor.is_connected == True
|
||||
mock_connect.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_should_handle_connection_failure(self, csi_extractor):
|
||||
"""Should handle connection failure gracefully."""
|
||||
with patch.object(csi_extractor, '_establish_hardware_connection', new_callable=AsyncMock) as mock_connect:
|
||||
mock_connect.side_effect = ConnectionError("Hardware not found")
|
||||
|
||||
result = await csi_extractor.connect()
|
||||
|
||||
assert result == False
|
||||
assert csi_extractor.is_connected == False
|
||||
csi_extractor.logger.error.assert_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_should_disconnect_properly(self, csi_extractor):
|
||||
"""Should disconnect from hardware properly."""
|
||||
csi_extractor.is_connected = True
|
||||
|
||||
with patch.object(csi_extractor, '_close_hardware_connection', new_callable=AsyncMock) as mock_disconnect:
|
||||
await csi_extractor.disconnect()
|
||||
|
||||
assert csi_extractor.is_connected == False
|
||||
mock_disconnect.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_should_extract_csi_data_successfully(self, csi_extractor, sample_csi_data):
|
||||
"""Should extract CSI data successfully from hardware."""
|
||||
csi_extractor.is_connected = True
|
||||
|
||||
with patch.object(csi_extractor, '_read_raw_data', new_callable=AsyncMock) as mock_read:
|
||||
with patch.object(csi_extractor.parser, 'parse', return_value=sample_csi_data) as mock_parse:
|
||||
mock_read.return_value = b"raw_csi_data"
|
||||
|
||||
result = await csi_extractor.extract_csi()
|
||||
|
||||
assert result == sample_csi_data
|
||||
mock_read.assert_called_once()
|
||||
mock_parse.assert_called_once_with(b"raw_csi_data")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_should_handle_extraction_failure_when_not_connected(self, csi_extractor):
|
||||
"""Should handle extraction failure when not connected."""
|
||||
csi_extractor.is_connected = False
|
||||
|
||||
with pytest.raises(CSIParseError, match="Not connected to hardware"):
|
||||
await csi_extractor.extract_csi()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_should_retry_on_temporary_failure(self, csi_extractor, sample_csi_data):
|
||||
"""Should retry extraction on temporary failure."""
|
||||
csi_extractor.is_connected = True
|
||||
|
||||
with patch.object(csi_extractor, '_read_raw_data', new_callable=AsyncMock) as mock_read:
|
||||
with patch.object(csi_extractor.parser, 'parse') as mock_parse:
|
||||
# First two calls fail, third succeeds
|
||||
mock_read.side_effect = [ConnectionError(), ConnectionError(), b"raw_data"]
|
||||
mock_parse.return_value = sample_csi_data
|
||||
|
||||
result = await csi_extractor.extract_csi()
|
||||
|
||||
assert result == sample_csi_data
|
||||
assert mock_read.call_count == 3
|
||||
|
||||
def test_should_validate_csi_data_successfully(self, csi_extractor, sample_csi_data):
|
||||
"""Should validate CSI data successfully."""
|
||||
result = csi_extractor.validate_csi_data(sample_csi_data)
|
||||
|
||||
assert result == True
|
||||
|
||||
def test_should_reject_invalid_csi_data(self, csi_extractor):
|
||||
"""Should reject CSI data with invalid structure."""
|
||||
invalid_data = CSIData(
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
amplitude=np.array([]), # Empty array
|
||||
phase=np.array([]),
|
||||
frequency=0, # Invalid frequency
|
||||
bandwidth=0,
|
||||
num_subcarriers=0,
|
||||
num_antennas=0,
|
||||
snr=-100, # Invalid SNR
|
||||
metadata={}
|
||||
)
|
||||
|
||||
with pytest.raises(CSIValidationError):
|
||||
csi_extractor.validate_csi_data(invalid_data)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_should_start_streaming_successfully(self, csi_extractor, sample_csi_data):
|
||||
"""Should start CSI data streaming successfully."""
|
||||
csi_extractor.is_connected = True
|
||||
callback = Mock()
|
||||
|
||||
with patch.object(csi_extractor, 'extract_csi', new_callable=AsyncMock) as mock_extract:
|
||||
mock_extract.return_value = sample_csi_data
|
||||
|
||||
# Start streaming with limited iterations to avoid infinite loop
|
||||
streaming_task = asyncio.create_task(csi_extractor.start_streaming(callback))
|
||||
await asyncio.sleep(0.1) # Let it run briefly
|
||||
csi_extractor.stop_streaming()
|
||||
await streaming_task
|
||||
|
||||
callback.assert_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_should_stop_streaming_gracefully(self, csi_extractor):
|
||||
"""Should stop streaming gracefully."""
|
||||
csi_extractor.is_streaming = True
|
||||
|
||||
csi_extractor.stop_streaming()
|
||||
|
||||
assert csi_extractor.is_streaming == False
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.tdd
|
||||
@pytest.mark.london
|
||||
class TestESP32CSIParser:
|
||||
"""Test ESP32 CSI parser using London School TDD."""
|
||||
|
||||
@pytest.fixture
|
||||
def parser(self):
|
||||
"""Create ESP32 CSI parser for testing."""
|
||||
return ESP32CSIParser()
|
||||
|
||||
@pytest.fixture
|
||||
def raw_esp32_data(self):
|
||||
"""Sample raw ESP32 CSI data with correct 3×56 amplitude and phase values."""
|
||||
n_ant, n_sub = 3, 56
|
||||
amp = ",".join(["1.0"] * (n_ant * n_sub))
|
||||
pha = ",".join(["0.5"] * (n_ant * n_sub))
|
||||
return f"CSI_DATA:1234567890,{n_ant},{n_sub},2400,20,15.5,{amp},{pha}".encode()
|
||||
|
||||
def test_should_parse_valid_esp32_data(self, parser, raw_esp32_data):
|
||||
"""Should parse valid ESP32 CSI data successfully."""
|
||||
result = parser.parse(raw_esp32_data)
|
||||
|
||||
assert isinstance(result, CSIData)
|
||||
assert result.num_antennas == 3
|
||||
assert result.num_subcarriers == 56
|
||||
assert result.frequency == 2400000000 # 2.4 GHz
|
||||
assert result.bandwidth == 20000000 # 20 MHz
|
||||
assert result.snr == 15.5
|
||||
|
||||
def test_should_handle_malformed_data(self, parser):
|
||||
"""Should handle malformed ESP32 data gracefully."""
|
||||
malformed_data = b"INVALID_DATA"
|
||||
|
||||
with pytest.raises(CSIParseError, match="Invalid ESP32 CSI data format"):
|
||||
parser.parse(malformed_data)
|
||||
|
||||
def test_should_handle_empty_data(self, parser):
|
||||
"""Should handle empty data gracefully."""
|
||||
with pytest.raises(CSIParseError, match="Empty data received"):
|
||||
parser.parse(b"")
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.tdd
|
||||
@pytest.mark.london
|
||||
class TestRouterCSIParser:
|
||||
"""Test Router CSI parser using London School TDD."""
|
||||
|
||||
@pytest.fixture
|
||||
def parser(self):
|
||||
"""Create Router CSI parser for testing."""
|
||||
return RouterCSIParser()
|
||||
|
||||
def test_should_parse_atheros_format(self, parser):
|
||||
"""Should parse Atheros CSI format successfully."""
|
||||
raw_data = b"ATHEROS_CSI:mock_data"
|
||||
|
||||
with patch.object(parser, '_parse_atheros_format', return_value=Mock(spec=CSIData)) as mock_parse:
|
||||
result = parser.parse(raw_data)
|
||||
|
||||
mock_parse.assert_called_once()
|
||||
assert result is not None
|
||||
|
||||
def test_should_handle_unknown_format(self, parser):
|
||||
"""Should handle unknown router format gracefully."""
|
||||
unknown_data = b"UNKNOWN_FORMAT:data"
|
||||
|
||||
with pytest.raises(CSIParseError, match="Unknown router CSI format"):
|
||||
parser.parse(unknown_data)
|
||||
@@ -0,0 +1,384 @@
|
||||
"""Complete TDD tests for CSI extractor with 100% coverage."""
|
||||
|
||||
import pytest
|
||||
import numpy as np
|
||||
from unittest.mock import Mock, patch, AsyncMock, MagicMock
|
||||
from typing import Dict, Any, Optional
|
||||
import asyncio
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from src.hardware.csi_extractor import (
|
||||
CSIExtractor,
|
||||
CSIExtractionError,
|
||||
CSIParseError,
|
||||
CSIData,
|
||||
ESP32CSIParser,
|
||||
RouterCSIParser,
|
||||
CSIValidationError
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.tdd
|
||||
@pytest.mark.london
|
||||
class TestCSIExtractorComplete:
|
||||
"""Complete CSI extractor tests for 100% coverage."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_logger(self):
|
||||
"""Mock logger for testing."""
|
||||
return Mock()
|
||||
|
||||
@pytest.fixture
|
||||
def esp32_config(self):
|
||||
"""ESP32 configuration for testing."""
|
||||
return {
|
||||
'hardware_type': 'esp32',
|
||||
'sampling_rate': 100,
|
||||
'buffer_size': 1024,
|
||||
'timeout': 5.0,
|
||||
'validation_enabled': True,
|
||||
'retry_attempts': 3
|
||||
}
|
||||
|
||||
@pytest.fixture
|
||||
def router_config(self):
|
||||
"""Router configuration for testing."""
|
||||
return {
|
||||
'hardware_type': 'router',
|
||||
'sampling_rate': 50,
|
||||
'buffer_size': 512,
|
||||
'timeout': 10.0,
|
||||
'validation_enabled': False,
|
||||
'retry_attempts': 1
|
||||
}
|
||||
|
||||
@pytest.fixture
|
||||
def sample_csi_data(self):
|
||||
"""Sample CSI data for testing."""
|
||||
return CSIData(
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
amplitude=np.random.rand(3, 56),
|
||||
phase=np.random.rand(3, 56),
|
||||
frequency=2.4e9,
|
||||
bandwidth=20e6,
|
||||
num_subcarriers=56,
|
||||
num_antennas=3,
|
||||
snr=15.5,
|
||||
metadata={'source': 'esp32', 'channel': 6}
|
||||
)
|
||||
|
||||
def test_should_create_router_parser(self, router_config, mock_logger):
|
||||
"""Should create router parser when hardware_type is router."""
|
||||
extractor = CSIExtractor(config=router_config, logger=mock_logger)
|
||||
|
||||
assert isinstance(extractor.parser, RouterCSIParser)
|
||||
assert extractor.hardware_type == 'router'
|
||||
|
||||
def test_should_raise_error_for_unsupported_hardware(self, mock_logger):
|
||||
"""Should raise error for unsupported hardware type."""
|
||||
invalid_config = {
|
||||
'hardware_type': 'unsupported',
|
||||
'sampling_rate': 100,
|
||||
'buffer_size': 1024,
|
||||
'timeout': 5.0
|
||||
}
|
||||
|
||||
with pytest.raises(ValueError, match="Unsupported hardware type: unsupported"):
|
||||
CSIExtractor(config=invalid_config, logger=mock_logger)
|
||||
|
||||
def test_config_validation_negative_sampling_rate(self, mock_logger):
|
||||
"""Should validate sampling_rate is positive."""
|
||||
invalid_config = {
|
||||
'hardware_type': 'esp32',
|
||||
'sampling_rate': -1,
|
||||
'buffer_size': 1024,
|
||||
'timeout': 5.0
|
||||
}
|
||||
|
||||
with pytest.raises(ValueError, match="sampling_rate must be positive"):
|
||||
CSIExtractor(config=invalid_config, logger=mock_logger)
|
||||
|
||||
def test_config_validation_zero_buffer_size(self, mock_logger):
|
||||
"""Should validate buffer_size is positive."""
|
||||
invalid_config = {
|
||||
'hardware_type': 'esp32',
|
||||
'sampling_rate': 100,
|
||||
'buffer_size': 0,
|
||||
'timeout': 5.0
|
||||
}
|
||||
|
||||
with pytest.raises(ValueError, match="buffer_size must be positive"):
|
||||
CSIExtractor(config=invalid_config, logger=mock_logger)
|
||||
|
||||
def test_config_validation_negative_timeout(self, mock_logger):
|
||||
"""Should validate timeout is positive."""
|
||||
invalid_config = {
|
||||
'hardware_type': 'esp32',
|
||||
'sampling_rate': 100,
|
||||
'buffer_size': 1024,
|
||||
'timeout': -1.0
|
||||
}
|
||||
|
||||
with pytest.raises(ValueError, match="timeout must be positive"):
|
||||
CSIExtractor(config=invalid_config, logger=mock_logger)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_disconnect_when_not_connected(self, esp32_config, mock_logger):
|
||||
"""Should handle disconnect when not connected."""
|
||||
extractor = CSIExtractor(config=esp32_config, logger=mock_logger)
|
||||
extractor.is_connected = False
|
||||
|
||||
with patch.object(extractor, '_close_hardware_connection', new_callable=AsyncMock) as mock_close:
|
||||
await extractor.disconnect()
|
||||
|
||||
# Should not call close when not connected
|
||||
mock_close.assert_not_called()
|
||||
assert extractor.is_connected == False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_extract_with_validation_disabled(self, esp32_config, mock_logger, sample_csi_data):
|
||||
"""Should skip validation when disabled."""
|
||||
esp32_config['validation_enabled'] = False
|
||||
extractor = CSIExtractor(config=esp32_config, logger=mock_logger)
|
||||
extractor.is_connected = True
|
||||
|
||||
with patch.object(extractor, '_read_raw_data', new_callable=AsyncMock) as mock_read:
|
||||
with patch.object(extractor.parser, 'parse', return_value=sample_csi_data) as mock_parse:
|
||||
with patch.object(extractor, 'validate_csi_data') as mock_validate:
|
||||
mock_read.return_value = b"raw_data"
|
||||
|
||||
result = await extractor.extract_csi()
|
||||
|
||||
assert result == sample_csi_data
|
||||
mock_validate.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_extract_max_retries_exceeded(self, esp32_config, mock_logger):
|
||||
"""Should raise error after max retries exceeded."""
|
||||
esp32_config['retry_attempts'] = 2
|
||||
extractor = CSIExtractor(config=esp32_config, logger=mock_logger)
|
||||
extractor.is_connected = True
|
||||
|
||||
with patch.object(extractor, '_read_raw_data', new_callable=AsyncMock) as mock_read:
|
||||
mock_read.side_effect = ConnectionError("Connection failed")
|
||||
|
||||
with pytest.raises(CSIParseError, match="Extraction failed after 2 attempts"):
|
||||
await extractor.extract_csi()
|
||||
|
||||
assert mock_read.call_count == 2
|
||||
|
||||
def test_validation_empty_amplitude(self, esp32_config, mock_logger):
|
||||
"""Should raise validation error for empty amplitude."""
|
||||
extractor = CSIExtractor(config=esp32_config, logger=mock_logger)
|
||||
|
||||
invalid_data = CSIData(
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
amplitude=np.array([]),
|
||||
phase=np.random.rand(3, 56),
|
||||
frequency=2.4e9,
|
||||
bandwidth=20e6,
|
||||
num_subcarriers=56,
|
||||
num_antennas=3,
|
||||
snr=15.5,
|
||||
metadata={}
|
||||
)
|
||||
|
||||
with pytest.raises(CSIValidationError, match="Empty amplitude data"):
|
||||
extractor.validate_csi_data(invalid_data)
|
||||
|
||||
def test_validation_empty_phase(self, esp32_config, mock_logger):
|
||||
"""Should raise validation error for empty phase."""
|
||||
extractor = CSIExtractor(config=esp32_config, logger=mock_logger)
|
||||
|
||||
invalid_data = CSIData(
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
amplitude=np.random.rand(3, 56),
|
||||
phase=np.array([]),
|
||||
frequency=2.4e9,
|
||||
bandwidth=20e6,
|
||||
num_subcarriers=56,
|
||||
num_antennas=3,
|
||||
snr=15.5,
|
||||
metadata={}
|
||||
)
|
||||
|
||||
with pytest.raises(CSIValidationError, match="Empty phase data"):
|
||||
extractor.validate_csi_data(invalid_data)
|
||||
|
||||
def test_validation_invalid_frequency(self, esp32_config, mock_logger):
|
||||
"""Should raise validation error for invalid frequency."""
|
||||
extractor = CSIExtractor(config=esp32_config, logger=mock_logger)
|
||||
|
||||
invalid_data = CSIData(
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
amplitude=np.random.rand(3, 56),
|
||||
phase=np.random.rand(3, 56),
|
||||
frequency=0,
|
||||
bandwidth=20e6,
|
||||
num_subcarriers=56,
|
||||
num_antennas=3,
|
||||
snr=15.5,
|
||||
metadata={}
|
||||
)
|
||||
|
||||
with pytest.raises(CSIValidationError, match="Invalid frequency"):
|
||||
extractor.validate_csi_data(invalid_data)
|
||||
|
||||
def test_validation_invalid_bandwidth(self, esp32_config, mock_logger):
|
||||
"""Should raise validation error for invalid bandwidth."""
|
||||
extractor = CSIExtractor(config=esp32_config, logger=mock_logger)
|
||||
|
||||
invalid_data = CSIData(
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
amplitude=np.random.rand(3, 56),
|
||||
phase=np.random.rand(3, 56),
|
||||
frequency=2.4e9,
|
||||
bandwidth=0,
|
||||
num_subcarriers=56,
|
||||
num_antennas=3,
|
||||
snr=15.5,
|
||||
metadata={}
|
||||
)
|
||||
|
||||
with pytest.raises(CSIValidationError, match="Invalid bandwidth"):
|
||||
extractor.validate_csi_data(invalid_data)
|
||||
|
||||
def test_validation_invalid_subcarriers(self, esp32_config, mock_logger):
|
||||
"""Should raise validation error for invalid subcarriers."""
|
||||
extractor = CSIExtractor(config=esp32_config, logger=mock_logger)
|
||||
|
||||
invalid_data = CSIData(
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
amplitude=np.random.rand(3, 56),
|
||||
phase=np.random.rand(3, 56),
|
||||
frequency=2.4e9,
|
||||
bandwidth=20e6,
|
||||
num_subcarriers=0,
|
||||
num_antennas=3,
|
||||
snr=15.5,
|
||||
metadata={}
|
||||
)
|
||||
|
||||
with pytest.raises(CSIValidationError, match="Invalid number of subcarriers"):
|
||||
extractor.validate_csi_data(invalid_data)
|
||||
|
||||
def test_validation_invalid_antennas(self, esp32_config, mock_logger):
|
||||
"""Should raise validation error for invalid antennas."""
|
||||
extractor = CSIExtractor(config=esp32_config, logger=mock_logger)
|
||||
|
||||
invalid_data = CSIData(
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
amplitude=np.random.rand(3, 56),
|
||||
phase=np.random.rand(3, 56),
|
||||
frequency=2.4e9,
|
||||
bandwidth=20e6,
|
||||
num_subcarriers=56,
|
||||
num_antennas=0,
|
||||
snr=15.5,
|
||||
metadata={}
|
||||
)
|
||||
|
||||
with pytest.raises(CSIValidationError, match="Invalid number of antennas"):
|
||||
extractor.validate_csi_data(invalid_data)
|
||||
|
||||
def test_validation_snr_too_low(self, esp32_config, mock_logger):
|
||||
"""Should raise validation error for SNR too low."""
|
||||
extractor = CSIExtractor(config=esp32_config, logger=mock_logger)
|
||||
|
||||
invalid_data = CSIData(
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
amplitude=np.random.rand(3, 56),
|
||||
phase=np.random.rand(3, 56),
|
||||
frequency=2.4e9,
|
||||
bandwidth=20e6,
|
||||
num_subcarriers=56,
|
||||
num_antennas=3,
|
||||
snr=-100,
|
||||
metadata={}
|
||||
)
|
||||
|
||||
with pytest.raises(CSIValidationError, match="Invalid SNR value"):
|
||||
extractor.validate_csi_data(invalid_data)
|
||||
|
||||
def test_validation_snr_too_high(self, esp32_config, mock_logger):
|
||||
"""Should raise validation error for SNR too high."""
|
||||
extractor = CSIExtractor(config=esp32_config, logger=mock_logger)
|
||||
|
||||
invalid_data = CSIData(
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
amplitude=np.random.rand(3, 56),
|
||||
phase=np.random.rand(3, 56),
|
||||
frequency=2.4e9,
|
||||
bandwidth=20e6,
|
||||
num_subcarriers=56,
|
||||
num_antennas=3,
|
||||
snr=100,
|
||||
metadata={}
|
||||
)
|
||||
|
||||
with pytest.raises(CSIValidationError, match="Invalid SNR value"):
|
||||
extractor.validate_csi_data(invalid_data)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_streaming_with_exception(self, esp32_config, mock_logger):
|
||||
"""Should handle exceptions during streaming."""
|
||||
extractor = CSIExtractor(config=esp32_config, logger=mock_logger)
|
||||
extractor.is_connected = True
|
||||
callback = Mock()
|
||||
|
||||
with patch.object(extractor, 'extract_csi', new_callable=AsyncMock) as mock_extract:
|
||||
mock_extract.side_effect = Exception("Extraction error")
|
||||
|
||||
# Start streaming and let it handle the exception
|
||||
streaming_task = asyncio.create_task(extractor.start_streaming(callback))
|
||||
await asyncio.sleep(0.1) # Let it run briefly and hit the exception
|
||||
await streaming_task
|
||||
|
||||
# Should log error and stop streaming
|
||||
assert extractor.is_streaming == False
|
||||
extractor.logger.error.assert_called()
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.tdd
|
||||
@pytest.mark.london
|
||||
class TestESP32CSIParserComplete:
|
||||
"""Complete ESP32 CSI parser tests for 100% coverage."""
|
||||
|
||||
@pytest.fixture
|
||||
def parser(self):
|
||||
"""Create ESP32 CSI parser for testing."""
|
||||
return ESP32CSIParser()
|
||||
|
||||
def test_parse_with_value_error(self, parser):
|
||||
"""Should handle ValueError during parsing."""
|
||||
invalid_data = b"CSI_DATA:invalid_timestamp,3,56,2400,20,15.5"
|
||||
|
||||
with pytest.raises(CSIParseError, match="Failed to parse ESP32 data"):
|
||||
parser.parse(invalid_data)
|
||||
|
||||
def test_parse_with_index_error(self, parser):
|
||||
"""Should handle IndexError during parsing."""
|
||||
invalid_data = b"CSI_DATA:1234567890" # Missing fields
|
||||
|
||||
with pytest.raises(CSIParseError, match="Failed to parse ESP32 data"):
|
||||
parser.parse(invalid_data)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.tdd
|
||||
@pytest.mark.london
|
||||
class TestRouterCSIParserComplete:
|
||||
"""Complete Router CSI parser tests for 100% coverage."""
|
||||
|
||||
@pytest.fixture
|
||||
def parser(self):
|
||||
"""Create Router CSI parser for testing."""
|
||||
return RouterCSIParser()
|
||||
|
||||
def test_parse_atheros_format_directly(self, parser):
|
||||
"""Should raise CSIExtractionError for Atheros format — real binary parser not yet implemented."""
|
||||
raw_data = b"ATHEROS_CSI:some_binary_data"
|
||||
with pytest.raises(CSIExtractionError, match="Atheros CSI format parsing is not yet implemented"):
|
||||
parser.parse(raw_data)
|
||||
@@ -0,0 +1,98 @@
|
||||
import pytest
|
||||
import numpy as np
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import Mock, patch
|
||||
from src.core.csi_processor import CSIProcessor, CSIFeatures
|
||||
from src.hardware.csi_extractor import CSIData
|
||||
|
||||
|
||||
def make_csi_data(amplitude=None, phase=None, n_ant=3, n_sub=56):
|
||||
"""Build a CSIData test fixture."""
|
||||
if amplitude is None:
|
||||
amplitude = np.random.uniform(0.1, 2.0, (n_ant, n_sub))
|
||||
if phase is None:
|
||||
phase = np.random.uniform(-np.pi, np.pi, (n_ant, n_sub))
|
||||
return CSIData(
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
amplitude=amplitude,
|
||||
phase=phase,
|
||||
frequency=5.21e9,
|
||||
bandwidth=17.5e6,
|
||||
num_subcarriers=n_sub,
|
||||
num_antennas=n_ant,
|
||||
snr=15.0,
|
||||
metadata={"source": "test"},
|
||||
)
|
||||
|
||||
|
||||
_PROCESSOR_CONFIG = {
|
||||
"sampling_rate": 100,
|
||||
"window_size": 56,
|
||||
"overlap": 0.5,
|
||||
"noise_threshold": -60,
|
||||
"human_detection_threshold": 0.8,
|
||||
"smoothing_factor": 0.9,
|
||||
"max_history_size": 500,
|
||||
"enable_preprocessing": True,
|
||||
"enable_feature_extraction": True,
|
||||
"enable_human_detection": True,
|
||||
}
|
||||
|
||||
|
||||
class TestCSIProcessor:
|
||||
"""Test suite for CSI processor following London School TDD principles"""
|
||||
|
||||
@pytest.fixture
|
||||
def csi_processor(self):
|
||||
"""Create CSI processor instance for testing"""
|
||||
return CSIProcessor(config=_PROCESSOR_CONFIG)
|
||||
|
||||
@pytest.fixture
|
||||
def sample_csi(self):
|
||||
"""Generate synthetic CSIData for testing"""
|
||||
return make_csi_data()
|
||||
|
||||
def test_preprocess_returns_csi_data(self, csi_processor, sample_csi):
|
||||
"""Preprocess should return a CSIData instance"""
|
||||
result = csi_processor.preprocess_csi_data(sample_csi)
|
||||
assert isinstance(result, CSIData)
|
||||
assert result.num_antennas == sample_csi.num_antennas
|
||||
assert result.num_subcarriers == sample_csi.num_subcarriers
|
||||
|
||||
def test_preprocess_normalises_amplitude(self, csi_processor, sample_csi):
|
||||
"""Preprocess should produce finite, non-negative amplitude with unit-variance normalisation"""
|
||||
result = csi_processor.preprocess_csi_data(sample_csi)
|
||||
assert np.all(np.isfinite(result.amplitude))
|
||||
assert result.amplitude.min() >= 0.0
|
||||
# Normalised to unit variance: std ≈ 1.0 (may differ due to Hamming window)
|
||||
std = np.std(result.amplitude)
|
||||
assert 0.5 < std < 5.0 # within reasonable bounds of unit-variance normalisation
|
||||
|
||||
def test_preprocess_removes_nan(self, csi_processor):
|
||||
"""Preprocess should replace NaN amplitude with 0"""
|
||||
amp = np.ones((3, 56))
|
||||
amp[0, 0] = np.nan
|
||||
csi = make_csi_data(amplitude=amp)
|
||||
result = csi_processor.preprocess_csi_data(csi)
|
||||
assert not np.isnan(result.amplitude).any()
|
||||
|
||||
def test_extract_features_returns_csi_features(self, csi_processor, sample_csi):
|
||||
"""extract_features should return a CSIFeatures instance"""
|
||||
preprocessed = csi_processor.preprocess_csi_data(sample_csi)
|
||||
features = csi_processor.extract_features(preprocessed)
|
||||
assert isinstance(features, CSIFeatures)
|
||||
|
||||
def test_extract_features_has_correct_shapes(self, csi_processor, sample_csi):
|
||||
"""Feature arrays should have expected shapes"""
|
||||
preprocessed = csi_processor.preprocess_csi_data(sample_csi)
|
||||
features = csi_processor.extract_features(preprocessed)
|
||||
assert features.amplitude_mean.shape == (56,)
|
||||
assert features.amplitude_variance.shape == (56,)
|
||||
|
||||
def test_preprocess_performance(self, csi_processor, sample_csi):
|
||||
"""Preprocessing a single frame must complete in < 10 ms"""
|
||||
start = time.perf_counter()
|
||||
csi_processor.preprocess_csi_data(sample_csi)
|
||||
elapsed = time.perf_counter() - start
|
||||
assert elapsed < 0.010 # < 10 ms
|
||||
@@ -0,0 +1,485 @@
|
||||
"""TDD tests for CSI processor following London School approach."""
|
||||
|
||||
import pytest
|
||||
import numpy as np
|
||||
import sys
|
||||
import os
|
||||
from unittest.mock import Mock, patch, AsyncMock, MagicMock
|
||||
from datetime import datetime, timezone
|
||||
import importlib.util
|
||||
from typing import Dict, List, Any
|
||||
|
||||
# Resolve paths relative to the v1/ root (this file is at v1/tests/unit/)
|
||||
_TESTS_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
_V1_DIR = os.path.abspath(os.path.join(_TESTS_DIR, '..', '..'))
|
||||
if _V1_DIR not in sys.path:
|
||||
sys.path.insert(0, _V1_DIR)
|
||||
|
||||
# Import the CSI processor module directly
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
'csi_processor',
|
||||
os.path.join(_V1_DIR, 'src', 'core', 'csi_processor.py')
|
||||
)
|
||||
csi_processor_module = importlib.util.module_from_spec(spec)
|
||||
|
||||
# Import CSI extractor for dependencies
|
||||
csi_spec = importlib.util.spec_from_file_location(
|
||||
'csi_extractor',
|
||||
os.path.join(_V1_DIR, 'src', 'hardware', 'csi_extractor.py')
|
||||
)
|
||||
csi_module = importlib.util.module_from_spec(csi_spec)
|
||||
csi_spec.loader.exec_module(csi_module)
|
||||
|
||||
# Make dependencies available and load the processor
|
||||
csi_processor_module.CSIData = csi_module.CSIData
|
||||
spec.loader.exec_module(csi_processor_module)
|
||||
|
||||
# Get classes from modules
|
||||
CSIProcessor = csi_processor_module.CSIProcessor
|
||||
CSIProcessingError = csi_processor_module.CSIProcessingError
|
||||
HumanDetectionResult = csi_processor_module.HumanDetectionResult
|
||||
CSIFeatures = csi_processor_module.CSIFeatures
|
||||
CSIData = csi_module.CSIData
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.tdd
|
||||
@pytest.mark.london
|
||||
class TestCSIProcessor:
|
||||
"""Test CSI processor using London School TDD."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_logger(self):
|
||||
"""Mock logger for testing."""
|
||||
return Mock()
|
||||
|
||||
@pytest.fixture
|
||||
def processor_config(self):
|
||||
"""CSI processor configuration for testing."""
|
||||
return {
|
||||
'sampling_rate': 100,
|
||||
'window_size': 256,
|
||||
'overlap': 0.5,
|
||||
'noise_threshold': -60.0,
|
||||
'human_detection_threshold': 0.7,
|
||||
'smoothing_factor': 0.8,
|
||||
'max_history_size': 1000,
|
||||
'enable_preprocessing': True,
|
||||
'enable_feature_extraction': True,
|
||||
'enable_human_detection': True
|
||||
}
|
||||
|
||||
@pytest.fixture
|
||||
def csi_processor(self, processor_config, mock_logger):
|
||||
"""Create CSI processor for testing."""
|
||||
return CSIProcessor(config=processor_config, logger=mock_logger)
|
||||
|
||||
@pytest.fixture
|
||||
def sample_csi_data(self):
|
||||
"""Sample CSI data for testing."""
|
||||
return CSIData(
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
amplitude=np.random.rand(3, 56) + 1.0, # Ensure positive amplitude
|
||||
phase=np.random.uniform(-np.pi, np.pi, (3, 56)),
|
||||
frequency=2.4e9,
|
||||
bandwidth=20e6,
|
||||
num_subcarriers=56,
|
||||
num_antennas=3,
|
||||
snr=15.5,
|
||||
metadata={'source': 'test'}
|
||||
)
|
||||
|
||||
@pytest.fixture
|
||||
def sample_features(self):
|
||||
"""Sample CSI features for testing."""
|
||||
return CSIFeatures(
|
||||
amplitude_mean=np.random.rand(56),
|
||||
amplitude_variance=np.random.rand(56),
|
||||
phase_difference=np.random.rand(56),
|
||||
correlation_matrix=np.random.rand(3, 3),
|
||||
doppler_shift=np.random.rand(10),
|
||||
power_spectral_density=np.random.rand(128),
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
metadata={'processing_params': {}}
|
||||
)
|
||||
|
||||
# Initialization tests
|
||||
def test_should_initialize_with_valid_config(self, processor_config, mock_logger):
|
||||
"""Should initialize CSI processor with valid configuration."""
|
||||
processor = CSIProcessor(config=processor_config, logger=mock_logger)
|
||||
|
||||
assert processor.config == processor_config
|
||||
assert processor.logger == mock_logger
|
||||
assert processor.sampling_rate == 100
|
||||
assert processor.window_size == 256
|
||||
assert processor.overlap == 0.5
|
||||
assert processor.noise_threshold == -60.0
|
||||
assert processor.human_detection_threshold == 0.7
|
||||
assert processor.smoothing_factor == 0.8
|
||||
assert processor.max_history_size == 1000
|
||||
assert len(processor.csi_history) == 0
|
||||
|
||||
def test_should_raise_error_with_invalid_config(self, mock_logger):
|
||||
"""Should raise error when initialized with invalid configuration."""
|
||||
invalid_config = {'invalid': 'config'}
|
||||
|
||||
with pytest.raises(ValueError, match="Missing required configuration"):
|
||||
CSIProcessor(config=invalid_config, logger=mock_logger)
|
||||
|
||||
def test_should_validate_required_fields(self, mock_logger):
|
||||
"""Should validate all required configuration fields."""
|
||||
required_fields = ['sampling_rate', 'window_size', 'overlap', 'noise_threshold']
|
||||
base_config = {
|
||||
'sampling_rate': 100,
|
||||
'window_size': 256,
|
||||
'overlap': 0.5,
|
||||
'noise_threshold': -60.0
|
||||
}
|
||||
|
||||
for field in required_fields:
|
||||
config = base_config.copy()
|
||||
del config[field]
|
||||
|
||||
with pytest.raises(ValueError, match="Missing required configuration"):
|
||||
CSIProcessor(config=config, logger=mock_logger)
|
||||
|
||||
def test_should_use_default_values(self, mock_logger):
|
||||
"""Should use default values for optional parameters."""
|
||||
minimal_config = {
|
||||
'sampling_rate': 100,
|
||||
'window_size': 256,
|
||||
'overlap': 0.5,
|
||||
'noise_threshold': -60.0
|
||||
}
|
||||
|
||||
processor = CSIProcessor(config=minimal_config, logger=mock_logger)
|
||||
|
||||
assert processor.human_detection_threshold == 0.8 # default
|
||||
assert processor.smoothing_factor == 0.9 # default
|
||||
assert processor.max_history_size == 500 # default
|
||||
|
||||
def test_should_initialize_without_logger(self, processor_config):
|
||||
"""Should initialize without logger provided."""
|
||||
processor = CSIProcessor(config=processor_config)
|
||||
|
||||
assert processor.logger is not None # Should create default logger
|
||||
|
||||
# Preprocessing tests
|
||||
def test_should_preprocess_csi_data_successfully(self, csi_processor, sample_csi_data):
|
||||
"""Should preprocess CSI data successfully."""
|
||||
with patch.object(csi_processor, '_remove_noise') as mock_noise:
|
||||
with patch.object(csi_processor, '_apply_windowing') as mock_window:
|
||||
with patch.object(csi_processor, '_normalize_amplitude') as mock_normalize:
|
||||
mock_noise.return_value = sample_csi_data
|
||||
mock_window.return_value = sample_csi_data
|
||||
mock_normalize.return_value = sample_csi_data
|
||||
|
||||
result = csi_processor.preprocess_csi_data(sample_csi_data)
|
||||
|
||||
assert result == sample_csi_data
|
||||
mock_noise.assert_called_once_with(sample_csi_data)
|
||||
mock_window.assert_called_once()
|
||||
mock_normalize.assert_called_once()
|
||||
|
||||
def test_should_skip_preprocessing_when_disabled(self, processor_config, mock_logger, sample_csi_data):
|
||||
"""Should skip preprocessing when disabled."""
|
||||
processor_config['enable_preprocessing'] = False
|
||||
processor = CSIProcessor(config=processor_config, logger=mock_logger)
|
||||
|
||||
result = processor.preprocess_csi_data(sample_csi_data)
|
||||
|
||||
assert result == sample_csi_data
|
||||
|
||||
def test_should_handle_preprocessing_error(self, csi_processor, sample_csi_data):
|
||||
"""Should handle preprocessing errors gracefully."""
|
||||
with patch.object(csi_processor, '_remove_noise') as mock_noise:
|
||||
mock_noise.side_effect = Exception("Preprocessing error")
|
||||
|
||||
with pytest.raises(CSIProcessingError, match="Failed to preprocess CSI data"):
|
||||
csi_processor.preprocess_csi_data(sample_csi_data)
|
||||
|
||||
# Feature extraction tests
|
||||
def test_should_extract_features_successfully(self, csi_processor, sample_csi_data, sample_features):
|
||||
"""Should extract features from CSI data successfully."""
|
||||
with patch.object(csi_processor, '_extract_amplitude_features') as mock_amp:
|
||||
with patch.object(csi_processor, '_extract_phase_features') as mock_phase:
|
||||
with patch.object(csi_processor, '_extract_correlation_features') as mock_corr:
|
||||
with patch.object(csi_processor, '_extract_doppler_features') as mock_doppler:
|
||||
mock_amp.return_value = (sample_features.amplitude_mean, sample_features.amplitude_variance)
|
||||
mock_phase.return_value = sample_features.phase_difference
|
||||
mock_corr.return_value = sample_features.correlation_matrix
|
||||
mock_doppler.return_value = (sample_features.doppler_shift, sample_features.power_spectral_density)
|
||||
|
||||
result = csi_processor.extract_features(sample_csi_data)
|
||||
|
||||
assert isinstance(result, CSIFeatures)
|
||||
assert np.array_equal(result.amplitude_mean, sample_features.amplitude_mean)
|
||||
assert np.array_equal(result.amplitude_variance, sample_features.amplitude_variance)
|
||||
mock_amp.assert_called_once()
|
||||
mock_phase.assert_called_once()
|
||||
mock_corr.assert_called_once()
|
||||
mock_doppler.assert_called_once()
|
||||
|
||||
def test_should_skip_feature_extraction_when_disabled(self, processor_config, mock_logger, sample_csi_data):
|
||||
"""Should skip feature extraction when disabled."""
|
||||
processor_config['enable_feature_extraction'] = False
|
||||
processor = CSIProcessor(config=processor_config, logger=mock_logger)
|
||||
|
||||
result = processor.extract_features(sample_csi_data)
|
||||
|
||||
assert result is None
|
||||
|
||||
def test_should_handle_feature_extraction_error(self, csi_processor, sample_csi_data):
|
||||
"""Should handle feature extraction errors gracefully."""
|
||||
with patch.object(csi_processor, '_extract_amplitude_features') as mock_amp:
|
||||
mock_amp.side_effect = Exception("Feature extraction error")
|
||||
|
||||
with pytest.raises(CSIProcessingError, match="Failed to extract features"):
|
||||
csi_processor.extract_features(sample_csi_data)
|
||||
|
||||
# Human detection tests
|
||||
def test_should_detect_human_presence_successfully(self, csi_processor, sample_features):
|
||||
"""Should detect human presence successfully."""
|
||||
with patch.object(csi_processor, '_analyze_motion_patterns') as mock_motion:
|
||||
with patch.object(csi_processor, '_calculate_detection_confidence') as mock_confidence:
|
||||
with patch.object(csi_processor, '_apply_temporal_smoothing') as mock_smooth:
|
||||
mock_motion.return_value = 0.9
|
||||
mock_confidence.return_value = 0.85
|
||||
mock_smooth.return_value = 0.88
|
||||
|
||||
result = csi_processor.detect_human_presence(sample_features)
|
||||
|
||||
assert isinstance(result, HumanDetectionResult)
|
||||
assert result.human_detected == True
|
||||
assert result.confidence == 0.88
|
||||
assert result.motion_score == 0.9
|
||||
mock_motion.assert_called_once()
|
||||
mock_confidence.assert_called_once()
|
||||
mock_smooth.assert_called_once()
|
||||
|
||||
def test_should_detect_no_human_presence(self, csi_processor, sample_features):
|
||||
"""Should detect no human presence when confidence is low."""
|
||||
with patch.object(csi_processor, '_analyze_motion_patterns') as mock_motion:
|
||||
with patch.object(csi_processor, '_calculate_detection_confidence') as mock_confidence:
|
||||
with patch.object(csi_processor, '_apply_temporal_smoothing') as mock_smooth:
|
||||
mock_motion.return_value = 0.3
|
||||
mock_confidence.return_value = 0.2
|
||||
mock_smooth.return_value = 0.25
|
||||
|
||||
result = csi_processor.detect_human_presence(sample_features)
|
||||
|
||||
assert result.human_detected == False
|
||||
assert result.confidence == 0.25
|
||||
assert result.motion_score == 0.3
|
||||
|
||||
def test_should_skip_human_detection_when_disabled(self, processor_config, mock_logger, sample_features):
|
||||
"""Should skip human detection when disabled."""
|
||||
processor_config['enable_human_detection'] = False
|
||||
processor = CSIProcessor(config=processor_config, logger=mock_logger)
|
||||
|
||||
result = processor.detect_human_presence(sample_features)
|
||||
|
||||
assert result is None
|
||||
|
||||
def test_should_handle_human_detection_error(self, csi_processor, sample_features):
|
||||
"""Should handle human detection errors gracefully."""
|
||||
with patch.object(csi_processor, '_analyze_motion_patterns') as mock_motion:
|
||||
mock_motion.side_effect = Exception("Detection error")
|
||||
|
||||
with pytest.raises(CSIProcessingError, match="Failed to detect human presence"):
|
||||
csi_processor.detect_human_presence(sample_features)
|
||||
|
||||
# Processing pipeline tests
|
||||
@pytest.mark.asyncio
|
||||
async def test_should_process_csi_data_pipeline_successfully(self, csi_processor, sample_csi_data, sample_features):
|
||||
"""Should process CSI data through full pipeline successfully."""
|
||||
expected_detection = HumanDetectionResult(
|
||||
human_detected=True,
|
||||
confidence=0.85,
|
||||
motion_score=0.9,
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
features=sample_features,
|
||||
metadata={}
|
||||
)
|
||||
|
||||
with patch.object(csi_processor, 'preprocess_csi_data', return_value=sample_csi_data) as mock_preprocess:
|
||||
with patch.object(csi_processor, 'extract_features', return_value=sample_features) as mock_features:
|
||||
with patch.object(csi_processor, 'detect_human_presence', return_value=expected_detection) as mock_detect:
|
||||
|
||||
result = await csi_processor.process_csi_data(sample_csi_data)
|
||||
|
||||
assert result == expected_detection
|
||||
mock_preprocess.assert_called_once_with(sample_csi_data)
|
||||
mock_features.assert_called_once_with(sample_csi_data)
|
||||
mock_detect.assert_called_once_with(sample_features)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_should_handle_pipeline_processing_error(self, csi_processor, sample_csi_data):
|
||||
"""Should handle pipeline processing errors gracefully."""
|
||||
with patch.object(csi_processor, 'preprocess_csi_data') as mock_preprocess:
|
||||
mock_preprocess.side_effect = CSIProcessingError("Pipeline error")
|
||||
|
||||
with pytest.raises(CSIProcessingError):
|
||||
await csi_processor.process_csi_data(sample_csi_data)
|
||||
|
||||
# History management tests
|
||||
def test_should_add_csi_data_to_history(self, csi_processor, sample_csi_data):
|
||||
"""Should add CSI data to history successfully."""
|
||||
csi_processor.add_to_history(sample_csi_data)
|
||||
|
||||
assert len(csi_processor.csi_history) == 1
|
||||
assert csi_processor.csi_history[0] == sample_csi_data
|
||||
|
||||
def test_should_maintain_history_size_limit(self, processor_config, mock_logger):
|
||||
"""Should maintain history size within limits."""
|
||||
processor_config['max_history_size'] = 2
|
||||
processor = CSIProcessor(config=processor_config, logger=mock_logger)
|
||||
|
||||
# Add 3 items to history of size 2
|
||||
for i in range(3):
|
||||
csi_data = CSIData(
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
amplitude=np.random.rand(3, 56),
|
||||
phase=np.random.rand(3, 56),
|
||||
frequency=2.4e9,
|
||||
bandwidth=20e6,
|
||||
num_subcarriers=56,
|
||||
num_antennas=3,
|
||||
snr=15.5,
|
||||
metadata={'index': i}
|
||||
)
|
||||
processor.add_to_history(csi_data)
|
||||
|
||||
assert len(processor.csi_history) == 2
|
||||
assert processor.csi_history[0].metadata['index'] == 1 # First item removed
|
||||
assert processor.csi_history[1].metadata['index'] == 2
|
||||
|
||||
def test_should_clear_history(self, csi_processor, sample_csi_data):
|
||||
"""Should clear history successfully."""
|
||||
csi_processor.add_to_history(sample_csi_data)
|
||||
assert len(csi_processor.csi_history) > 0
|
||||
|
||||
csi_processor.clear_history()
|
||||
|
||||
assert len(csi_processor.csi_history) == 0
|
||||
|
||||
def test_should_get_recent_history(self, csi_processor):
|
||||
"""Should get recent history entries."""
|
||||
# Add 5 items to history
|
||||
for i in range(5):
|
||||
csi_data = CSIData(
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
amplitude=np.random.rand(3, 56),
|
||||
phase=np.random.rand(3, 56),
|
||||
frequency=2.4e9,
|
||||
bandwidth=20e6,
|
||||
num_subcarriers=56,
|
||||
num_antennas=3,
|
||||
snr=15.5,
|
||||
metadata={'index': i}
|
||||
)
|
||||
csi_processor.add_to_history(csi_data)
|
||||
|
||||
recent = csi_processor.get_recent_history(3)
|
||||
|
||||
assert len(recent) == 3
|
||||
assert recent[0].metadata['index'] == 2 # Most recent first
|
||||
assert recent[1].metadata['index'] == 3
|
||||
assert recent[2].metadata['index'] == 4
|
||||
|
||||
# Statistics and monitoring tests
|
||||
def test_should_get_processing_statistics(self, csi_processor):
|
||||
"""Should get processing statistics."""
|
||||
# Simulate some processing
|
||||
csi_processor._total_processed = 100
|
||||
csi_processor._processing_errors = 5
|
||||
csi_processor._human_detections = 25
|
||||
|
||||
stats = csi_processor.get_processing_statistics()
|
||||
|
||||
assert isinstance(stats, dict)
|
||||
assert stats['total_processed'] == 100
|
||||
assert stats['processing_errors'] == 5
|
||||
assert stats['human_detections'] == 25
|
||||
assert stats['error_rate'] == 0.05
|
||||
assert stats['detection_rate'] == 0.25
|
||||
|
||||
def test_should_reset_statistics(self, csi_processor):
|
||||
"""Should reset processing statistics."""
|
||||
csi_processor._total_processed = 100
|
||||
csi_processor._processing_errors = 5
|
||||
csi_processor._human_detections = 25
|
||||
|
||||
csi_processor.reset_statistics()
|
||||
|
||||
assert csi_processor._total_processed == 0
|
||||
assert csi_processor._processing_errors == 0
|
||||
assert csi_processor._human_detections == 0
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.tdd
|
||||
@pytest.mark.london
|
||||
class TestCSIFeatures:
|
||||
"""Test CSI features data structure."""
|
||||
|
||||
def test_should_create_csi_features(self):
|
||||
"""Should create CSI features successfully."""
|
||||
features = CSIFeatures(
|
||||
amplitude_mean=np.random.rand(56),
|
||||
amplitude_variance=np.random.rand(56),
|
||||
phase_difference=np.random.rand(56),
|
||||
correlation_matrix=np.random.rand(3, 3),
|
||||
doppler_shift=np.random.rand(10),
|
||||
power_spectral_density=np.random.rand(128),
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
metadata={'test': 'data'}
|
||||
)
|
||||
|
||||
assert features.amplitude_mean.shape == (56,)
|
||||
assert features.amplitude_variance.shape == (56,)
|
||||
assert features.phase_difference.shape == (56,)
|
||||
assert features.correlation_matrix.shape == (3, 3)
|
||||
assert features.doppler_shift.shape == (10,)
|
||||
assert features.power_spectral_density.shape == (128,)
|
||||
assert isinstance(features.timestamp, datetime)
|
||||
assert features.metadata['test'] == 'data'
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.tdd
|
||||
@pytest.mark.london
|
||||
class TestHumanDetectionResult:
|
||||
"""Test human detection result data structure."""
|
||||
|
||||
@pytest.fixture
|
||||
def sample_features(self):
|
||||
"""Sample features for testing."""
|
||||
return CSIFeatures(
|
||||
amplitude_mean=np.random.rand(56),
|
||||
amplitude_variance=np.random.rand(56),
|
||||
phase_difference=np.random.rand(56),
|
||||
correlation_matrix=np.random.rand(3, 3),
|
||||
doppler_shift=np.random.rand(10),
|
||||
power_spectral_density=np.random.rand(128),
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
metadata={}
|
||||
)
|
||||
|
||||
def test_should_create_detection_result(self, sample_features):
|
||||
"""Should create human detection result successfully."""
|
||||
result = HumanDetectionResult(
|
||||
human_detected=True,
|
||||
confidence=0.85,
|
||||
motion_score=0.92,
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
features=sample_features,
|
||||
metadata={'test': 'data'}
|
||||
)
|
||||
|
||||
assert result.human_detected == True
|
||||
assert result.confidence == 0.85
|
||||
assert result.motion_score == 0.92
|
||||
assert isinstance(result.timestamp, datetime)
|
||||
assert result.features == sample_features
|
||||
assert result.metadata['test'] == 'data'
|
||||
@@ -0,0 +1,606 @@
|
||||
"""Standalone tests for CSI extractor module."""
|
||||
|
||||
import pytest
|
||||
import numpy as np
|
||||
import sys
|
||||
import os
|
||||
from unittest.mock import Mock, patch, AsyncMock
|
||||
import asyncio
|
||||
from datetime import datetime, timezone
|
||||
import importlib.util
|
||||
|
||||
# Resolve paths relative to v1/ (this file lives at v1/tests/unit/)
|
||||
_TESTS_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
_V1_DIR = os.path.abspath(os.path.join(_TESTS_DIR, '..', '..'))
|
||||
if _V1_DIR not in sys.path:
|
||||
sys.path.insert(0, _V1_DIR)
|
||||
|
||||
# Import the module directly to avoid circular imports
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
'csi_extractor',
|
||||
os.path.join(_V1_DIR, 'src', 'hardware', 'csi_extractor.py')
|
||||
)
|
||||
csi_module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(csi_module)
|
||||
|
||||
# Get classes from the module
|
||||
CSIExtractor = csi_module.CSIExtractor
|
||||
CSIExtractionError = csi_module.CSIExtractionError
|
||||
CSIParseError = csi_module.CSIParseError
|
||||
CSIData = csi_module.CSIData
|
||||
ESP32CSIParser = csi_module.ESP32CSIParser
|
||||
RouterCSIParser = csi_module.RouterCSIParser
|
||||
CSIValidationError = csi_module.CSIValidationError
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.tdd
|
||||
@pytest.mark.london
|
||||
class TestCSIExtractorStandalone:
|
||||
"""Standalone tests for CSI extractor with 100% coverage."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_logger(self):
|
||||
"""Mock logger for testing."""
|
||||
return Mock()
|
||||
|
||||
@pytest.fixture
|
||||
def esp32_config(self):
|
||||
"""ESP32 configuration for testing."""
|
||||
return {
|
||||
'hardware_type': 'esp32',
|
||||
'sampling_rate': 100,
|
||||
'buffer_size': 1024,
|
||||
'timeout': 5.0,
|
||||
'validation_enabled': True,
|
||||
'retry_attempts': 3
|
||||
}
|
||||
|
||||
@pytest.fixture
|
||||
def router_config(self):
|
||||
"""Router configuration for testing."""
|
||||
return {
|
||||
'hardware_type': 'router',
|
||||
'sampling_rate': 50,
|
||||
'buffer_size': 512,
|
||||
'timeout': 10.0,
|
||||
'validation_enabled': False,
|
||||
'retry_attempts': 1
|
||||
}
|
||||
|
||||
@pytest.fixture
|
||||
def sample_csi_data(self):
|
||||
"""Sample CSI data for testing."""
|
||||
return CSIData(
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
amplitude=np.random.rand(3, 56),
|
||||
phase=np.random.rand(3, 56),
|
||||
frequency=2.4e9,
|
||||
bandwidth=20e6,
|
||||
num_subcarriers=56,
|
||||
num_antennas=3,
|
||||
snr=15.5,
|
||||
metadata={'source': 'esp32', 'channel': 6}
|
||||
)
|
||||
|
||||
# Test all initialization paths
|
||||
def test_init_esp32_config(self, esp32_config, mock_logger):
|
||||
"""Should initialize with ESP32 configuration."""
|
||||
extractor = CSIExtractor(config=esp32_config, logger=mock_logger)
|
||||
|
||||
assert extractor.config == esp32_config
|
||||
assert extractor.logger == mock_logger
|
||||
assert extractor.is_connected == False
|
||||
assert extractor.hardware_type == 'esp32'
|
||||
assert isinstance(extractor.parser, ESP32CSIParser)
|
||||
|
||||
def test_init_router_config(self, router_config, mock_logger):
|
||||
"""Should initialize with router configuration."""
|
||||
extractor = CSIExtractor(config=router_config, logger=mock_logger)
|
||||
|
||||
assert isinstance(extractor.parser, RouterCSIParser)
|
||||
assert extractor.hardware_type == 'router'
|
||||
|
||||
def test_init_unsupported_hardware(self, mock_logger):
|
||||
"""Should raise error for unsupported hardware type."""
|
||||
invalid_config = {
|
||||
'hardware_type': 'unsupported',
|
||||
'sampling_rate': 100,
|
||||
'buffer_size': 1024,
|
||||
'timeout': 5.0
|
||||
}
|
||||
|
||||
with pytest.raises(ValueError, match="Unsupported hardware type: unsupported"):
|
||||
CSIExtractor(config=invalid_config, logger=mock_logger)
|
||||
|
||||
def test_init_without_logger(self, esp32_config):
|
||||
"""Should initialize without logger."""
|
||||
extractor = CSIExtractor(config=esp32_config)
|
||||
|
||||
assert extractor.logger is not None # Should create default logger
|
||||
|
||||
# Test all validation paths
|
||||
def test_validation_missing_fields(self, mock_logger):
|
||||
"""Should validate missing required fields."""
|
||||
for missing_field in ['hardware_type', 'sampling_rate', 'buffer_size', 'timeout']:
|
||||
config = {
|
||||
'hardware_type': 'esp32',
|
||||
'sampling_rate': 100,
|
||||
'buffer_size': 1024,
|
||||
'timeout': 5.0
|
||||
}
|
||||
del config[missing_field]
|
||||
|
||||
with pytest.raises(ValueError, match="Missing required configuration"):
|
||||
CSIExtractor(config=config, logger=mock_logger)
|
||||
|
||||
def test_validation_negative_sampling_rate(self, mock_logger):
|
||||
"""Should validate sampling_rate is positive."""
|
||||
config = {
|
||||
'hardware_type': 'esp32',
|
||||
'sampling_rate': -1,
|
||||
'buffer_size': 1024,
|
||||
'timeout': 5.0
|
||||
}
|
||||
|
||||
with pytest.raises(ValueError, match="sampling_rate must be positive"):
|
||||
CSIExtractor(config=config, logger=mock_logger)
|
||||
|
||||
def test_validation_zero_buffer_size(self, mock_logger):
|
||||
"""Should validate buffer_size is positive."""
|
||||
config = {
|
||||
'hardware_type': 'esp32',
|
||||
'sampling_rate': 100,
|
||||
'buffer_size': 0,
|
||||
'timeout': 5.0
|
||||
}
|
||||
|
||||
with pytest.raises(ValueError, match="buffer_size must be positive"):
|
||||
CSIExtractor(config=config, logger=mock_logger)
|
||||
|
||||
def test_validation_negative_timeout(self, mock_logger):
|
||||
"""Should validate timeout is positive."""
|
||||
config = {
|
||||
'hardware_type': 'esp32',
|
||||
'sampling_rate': 100,
|
||||
'buffer_size': 1024,
|
||||
'timeout': -1.0
|
||||
}
|
||||
|
||||
with pytest.raises(ValueError, match="timeout must be positive"):
|
||||
CSIExtractor(config=config, logger=mock_logger)
|
||||
|
||||
# Test connection management
|
||||
@pytest.mark.asyncio
|
||||
async def test_connect_success(self, esp32_config, mock_logger):
|
||||
"""Should connect successfully."""
|
||||
extractor = CSIExtractor(config=esp32_config, logger=mock_logger)
|
||||
|
||||
with patch.object(extractor, '_establish_hardware_connection', new_callable=AsyncMock) as mock_conn:
|
||||
mock_conn.return_value = True
|
||||
|
||||
result = await extractor.connect()
|
||||
|
||||
assert result == True
|
||||
assert extractor.is_connected == True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_connect_failure(self, esp32_config, mock_logger):
|
||||
"""Should handle connection failure."""
|
||||
extractor = CSIExtractor(config=esp32_config, logger=mock_logger)
|
||||
|
||||
with patch.object(extractor, '_establish_hardware_connection', new_callable=AsyncMock) as mock_conn:
|
||||
mock_conn.side_effect = ConnectionError("Failed")
|
||||
|
||||
result = await extractor.connect()
|
||||
|
||||
assert result == False
|
||||
assert extractor.is_connected == False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_disconnect_when_connected(self, esp32_config, mock_logger):
|
||||
"""Should disconnect when connected."""
|
||||
extractor = CSIExtractor(config=esp32_config, logger=mock_logger)
|
||||
extractor.is_connected = True
|
||||
|
||||
with patch.object(extractor, '_close_hardware_connection', new_callable=AsyncMock) as mock_close:
|
||||
await extractor.disconnect()
|
||||
|
||||
assert extractor.is_connected == False
|
||||
mock_close.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_disconnect_when_not_connected(self, esp32_config, mock_logger):
|
||||
"""Should not disconnect when not connected."""
|
||||
extractor = CSIExtractor(config=esp32_config, logger=mock_logger)
|
||||
extractor.is_connected = False
|
||||
|
||||
with patch.object(extractor, '_close_hardware_connection', new_callable=AsyncMock) as mock_close:
|
||||
await extractor.disconnect()
|
||||
|
||||
mock_close.assert_not_called()
|
||||
|
||||
# Test extraction
|
||||
@pytest.mark.asyncio
|
||||
async def test_extract_not_connected(self, esp32_config, mock_logger):
|
||||
"""Should raise error when not connected."""
|
||||
extractor = CSIExtractor(config=esp32_config, logger=mock_logger)
|
||||
extractor.is_connected = False
|
||||
|
||||
with pytest.raises(CSIParseError, match="Not connected to hardware"):
|
||||
await extractor.extract_csi()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_extract_success_with_validation(self, esp32_config, mock_logger, sample_csi_data):
|
||||
"""Should extract successfully with validation."""
|
||||
extractor = CSIExtractor(config=esp32_config, logger=mock_logger)
|
||||
extractor.is_connected = True
|
||||
|
||||
with patch.object(extractor, '_read_raw_data', new_callable=AsyncMock) as mock_read:
|
||||
with patch.object(extractor.parser, 'parse', return_value=sample_csi_data):
|
||||
with patch.object(extractor, 'validate_csi_data', return_value=True) as mock_validate:
|
||||
mock_read.return_value = b"raw_data"
|
||||
|
||||
result = await extractor.extract_csi()
|
||||
|
||||
assert result == sample_csi_data
|
||||
mock_validate.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_extract_success_without_validation(self, esp32_config, mock_logger, sample_csi_data):
|
||||
"""Should extract successfully without validation."""
|
||||
esp32_config['validation_enabled'] = False
|
||||
extractor = CSIExtractor(config=esp32_config, logger=mock_logger)
|
||||
extractor.is_connected = True
|
||||
|
||||
with patch.object(extractor, '_read_raw_data', new_callable=AsyncMock) as mock_read:
|
||||
with patch.object(extractor.parser, 'parse', return_value=sample_csi_data):
|
||||
with patch.object(extractor, 'validate_csi_data') as mock_validate:
|
||||
mock_read.return_value = b"raw_data"
|
||||
|
||||
result = await extractor.extract_csi()
|
||||
|
||||
assert result == sample_csi_data
|
||||
mock_validate.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_extract_retry_success(self, esp32_config, mock_logger, sample_csi_data):
|
||||
"""Should retry and succeed."""
|
||||
esp32_config['retry_attempts'] = 3
|
||||
extractor = CSIExtractor(config=esp32_config, logger=mock_logger)
|
||||
extractor.is_connected = True
|
||||
|
||||
with patch.object(extractor, '_read_raw_data', new_callable=AsyncMock) as mock_read:
|
||||
with patch.object(extractor.parser, 'parse', return_value=sample_csi_data):
|
||||
# Fail first two attempts, succeed on third
|
||||
mock_read.side_effect = [ConnectionError(), ConnectionError(), b"raw_data"]
|
||||
|
||||
result = await extractor.extract_csi()
|
||||
|
||||
assert result == sample_csi_data
|
||||
assert mock_read.call_count == 3
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_extract_retry_failure(self, esp32_config, mock_logger):
|
||||
"""Should fail after max retries."""
|
||||
esp32_config['retry_attempts'] = 2
|
||||
extractor = CSIExtractor(config=esp32_config, logger=mock_logger)
|
||||
extractor.is_connected = True
|
||||
|
||||
with patch.object(extractor, '_read_raw_data', new_callable=AsyncMock) as mock_read:
|
||||
mock_read.side_effect = ConnectionError("Failed")
|
||||
|
||||
with pytest.raises(CSIParseError, match="Extraction failed after 2 attempts"):
|
||||
await extractor.extract_csi()
|
||||
|
||||
# Test validation
|
||||
def test_validate_success(self, esp32_config, mock_logger, sample_csi_data):
|
||||
"""Should validate successfully."""
|
||||
extractor = CSIExtractor(config=esp32_config, logger=mock_logger)
|
||||
|
||||
result = extractor.validate_csi_data(sample_csi_data)
|
||||
|
||||
assert result == True
|
||||
|
||||
def test_validate_empty_amplitude(self, esp32_config, mock_logger):
|
||||
"""Should reject empty amplitude."""
|
||||
extractor = CSIExtractor(config=esp32_config, logger=mock_logger)
|
||||
|
||||
data = CSIData(
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
amplitude=np.array([]),
|
||||
phase=np.random.rand(3, 56),
|
||||
frequency=2.4e9,
|
||||
bandwidth=20e6,
|
||||
num_subcarriers=56,
|
||||
num_antennas=3,
|
||||
snr=15.5,
|
||||
metadata={}
|
||||
)
|
||||
|
||||
with pytest.raises(CSIValidationError, match="Empty amplitude data"):
|
||||
extractor.validate_csi_data(data)
|
||||
|
||||
def test_validate_empty_phase(self, esp32_config, mock_logger):
|
||||
"""Should reject empty phase."""
|
||||
extractor = CSIExtractor(config=esp32_config, logger=mock_logger)
|
||||
|
||||
data = CSIData(
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
amplitude=np.random.rand(3, 56),
|
||||
phase=np.array([]),
|
||||
frequency=2.4e9,
|
||||
bandwidth=20e6,
|
||||
num_subcarriers=56,
|
||||
num_antennas=3,
|
||||
snr=15.5,
|
||||
metadata={}
|
||||
)
|
||||
|
||||
with pytest.raises(CSIValidationError, match="Empty phase data"):
|
||||
extractor.validate_csi_data(data)
|
||||
|
||||
def test_validate_invalid_frequency(self, esp32_config, mock_logger):
|
||||
"""Should reject invalid frequency."""
|
||||
extractor = CSIExtractor(config=esp32_config, logger=mock_logger)
|
||||
|
||||
data = CSIData(
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
amplitude=np.random.rand(3, 56),
|
||||
phase=np.random.rand(3, 56),
|
||||
frequency=0,
|
||||
bandwidth=20e6,
|
||||
num_subcarriers=56,
|
||||
num_antennas=3,
|
||||
snr=15.5,
|
||||
metadata={}
|
||||
)
|
||||
|
||||
with pytest.raises(CSIValidationError, match="Invalid frequency"):
|
||||
extractor.validate_csi_data(data)
|
||||
|
||||
def test_validate_invalid_bandwidth(self, esp32_config, mock_logger):
|
||||
"""Should reject invalid bandwidth."""
|
||||
extractor = CSIExtractor(config=esp32_config, logger=mock_logger)
|
||||
|
||||
data = CSIData(
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
amplitude=np.random.rand(3, 56),
|
||||
phase=np.random.rand(3, 56),
|
||||
frequency=2.4e9,
|
||||
bandwidth=0,
|
||||
num_subcarriers=56,
|
||||
num_antennas=3,
|
||||
snr=15.5,
|
||||
metadata={}
|
||||
)
|
||||
|
||||
with pytest.raises(CSIValidationError, match="Invalid bandwidth"):
|
||||
extractor.validate_csi_data(data)
|
||||
|
||||
def test_validate_invalid_subcarriers(self, esp32_config, mock_logger):
|
||||
"""Should reject invalid subcarriers."""
|
||||
extractor = CSIExtractor(config=esp32_config, logger=mock_logger)
|
||||
|
||||
data = CSIData(
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
amplitude=np.random.rand(3, 56),
|
||||
phase=np.random.rand(3, 56),
|
||||
frequency=2.4e9,
|
||||
bandwidth=20e6,
|
||||
num_subcarriers=0,
|
||||
num_antennas=3,
|
||||
snr=15.5,
|
||||
metadata={}
|
||||
)
|
||||
|
||||
with pytest.raises(CSIValidationError, match="Invalid number of subcarriers"):
|
||||
extractor.validate_csi_data(data)
|
||||
|
||||
def test_validate_invalid_antennas(self, esp32_config, mock_logger):
|
||||
"""Should reject invalid antennas."""
|
||||
extractor = CSIExtractor(config=esp32_config, logger=mock_logger)
|
||||
|
||||
data = CSIData(
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
amplitude=np.random.rand(3, 56),
|
||||
phase=np.random.rand(3, 56),
|
||||
frequency=2.4e9,
|
||||
bandwidth=20e6,
|
||||
num_subcarriers=56,
|
||||
num_antennas=0,
|
||||
snr=15.5,
|
||||
metadata={}
|
||||
)
|
||||
|
||||
with pytest.raises(CSIValidationError, match="Invalid number of antennas"):
|
||||
extractor.validate_csi_data(data)
|
||||
|
||||
def test_validate_snr_too_low(self, esp32_config, mock_logger):
|
||||
"""Should reject SNR too low."""
|
||||
extractor = CSIExtractor(config=esp32_config, logger=mock_logger)
|
||||
|
||||
data = CSIData(
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
amplitude=np.random.rand(3, 56),
|
||||
phase=np.random.rand(3, 56),
|
||||
frequency=2.4e9,
|
||||
bandwidth=20e6,
|
||||
num_subcarriers=56,
|
||||
num_antennas=3,
|
||||
snr=-100,
|
||||
metadata={}
|
||||
)
|
||||
|
||||
with pytest.raises(CSIValidationError, match="Invalid SNR value"):
|
||||
extractor.validate_csi_data(data)
|
||||
|
||||
def test_validate_snr_too_high(self, esp32_config, mock_logger):
|
||||
"""Should reject SNR too high."""
|
||||
extractor = CSIExtractor(config=esp32_config, logger=mock_logger)
|
||||
|
||||
data = CSIData(
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
amplitude=np.random.rand(3, 56),
|
||||
phase=np.random.rand(3, 56),
|
||||
frequency=2.4e9,
|
||||
bandwidth=20e6,
|
||||
num_subcarriers=56,
|
||||
num_antennas=3,
|
||||
snr=100,
|
||||
metadata={}
|
||||
)
|
||||
|
||||
with pytest.raises(CSIValidationError, match="Invalid SNR value"):
|
||||
extractor.validate_csi_data(data)
|
||||
|
||||
# Test streaming
|
||||
@pytest.mark.asyncio
|
||||
async def test_streaming_success(self, esp32_config, mock_logger, sample_csi_data):
|
||||
"""Should stream successfully."""
|
||||
extractor = CSIExtractor(config=esp32_config, logger=mock_logger)
|
||||
extractor.is_connected = True
|
||||
callback = Mock()
|
||||
|
||||
with patch.object(extractor, 'extract_csi', new_callable=AsyncMock) as mock_extract:
|
||||
mock_extract.return_value = sample_csi_data
|
||||
|
||||
# Start streaming task
|
||||
task = asyncio.create_task(extractor.start_streaming(callback))
|
||||
await asyncio.sleep(0.1) # Let it run briefly
|
||||
extractor.stop_streaming()
|
||||
await task
|
||||
|
||||
callback.assert_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_streaming_exception(self, esp32_config, mock_logger):
|
||||
"""Should handle streaming exceptions."""
|
||||
extractor = CSIExtractor(config=esp32_config, logger=mock_logger)
|
||||
extractor.is_connected = True
|
||||
callback = Mock()
|
||||
|
||||
with patch.object(extractor, 'extract_csi', new_callable=AsyncMock) as mock_extract:
|
||||
mock_extract.side_effect = Exception("Test error")
|
||||
|
||||
# Start streaming and let it handle exception
|
||||
task = asyncio.create_task(extractor.start_streaming(callback))
|
||||
await task # This should complete due to exception
|
||||
|
||||
assert extractor.is_streaming == False
|
||||
|
||||
def test_stop_streaming(self, esp32_config, mock_logger):
|
||||
"""Should stop streaming."""
|
||||
extractor = CSIExtractor(config=esp32_config, logger=mock_logger)
|
||||
extractor.is_streaming = True
|
||||
|
||||
extractor.stop_streaming()
|
||||
|
||||
assert extractor.is_streaming == False
|
||||
|
||||
# Test placeholder implementations for 100% coverage
|
||||
@pytest.mark.asyncio
|
||||
async def test_establish_hardware_connection_placeholder(self, esp32_config, mock_logger):
|
||||
"""Should test placeholder hardware connection."""
|
||||
extractor = CSIExtractor(config=esp32_config, logger=mock_logger)
|
||||
|
||||
result = await extractor._establish_hardware_connection()
|
||||
|
||||
assert result == True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_close_hardware_connection_placeholder(self, esp32_config, mock_logger):
|
||||
"""Should test placeholder hardware disconnection."""
|
||||
extractor = CSIExtractor(config=esp32_config, logger=mock_logger)
|
||||
|
||||
# Should not raise any exception
|
||||
await extractor._close_hardware_connection()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_raw_data_placeholder(self, esp32_config, mock_logger):
|
||||
"""Should test placeholder raw data reading."""
|
||||
extractor = CSIExtractor(config=esp32_config, logger=mock_logger)
|
||||
|
||||
result = await extractor._read_raw_data()
|
||||
|
||||
assert result == b"CSI_DATA:1234567890,3,56,2400,20,15.5,[1.0,2.0,3.0],[0.5,1.5,2.5]"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.tdd
|
||||
class TestESP32CSIParserStandalone:
|
||||
"""Standalone tests for ESP32 CSI parser."""
|
||||
|
||||
@pytest.fixture
|
||||
def parser(self):
|
||||
"""Create parser instance."""
|
||||
return ESP32CSIParser()
|
||||
|
||||
def test_parse_valid_data(self, parser):
|
||||
"""Should parse valid ESP32 data."""
|
||||
n_ant, n_sub = 3, 56
|
||||
amp = ",".join(["1.0"] * (n_ant * n_sub))
|
||||
pha = ",".join(["0.5"] * (n_ant * n_sub))
|
||||
data = f"CSI_DATA:1234567890,{n_ant},{n_sub},2400,20,15.5,{amp},{pha}".encode()
|
||||
|
||||
result = parser.parse(data)
|
||||
|
||||
assert isinstance(result, CSIData)
|
||||
assert result.num_antennas == 3
|
||||
assert result.num_subcarriers == 56
|
||||
assert result.frequency == 2400000000
|
||||
assert result.bandwidth == 20000000
|
||||
assert result.snr == 15.5
|
||||
|
||||
def test_parse_empty_data(self, parser):
|
||||
"""Should reject empty data."""
|
||||
with pytest.raises(CSIParseError, match="Empty data received"):
|
||||
parser.parse(b"")
|
||||
|
||||
def test_parse_invalid_format(self, parser):
|
||||
"""Should reject invalid format."""
|
||||
with pytest.raises(CSIParseError, match="Invalid ESP32 CSI data format"):
|
||||
parser.parse(b"INVALID_DATA")
|
||||
|
||||
def test_parse_value_error(self, parser):
|
||||
"""Should handle ValueError."""
|
||||
data = b"CSI_DATA:invalid_number,3,56,2400,20,15.5"
|
||||
|
||||
with pytest.raises(CSIParseError, match="Failed to parse ESP32 data"):
|
||||
parser.parse(data)
|
||||
|
||||
def test_parse_index_error(self, parser):
|
||||
"""Should handle IndexError."""
|
||||
data = b"CSI_DATA:1234567890" # Missing fields
|
||||
|
||||
with pytest.raises(CSIParseError, match="Failed to parse ESP32 data"):
|
||||
parser.parse(data)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.tdd
|
||||
class TestRouterCSIParserStandalone:
|
||||
"""Standalone tests for Router CSI parser."""
|
||||
|
||||
@pytest.fixture
|
||||
def parser(self):
|
||||
"""Create parser instance."""
|
||||
return RouterCSIParser()
|
||||
|
||||
def test_parse_empty_data(self, parser):
|
||||
"""Should reject empty data."""
|
||||
with pytest.raises(CSIParseError, match="Empty data received"):
|
||||
parser.parse(b"")
|
||||
|
||||
def test_parse_atheros_format(self, parser):
|
||||
"""Should raise CSIExtractionError for Atheros format — real parser not yet implemented."""
|
||||
data = b"ATHEROS_CSI:some_binary_data"
|
||||
with pytest.raises(CSIExtractionError, match="Atheros CSI format parsing is not yet implemented"):
|
||||
parser.parse(data)
|
||||
|
||||
def test_parse_unknown_format(self, parser):
|
||||
"""Should reject unknown format."""
|
||||
data = b"UNKNOWN_FORMAT:data"
|
||||
|
||||
with pytest.raises(CSIParseError, match="Unknown router CSI format"):
|
||||
parser.parse(data)
|
||||
@@ -0,0 +1,367 @@
|
||||
import pytest
|
||||
import torch
|
||||
import torch.nn as nn
|
||||
import numpy as np
|
||||
from unittest.mock import Mock, patch
|
||||
from src.models.densepose_head import DensePoseHead, DensePoseError
|
||||
|
||||
|
||||
class TestDensePoseHead:
|
||||
"""Test suite for DensePose Head following London School TDD principles"""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_config(self):
|
||||
"""Configuration for DensePose head"""
|
||||
return {
|
||||
'input_channels': 256,
|
||||
'num_body_parts': 24,
|
||||
'num_uv_coordinates': 2,
|
||||
'hidden_channels': [128, 64],
|
||||
'kernel_size': 3,
|
||||
'padding': 1,
|
||||
'dropout_rate': 0.1,
|
||||
'use_deformable_conv': False,
|
||||
'use_fpn': True,
|
||||
'fpn_levels': [2, 3, 4, 5],
|
||||
'output_stride': 4
|
||||
}
|
||||
|
||||
@pytest.fixture
|
||||
def densepose_head(self, mock_config):
|
||||
"""Create DensePose head instance for testing"""
|
||||
return DensePoseHead(mock_config)
|
||||
|
||||
@pytest.fixture
|
||||
def mock_feature_input(self):
|
||||
"""Generate mock feature input tensor"""
|
||||
batch_size = 2
|
||||
channels = 256
|
||||
height = 56
|
||||
width = 56
|
||||
return torch.randn(batch_size, channels, height, width)
|
||||
|
||||
@pytest.fixture
|
||||
def mock_target_masks(self):
|
||||
"""Generate mock target segmentation masks"""
|
||||
batch_size = 2
|
||||
num_parts = 24
|
||||
height = 224
|
||||
width = 224
|
||||
return torch.randint(0, num_parts + 1, (batch_size, height, width))
|
||||
|
||||
@pytest.fixture
|
||||
def mock_target_uv(self):
|
||||
"""Generate mock target UV coordinates"""
|
||||
batch_size = 2
|
||||
num_coords = 2
|
||||
height = 224
|
||||
width = 224
|
||||
return torch.randn(batch_size, num_coords, height, width)
|
||||
|
||||
def test_head_initialization_creates_correct_architecture(self, mock_config):
|
||||
"""Test that DensePose head initializes with correct architecture"""
|
||||
# Act
|
||||
head = DensePoseHead(mock_config)
|
||||
|
||||
# Assert
|
||||
assert head is not None
|
||||
assert isinstance(head, nn.Module)
|
||||
assert head.input_channels == mock_config['input_channels']
|
||||
assert head.num_body_parts == mock_config['num_body_parts']
|
||||
assert head.num_uv_coordinates == mock_config['num_uv_coordinates']
|
||||
assert head.use_fpn == mock_config['use_fpn']
|
||||
assert hasattr(head, 'segmentation_head')
|
||||
assert hasattr(head, 'uv_regression_head')
|
||||
if mock_config['use_fpn']:
|
||||
assert hasattr(head, 'fpn')
|
||||
|
||||
def test_forward_pass_produces_correct_output_format(self, densepose_head, mock_feature_input):
|
||||
"""Test that forward pass produces correctly formatted output"""
|
||||
# Act
|
||||
output = densepose_head(mock_feature_input)
|
||||
|
||||
# Assert
|
||||
assert output is not None
|
||||
assert isinstance(output, dict)
|
||||
assert 'segmentation' in output
|
||||
assert 'uv_coordinates' in output
|
||||
|
||||
seg_output = output['segmentation']
|
||||
uv_output = output['uv_coordinates']
|
||||
|
||||
assert isinstance(seg_output, torch.Tensor)
|
||||
assert isinstance(uv_output, torch.Tensor)
|
||||
assert seg_output.shape[0] == mock_feature_input.shape[0] # Batch size preserved
|
||||
assert uv_output.shape[0] == mock_feature_input.shape[0] # Batch size preserved
|
||||
|
||||
def test_segmentation_head_produces_correct_shape(self, densepose_head, mock_feature_input):
|
||||
"""Test that segmentation head produces correct output shape"""
|
||||
# Act
|
||||
output = densepose_head(mock_feature_input)
|
||||
seg_output = output['segmentation']
|
||||
|
||||
# Assert
|
||||
expected_channels = densepose_head.num_body_parts + 1 # +1 for background
|
||||
assert seg_output.shape[1] == expected_channels
|
||||
assert seg_output.shape[2] >= mock_feature_input.shape[2] # Height upsampled
|
||||
assert seg_output.shape[3] >= mock_feature_input.shape[3] # Width upsampled
|
||||
|
||||
def test_uv_regression_head_produces_correct_shape(self, densepose_head, mock_feature_input):
|
||||
"""Test that UV regression head produces correct output shape"""
|
||||
# Act
|
||||
output = densepose_head(mock_feature_input)
|
||||
uv_output = output['uv_coordinates']
|
||||
|
||||
# Assert
|
||||
assert uv_output.shape[1] == densepose_head.num_uv_coordinates
|
||||
assert uv_output.shape[2] >= mock_feature_input.shape[2] # Height upsampled
|
||||
assert uv_output.shape[3] >= mock_feature_input.shape[3] # Width upsampled
|
||||
|
||||
def test_compute_segmentation_loss_measures_pixel_classification(self, densepose_head, mock_feature_input, mock_target_masks):
|
||||
"""Test that compute_segmentation_loss measures pixel classification accuracy"""
|
||||
# Arrange
|
||||
output = densepose_head(mock_feature_input)
|
||||
seg_logits = output['segmentation']
|
||||
|
||||
# Resize target to match output
|
||||
target_resized = torch.nn.functional.interpolate(
|
||||
mock_target_masks.float().unsqueeze(1),
|
||||
size=seg_logits.shape[2:],
|
||||
mode='nearest'
|
||||
).squeeze(1).long()
|
||||
|
||||
# Act
|
||||
loss = densepose_head.compute_segmentation_loss(seg_logits, target_resized)
|
||||
|
||||
# Assert
|
||||
assert loss is not None
|
||||
assert isinstance(loss, torch.Tensor)
|
||||
assert loss.dim() == 0 # Scalar loss
|
||||
assert loss.item() >= 0 # Loss should be non-negative
|
||||
|
||||
def test_compute_uv_loss_measures_coordinate_regression(self, densepose_head, mock_feature_input, mock_target_uv):
|
||||
"""Test that compute_uv_loss measures UV coordinate regression accuracy"""
|
||||
# Arrange
|
||||
output = densepose_head(mock_feature_input)
|
||||
uv_pred = output['uv_coordinates']
|
||||
|
||||
# Resize target to match output
|
||||
target_resized = torch.nn.functional.interpolate(
|
||||
mock_target_uv,
|
||||
size=uv_pred.shape[2:],
|
||||
mode='bilinear',
|
||||
align_corners=False
|
||||
)
|
||||
|
||||
# Act
|
||||
loss = densepose_head.compute_uv_loss(uv_pred, target_resized)
|
||||
|
||||
# Assert
|
||||
assert loss is not None
|
||||
assert isinstance(loss, torch.Tensor)
|
||||
assert loss.dim() == 0 # Scalar loss
|
||||
assert loss.item() >= 0 # Loss should be non-negative
|
||||
|
||||
def test_compute_total_loss_combines_segmentation_and_uv_losses(self, densepose_head, mock_feature_input, mock_target_masks, mock_target_uv):
|
||||
"""Test that compute_total_loss combines segmentation and UV losses"""
|
||||
# Arrange
|
||||
output = densepose_head(mock_feature_input)
|
||||
|
||||
# Resize targets to match outputs
|
||||
seg_target = torch.nn.functional.interpolate(
|
||||
mock_target_masks.float().unsqueeze(1),
|
||||
size=output['segmentation'].shape[2:],
|
||||
mode='nearest'
|
||||
).squeeze(1).long()
|
||||
|
||||
uv_target = torch.nn.functional.interpolate(
|
||||
mock_target_uv,
|
||||
size=output['uv_coordinates'].shape[2:],
|
||||
mode='bilinear',
|
||||
align_corners=False
|
||||
)
|
||||
|
||||
# Act
|
||||
total_loss = densepose_head.compute_total_loss(output, seg_target, uv_target)
|
||||
seg_loss = densepose_head.compute_segmentation_loss(output['segmentation'], seg_target)
|
||||
uv_loss = densepose_head.compute_uv_loss(output['uv_coordinates'], uv_target)
|
||||
|
||||
# Assert
|
||||
assert total_loss is not None
|
||||
assert isinstance(total_loss, torch.Tensor)
|
||||
assert total_loss.item() > 0
|
||||
# Total loss should be combination of individual losses
|
||||
expected_total = seg_loss + uv_loss
|
||||
assert torch.allclose(total_loss, expected_total, atol=1e-6)
|
||||
|
||||
def test_fpn_integration_enhances_multi_scale_features(self, mock_config, mock_feature_input):
|
||||
"""Test that FPN integration enhances multi-scale feature processing"""
|
||||
# Arrange
|
||||
config_with_fpn = mock_config.copy()
|
||||
config_with_fpn['use_fpn'] = True
|
||||
|
||||
config_without_fpn = mock_config.copy()
|
||||
config_without_fpn['use_fpn'] = False
|
||||
|
||||
head_with_fpn = DensePoseHead(config_with_fpn)
|
||||
head_without_fpn = DensePoseHead(config_without_fpn)
|
||||
|
||||
# Act
|
||||
output_with_fpn = head_with_fpn(mock_feature_input)
|
||||
output_without_fpn = head_without_fpn(mock_feature_input)
|
||||
|
||||
# Assert
|
||||
assert output_with_fpn['segmentation'].shape == output_without_fpn['segmentation'].shape
|
||||
assert output_with_fpn['uv_coordinates'].shape == output_without_fpn['uv_coordinates'].shape
|
||||
# Outputs should be different due to FPN
|
||||
assert not torch.allclose(output_with_fpn['segmentation'], output_without_fpn['segmentation'], atol=1e-6)
|
||||
|
||||
def test_get_prediction_confidence_provides_uncertainty_estimates(self, densepose_head, mock_feature_input):
|
||||
"""Test that get_prediction_confidence provides uncertainty estimates"""
|
||||
# Arrange
|
||||
output = densepose_head(mock_feature_input)
|
||||
|
||||
# Act
|
||||
confidence = densepose_head.get_prediction_confidence(output)
|
||||
|
||||
# Assert
|
||||
assert confidence is not None
|
||||
assert isinstance(confidence, dict)
|
||||
assert 'segmentation_confidence' in confidence
|
||||
assert 'uv_confidence' in confidence
|
||||
|
||||
seg_conf = confidence['segmentation_confidence']
|
||||
uv_conf = confidence['uv_confidence']
|
||||
|
||||
assert isinstance(seg_conf, torch.Tensor)
|
||||
assert isinstance(uv_conf, torch.Tensor)
|
||||
assert seg_conf.shape[0] == mock_feature_input.shape[0]
|
||||
assert uv_conf.shape[0] == mock_feature_input.shape[0]
|
||||
|
||||
def test_post_process_predictions_formats_output(self, densepose_head, mock_feature_input):
|
||||
"""Test that post_process_predictions formats output correctly"""
|
||||
# Arrange
|
||||
raw_output = densepose_head(mock_feature_input)
|
||||
|
||||
# Act
|
||||
processed = densepose_head.post_process_predictions(raw_output)
|
||||
|
||||
# Assert
|
||||
assert processed is not None
|
||||
assert isinstance(processed, dict)
|
||||
assert 'body_parts' in processed
|
||||
assert 'uv_coordinates' in processed
|
||||
assert 'confidence_scores' in processed
|
||||
|
||||
def test_training_mode_enables_dropout(self, densepose_head, mock_feature_input):
|
||||
"""Test that training mode enables dropout for regularization"""
|
||||
# Arrange
|
||||
densepose_head.train()
|
||||
|
||||
# Act
|
||||
output1 = densepose_head(mock_feature_input)
|
||||
output2 = densepose_head(mock_feature_input)
|
||||
|
||||
# Assert - outputs should be different due to dropout
|
||||
assert not torch.allclose(output1['segmentation'], output2['segmentation'], atol=1e-6)
|
||||
assert not torch.allclose(output1['uv_coordinates'], output2['uv_coordinates'], atol=1e-6)
|
||||
|
||||
def test_evaluation_mode_disables_dropout(self, densepose_head, mock_feature_input):
|
||||
"""Test that evaluation mode disables dropout for consistent inference"""
|
||||
# Arrange
|
||||
densepose_head.eval()
|
||||
|
||||
# Act
|
||||
output1 = densepose_head(mock_feature_input)
|
||||
output2 = densepose_head(mock_feature_input)
|
||||
|
||||
# Assert - outputs should be identical in eval mode
|
||||
assert torch.allclose(output1['segmentation'], output2['segmentation'], atol=1e-6)
|
||||
assert torch.allclose(output1['uv_coordinates'], output2['uv_coordinates'], atol=1e-6)
|
||||
|
||||
def test_head_validates_input_dimensions(self, densepose_head):
|
||||
"""Test that head validates input dimensions"""
|
||||
# Arrange
|
||||
invalid_input = torch.randn(2, 128, 56, 56) # Wrong number of channels
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(DensePoseError):
|
||||
densepose_head(invalid_input)
|
||||
|
||||
def test_head_handles_different_input_sizes(self, densepose_head):
|
||||
"""Test that head handles different input sizes"""
|
||||
# Arrange
|
||||
small_input = torch.randn(1, 256, 28, 28)
|
||||
large_input = torch.randn(1, 256, 112, 112)
|
||||
|
||||
# Act
|
||||
small_output = densepose_head(small_input)
|
||||
large_output = densepose_head(large_input)
|
||||
|
||||
# Assert
|
||||
assert small_output['segmentation'].shape[2:] != large_output['segmentation'].shape[2:]
|
||||
assert small_output['uv_coordinates'].shape[2:] != large_output['uv_coordinates'].shape[2:]
|
||||
|
||||
def test_head_supports_gradient_computation(self, densepose_head, mock_feature_input, mock_target_masks, mock_target_uv):
|
||||
"""Test that head supports gradient computation for training"""
|
||||
# Arrange
|
||||
densepose_head.train()
|
||||
optimizer = torch.optim.Adam(densepose_head.parameters(), lr=0.001)
|
||||
|
||||
output = densepose_head(mock_feature_input)
|
||||
|
||||
# Resize targets
|
||||
seg_target = torch.nn.functional.interpolate(
|
||||
mock_target_masks.float().unsqueeze(1),
|
||||
size=output['segmentation'].shape[2:],
|
||||
mode='nearest'
|
||||
).squeeze(1).long()
|
||||
|
||||
uv_target = torch.nn.functional.interpolate(
|
||||
mock_target_uv,
|
||||
size=output['uv_coordinates'].shape[2:],
|
||||
mode='bilinear',
|
||||
align_corners=False
|
||||
)
|
||||
|
||||
# Act
|
||||
loss = densepose_head.compute_total_loss(output, seg_target, uv_target)
|
||||
|
||||
optimizer.zero_grad()
|
||||
loss.backward()
|
||||
|
||||
# Assert
|
||||
for param in densepose_head.parameters():
|
||||
if param.requires_grad:
|
||||
assert param.grad is not None
|
||||
assert not torch.allclose(param.grad, torch.zeros_like(param.grad))
|
||||
|
||||
def test_head_configuration_validation(self):
|
||||
"""Test that head validates configuration parameters"""
|
||||
# Arrange
|
||||
invalid_config = {
|
||||
'input_channels': 0, # Invalid
|
||||
'num_body_parts': -1, # Invalid
|
||||
'num_uv_coordinates': 2
|
||||
}
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(ValueError):
|
||||
DensePoseHead(invalid_config)
|
||||
|
||||
def test_save_and_load_model_state(self, densepose_head, mock_feature_input):
|
||||
"""Test that model state can be saved and loaded"""
|
||||
# Arrange
|
||||
original_output = densepose_head(mock_feature_input)
|
||||
|
||||
# Act - Save state
|
||||
state_dict = densepose_head.state_dict()
|
||||
|
||||
# Create new head and load state
|
||||
new_head = DensePoseHead(densepose_head.config)
|
||||
new_head.load_state_dict(state_dict)
|
||||
new_output = new_head(mock_feature_input)
|
||||
|
||||
# Assert
|
||||
assert torch.allclose(original_output['segmentation'], new_output['segmentation'], atol=1e-6)
|
||||
assert torch.allclose(original_output['uv_coordinates'], new_output['uv_coordinates'], atol=1e-6)
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Tests for error handling in the API layer."""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
class TestExceptionHandlers:
|
||||
"""Test the exception handlers registered on the FastAPI app."""
|
||||
|
||||
def _get_app(self):
|
||||
"""Import app lazily to avoid side effects."""
|
||||
with patch("src.api.main.get_settings") as mock_gs, \
|
||||
patch("src.api.main.get_domain_config") as mock_gdc, \
|
||||
patch("src.api.main.get_pose_service") as mock_ps, \
|
||||
patch("src.api.main.get_stream_service") as mock_ss, \
|
||||
patch("src.api.main.get_hardware_service") as mock_hs, \
|
||||
patch("src.api.main.connection_manager") as mock_cm, \
|
||||
patch("src.api.main.PoseStreamHandler") as mock_psh:
|
||||
mock_gs.return_value = MagicMock(
|
||||
app_name="test", version="0.1", environment="test",
|
||||
is_production=False, enable_rate_limiting=False,
|
||||
enable_authentication=False, docs_url="/docs",
|
||||
redoc_url="/redoc", openapi_url="/openapi.json",
|
||||
api_prefix="/api/v1",
|
||||
)
|
||||
mock_gs.return_value.get_logging_config.return_value = {
|
||||
"version": 1, "disable_existing_loggers": False,
|
||||
"handlers": {}, "loggers": {},
|
||||
}
|
||||
mock_gs.return_value.get_cors_config.return_value = {
|
||||
"allow_origins": ["*"], "allow_methods": ["*"],
|
||||
"allow_headers": ["*"],
|
||||
}
|
||||
# Re-import to pick up patches
|
||||
import importlib
|
||||
import src.api.main as m
|
||||
importlib.reload(m)
|
||||
return m.app
|
||||
|
||||
|
||||
class TestErrorResponseModel:
|
||||
def test_error_json_structure(self):
|
||||
"""Verify error JSON has code, message, type fields."""
|
||||
error = {
|
||||
"error": {
|
||||
"code": 404,
|
||||
"message": "Not found",
|
||||
"type": "http_error"
|
||||
}
|
||||
}
|
||||
assert error["error"]["code"] == 404
|
||||
assert "message" in error["error"]
|
||||
assert "type" in error["error"]
|
||||
|
||||
def test_validation_error_structure(self):
|
||||
error = {
|
||||
"error": {
|
||||
"code": 422,
|
||||
"message": "Validation error",
|
||||
"type": "validation_error",
|
||||
"details": []
|
||||
}
|
||||
}
|
||||
assert error["error"]["type"] == "validation_error"
|
||||
assert isinstance(error["error"]["details"], list)
|
||||
|
||||
def test_internal_error_masks_details(self):
|
||||
"""In production, internal errors should not leak stack traces."""
|
||||
error = {
|
||||
"error": {
|
||||
"code": 500,
|
||||
"message": "Internal server error",
|
||||
"type": "internal_error"
|
||||
}
|
||||
}
|
||||
assert "traceback" not in str(error)
|
||||
assert error["error"]["message"] == "Internal server error"
|
||||
@@ -0,0 +1,206 @@
|
||||
"""Tests for ESP32BinaryParser (ADR-018 binary frame format)."""
|
||||
|
||||
import asyncio
|
||||
import math
|
||||
import socket
|
||||
import struct
|
||||
import threading
|
||||
import time
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
import sys
|
||||
import os
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..', 'src'))
|
||||
|
||||
from hardware.csi_extractor import (
|
||||
ESP32BinaryParser,
|
||||
CSIExtractor,
|
||||
CSIParseError,
|
||||
CSIExtractionError,
|
||||
)
|
||||
|
||||
# ADR-018 constants
|
||||
MAGIC = 0xC5110001
|
||||
HEADER_FMT = '<IBBHIIBB2x'
|
||||
HEADER_SIZE = 20
|
||||
|
||||
|
||||
def build_binary_frame(
|
||||
node_id: int = 1,
|
||||
n_antennas: int = 1,
|
||||
n_subcarriers: int = 4,
|
||||
freq_mhz: int = 2437,
|
||||
sequence: int = 0,
|
||||
rssi: int = -50,
|
||||
noise_floor: int = -90,
|
||||
iq_pairs: list = None,
|
||||
) -> bytes:
|
||||
"""Build an ADR-018 binary frame for testing."""
|
||||
if iq_pairs is None:
|
||||
iq_pairs = [(i % 50, (i * 2) % 50) for i in range(n_antennas * n_subcarriers)]
|
||||
|
||||
rssi_u8 = rssi & 0xFF
|
||||
noise_u8 = noise_floor & 0xFF
|
||||
|
||||
header = struct.pack(
|
||||
HEADER_FMT,
|
||||
MAGIC,
|
||||
node_id,
|
||||
n_antennas,
|
||||
n_subcarriers,
|
||||
freq_mhz,
|
||||
sequence,
|
||||
rssi_u8,
|
||||
noise_u8,
|
||||
)
|
||||
|
||||
iq_data = b''
|
||||
for i_val, q_val in iq_pairs:
|
||||
iq_data += struct.pack('<bb', i_val, q_val)
|
||||
|
||||
return header + iq_data
|
||||
|
||||
|
||||
class TestESP32BinaryParser:
|
||||
"""Tests for ESP32BinaryParser."""
|
||||
|
||||
def setup_method(self):
|
||||
self.parser = ESP32BinaryParser()
|
||||
|
||||
def test_parse_valid_binary_frame(self):
|
||||
"""Parse a well-formed ADR-018 binary frame."""
|
||||
iq = [(3, 4), (0, 10), (5, 12), (7, 0)]
|
||||
frame_bytes = build_binary_frame(
|
||||
node_id=1, n_antennas=1, n_subcarriers=4,
|
||||
freq_mhz=2437, sequence=42, rssi=-50, noise_floor=-90,
|
||||
iq_pairs=iq,
|
||||
)
|
||||
|
||||
result = self.parser.parse(frame_bytes)
|
||||
|
||||
assert result.num_antennas == 1
|
||||
assert result.num_subcarriers == 4
|
||||
assert result.amplitude.shape == (1, 4)
|
||||
assert result.phase.shape == (1, 4)
|
||||
assert result.metadata['node_id'] == 1
|
||||
assert result.metadata['sequence'] == 42
|
||||
assert result.metadata['rssi_dbm'] == -50
|
||||
assert result.metadata['noise_floor_dbm'] == -90
|
||||
assert result.metadata['channel_freq_mhz'] == 2437
|
||||
|
||||
# Check amplitude for I=3, Q=4 -> sqrt(9+16) = 5.0
|
||||
assert abs(result.amplitude[0, 0] - 5.0) < 0.001
|
||||
# I=0, Q=10 -> 10.0
|
||||
assert abs(result.amplitude[0, 1] - 10.0) < 0.001
|
||||
|
||||
def test_parse_frame_too_short(self):
|
||||
"""Reject frames shorter than the 20-byte header."""
|
||||
with pytest.raises(CSIParseError, match="too short"):
|
||||
self.parser.parse(b'\x00' * 10)
|
||||
|
||||
def test_parse_invalid_magic(self):
|
||||
"""Reject frames with wrong magic number."""
|
||||
bad_frame = build_binary_frame()
|
||||
# Corrupt magic
|
||||
bad_frame = b'\xFF\xFF\xFF\xFF' + bad_frame[4:]
|
||||
with pytest.raises(CSIParseError, match="Invalid magic"):
|
||||
self.parser.parse(bad_frame)
|
||||
|
||||
def test_parse_multi_antenna_frame(self):
|
||||
"""Parse a frame with 3 antennas and 4 subcarriers."""
|
||||
n_ant = 3
|
||||
n_sc = 4
|
||||
iq = [(i + 1, i + 2) for i in range(n_ant * n_sc)]
|
||||
|
||||
frame_bytes = build_binary_frame(
|
||||
node_id=5, n_antennas=n_ant, n_subcarriers=n_sc,
|
||||
iq_pairs=iq,
|
||||
)
|
||||
|
||||
result = self.parser.parse(frame_bytes)
|
||||
|
||||
assert result.num_antennas == 3
|
||||
assert result.num_subcarriers == 4
|
||||
assert result.amplitude.shape == (3, 4)
|
||||
assert result.phase.shape == (3, 4)
|
||||
|
||||
def test_udp_read_with_mock_server(self):
|
||||
"""Send a frame via UDP and verify CSIExtractor receives it."""
|
||||
# Find a free port
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
sock.bind(('127.0.0.1', 0))
|
||||
port = sock.getsockname()[1]
|
||||
sock.close()
|
||||
|
||||
frame_bytes = build_binary_frame(
|
||||
node_id=3, n_antennas=1, n_subcarriers=4,
|
||||
freq_mhz=2412, sequence=99,
|
||||
)
|
||||
|
||||
config = {
|
||||
'hardware_type': 'esp32',
|
||||
'parser_format': 'binary',
|
||||
'sampling_rate': 100,
|
||||
'buffer_size': 2048,
|
||||
'timeout': 2,
|
||||
'aggregator_host': '127.0.0.1',
|
||||
'aggregator_port': port,
|
||||
}
|
||||
|
||||
extractor = CSIExtractor(config)
|
||||
|
||||
async def run_test():
|
||||
# Connect
|
||||
await extractor.connect()
|
||||
|
||||
# Send frame after a short delay from a background thread
|
||||
def send():
|
||||
time.sleep(0.2)
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
s.sendto(frame_bytes, ('127.0.0.1', port))
|
||||
s.close()
|
||||
|
||||
sender = threading.Thread(target=send, daemon=True)
|
||||
sender.start()
|
||||
|
||||
result = await extractor.extract_csi()
|
||||
sender.join(timeout=2)
|
||||
|
||||
assert result.metadata['node_id'] == 3
|
||||
assert result.metadata['sequence'] == 99
|
||||
assert result.num_subcarriers == 4
|
||||
|
||||
await extractor.disconnect()
|
||||
|
||||
asyncio.run(run_test())
|
||||
|
||||
def test_udp_timeout(self):
|
||||
"""Verify timeout when no UDP server is sending data."""
|
||||
# Find a free port (nothing will send to it)
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
sock.bind(('127.0.0.1', 0))
|
||||
port = sock.getsockname()[1]
|
||||
sock.close()
|
||||
|
||||
config = {
|
||||
'hardware_type': 'esp32',
|
||||
'parser_format': 'binary',
|
||||
'sampling_rate': 100,
|
||||
'buffer_size': 2048,
|
||||
'timeout': 0.5,
|
||||
'retry_attempts': 1,
|
||||
'aggregator_host': '127.0.0.1',
|
||||
'aggregator_port': port,
|
||||
}
|
||||
|
||||
extractor = CSIExtractor(config)
|
||||
|
||||
async def run_test():
|
||||
await extractor.connect()
|
||||
with pytest.raises(CSIExtractionError, match="timed out"):
|
||||
await extractor.extract_csi()
|
||||
await extractor.disconnect()
|
||||
|
||||
asyncio.run(run_test())
|
||||
@@ -0,0 +1,65 @@
|
||||
"""Tests for HardwareService."""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, AsyncMock, patch
|
||||
|
||||
|
||||
class TestHardwareServiceInit:
|
||||
def test_init(self, mock_settings, mock_domain_config):
|
||||
mock_settings.mock_hardware = True
|
||||
with patch("src.services.hardware_service.RouterInterface"):
|
||||
from src.services.hardware_service import HardwareService
|
||||
svc = HardwareService(mock_settings, mock_domain_config)
|
||||
assert svc.is_running is False
|
||||
assert svc.stats["total_samples"] == 0
|
||||
assert svc.stats["connected_routers"] == 0
|
||||
|
||||
def test_stats_defaults(self, mock_settings, mock_domain_config):
|
||||
mock_settings.mock_hardware = True
|
||||
with patch("src.services.hardware_service.RouterInterface"):
|
||||
from src.services.hardware_service import HardwareService
|
||||
svc = HardwareService(mock_settings, mock_domain_config)
|
||||
assert svc.stats["successful_samples"] == 0
|
||||
assert svc.stats["failed_samples"] == 0
|
||||
assert svc.stats["last_sample_time"] is None
|
||||
|
||||
|
||||
class TestHardwareServiceLifecycle:
|
||||
@pytest.mark.asyncio
|
||||
async def test_start(self, mock_settings, mock_domain_config):
|
||||
mock_settings.mock_hardware = True
|
||||
with patch("src.services.hardware_service.RouterInterface"):
|
||||
from src.services.hardware_service import HardwareService
|
||||
svc = HardwareService(mock_settings, mock_domain_config)
|
||||
svc._initialize_routers = AsyncMock()
|
||||
svc._monitoring_loop = AsyncMock()
|
||||
await svc.start()
|
||||
assert svc.is_running is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_double_start_idempotent(self, mock_settings, mock_domain_config):
|
||||
mock_settings.mock_hardware = True
|
||||
with patch("src.services.hardware_service.RouterInterface"):
|
||||
from src.services.hardware_service import HardwareService
|
||||
svc = HardwareService(mock_settings, mock_domain_config)
|
||||
svc._initialize_routers = AsyncMock()
|
||||
svc._monitoring_loop = AsyncMock()
|
||||
await svc.start()
|
||||
await svc.start() # idempotent
|
||||
assert svc.is_running is True
|
||||
|
||||
|
||||
class TestHardwareServiceRouter:
|
||||
def test_no_routers_on_init(self, mock_settings, mock_domain_config):
|
||||
mock_settings.mock_hardware = True
|
||||
with patch("src.services.hardware_service.RouterInterface"):
|
||||
from src.services.hardware_service import HardwareService
|
||||
svc = HardwareService(mock_settings, mock_domain_config)
|
||||
assert len(svc.router_interfaces) == 0
|
||||
|
||||
def test_max_recent_samples(self, mock_settings, mock_domain_config):
|
||||
mock_settings.mock_hardware = True
|
||||
with patch("src.services.hardware_service.RouterInterface"):
|
||||
from src.services.hardware_service import HardwareService
|
||||
svc = HardwareService(mock_settings, mock_domain_config)
|
||||
assert svc.max_recent_samples == 1000
|
||||
@@ -0,0 +1,67 @@
|
||||
"""Tests for HealthCheckService."""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
|
||||
class TestHealthCheckServiceInit:
|
||||
def test_init(self, mock_settings):
|
||||
from src.services.health_check import HealthCheckService
|
||||
svc = HealthCheckService(mock_settings)
|
||||
assert svc._initialized is False
|
||||
assert svc._running is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_initialize(self, mock_settings):
|
||||
from src.services.health_check import HealthCheckService
|
||||
svc = HealthCheckService(mock_settings)
|
||||
await svc.initialize()
|
||||
assert svc._initialized is True
|
||||
assert "api" in svc._services
|
||||
assert "database" in svc._services
|
||||
assert "hardware" in svc._services
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_double_initialize(self, mock_settings):
|
||||
from src.services.health_check import HealthCheckService
|
||||
svc = HealthCheckService(mock_settings)
|
||||
await svc.initialize()
|
||||
await svc.initialize() # idempotent
|
||||
assert svc._initialized is True
|
||||
|
||||
|
||||
class TestHealthCheckAggregation:
|
||||
@pytest.mark.asyncio
|
||||
async def test_services_registered(self, mock_settings):
|
||||
from src.services.health_check import HealthCheckService, HealthStatus
|
||||
svc = HealthCheckService(mock_settings)
|
||||
await svc.initialize()
|
||||
assert len(svc._services) == 6
|
||||
for name, sh in svc._services.items():
|
||||
assert sh.status == HealthStatus.UNKNOWN
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_service_names(self, mock_settings):
|
||||
from src.services.health_check import HealthCheckService
|
||||
svc = HealthCheckService(mock_settings)
|
||||
await svc.initialize()
|
||||
expected = {"api", "database", "redis", "hardware", "pose", "stream"}
|
||||
assert set(svc._services.keys()) == expected
|
||||
|
||||
|
||||
class TestHealthStatus:
|
||||
def test_enum_values(self):
|
||||
from src.services.health_check import HealthStatus
|
||||
assert HealthStatus.HEALTHY.value == "healthy"
|
||||
assert HealthStatus.DEGRADED.value == "degraded"
|
||||
assert HealthStatus.UNHEALTHY.value == "unhealthy"
|
||||
assert HealthStatus.UNKNOWN.value == "unknown"
|
||||
|
||||
|
||||
class TestHealthCheck:
|
||||
def test_health_check_dataclass(self):
|
||||
from src.services.health_check import HealthCheck, HealthStatus
|
||||
hc = HealthCheck(name="test", status=HealthStatus.HEALTHY, message="ok")
|
||||
assert hc.name == "test"
|
||||
assert hc.status == HealthStatus.HEALTHY
|
||||
assert hc.duration_ms == 0.0
|
||||
@@ -0,0 +1,70 @@
|
||||
"""Tests for MetricsService."""
|
||||
|
||||
import pytest
|
||||
from datetime import timedelta
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
|
||||
class TestMetricSeries:
|
||||
def test_add_point(self):
|
||||
from src.services.metrics import MetricSeries
|
||||
ms = MetricSeries(name="test", description="desc", unit="ms")
|
||||
ms.add_point(42.0)
|
||||
assert len(ms.points) == 1
|
||||
assert ms.points[0].value == 42.0
|
||||
|
||||
def test_get_latest(self):
|
||||
from src.services.metrics import MetricSeries
|
||||
ms = MetricSeries(name="test", description="desc", unit="ms")
|
||||
ms.add_point(1.0)
|
||||
ms.add_point(2.0)
|
||||
latest = ms.get_latest()
|
||||
assert latest is not None
|
||||
assert latest.value == 2.0
|
||||
|
||||
def test_get_latest_empty(self):
|
||||
from src.services.metrics import MetricSeries
|
||||
ms = MetricSeries(name="test", description="desc", unit="ms")
|
||||
assert ms.get_latest() is None
|
||||
|
||||
def test_get_average(self):
|
||||
from src.services.metrics import MetricSeries
|
||||
ms = MetricSeries(name="test", description="desc", unit="ms")
|
||||
for v in [10.0, 20.0, 30.0]:
|
||||
ms.add_point(v)
|
||||
avg = ms.get_average(timedelta(minutes=5))
|
||||
assert avg == pytest.approx(20.0)
|
||||
|
||||
def test_get_average_empty(self):
|
||||
from src.services.metrics import MetricSeries
|
||||
ms = MetricSeries(name="test", description="desc", unit="ms")
|
||||
assert ms.get_average(timedelta(minutes=5)) is None
|
||||
|
||||
def test_get_max(self):
|
||||
from src.services.metrics import MetricSeries
|
||||
ms = MetricSeries(name="test", description="desc", unit="ms")
|
||||
for v in [10.0, 50.0, 30.0]:
|
||||
ms.add_point(v)
|
||||
mx = ms.get_max(timedelta(minutes=5))
|
||||
assert mx == 50.0
|
||||
|
||||
def test_labels(self):
|
||||
from src.services.metrics import MetricSeries
|
||||
ms = MetricSeries(name="test", description="desc", unit="ms")
|
||||
ms.add_point(1.0, {"region": "us-east"})
|
||||
assert ms.points[0].labels["region"] == "us-east"
|
||||
|
||||
def test_maxlen(self):
|
||||
from src.services.metrics import MetricSeries
|
||||
ms = MetricSeries(name="test", description="desc", unit="ms")
|
||||
for i in range(1100):
|
||||
ms.add_point(float(i))
|
||||
assert len(ms.points) == 1000
|
||||
|
||||
|
||||
class TestMetricsService:
|
||||
def test_init(self, mock_settings):
|
||||
with patch("src.services.metrics.psutil"):
|
||||
from src.services.metrics import MetricsService
|
||||
svc = MetricsService(mock_settings)
|
||||
assert svc._metrics is not None
|
||||
@@ -0,0 +1,293 @@
|
||||
import pytest
|
||||
import torch
|
||||
import torch.nn as nn
|
||||
import numpy as np
|
||||
from unittest.mock import Mock, patch
|
||||
from src.models.modality_translation import ModalityTranslationNetwork, ModalityTranslationError
|
||||
|
||||
|
||||
class TestModalityTranslationNetwork:
|
||||
"""Test suite for Modality Translation Network following London School TDD principles"""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_config(self):
|
||||
"""Configuration for modality translation network"""
|
||||
return {
|
||||
'input_channels': 6, # Real and imaginary parts for 3 antennas
|
||||
'hidden_channels': [64, 128, 256],
|
||||
'output_channels': 256,
|
||||
'kernel_size': 3,
|
||||
'stride': 1,
|
||||
'padding': 1,
|
||||
'dropout_rate': 0.1,
|
||||
'activation': 'relu',
|
||||
'normalization': 'batch',
|
||||
'use_attention': True,
|
||||
'attention_heads': 8
|
||||
}
|
||||
|
||||
@pytest.fixture
|
||||
def translation_network(self, mock_config):
|
||||
"""Create modality translation network instance for testing"""
|
||||
return ModalityTranslationNetwork(mock_config)
|
||||
|
||||
@pytest.fixture
|
||||
def mock_csi_input(self):
|
||||
"""Generate mock CSI input tensor"""
|
||||
batch_size = 4
|
||||
channels = 6 # Real and imaginary parts for 3 antennas
|
||||
height = 56 # Number of subcarriers
|
||||
width = 100 # Time samples
|
||||
return torch.randn(batch_size, channels, height, width)
|
||||
|
||||
@pytest.fixture
|
||||
def mock_target_features(self):
|
||||
"""Generate mock target feature tensor for training"""
|
||||
batch_size = 4
|
||||
feature_dim = 256
|
||||
spatial_height = 56
|
||||
spatial_width = 100
|
||||
return torch.randn(batch_size, feature_dim, spatial_height, spatial_width)
|
||||
|
||||
def test_network_initialization_creates_correct_architecture(self, mock_config):
|
||||
"""Test that modality translation network initializes with correct architecture"""
|
||||
# Act
|
||||
network = ModalityTranslationNetwork(mock_config)
|
||||
|
||||
# Assert
|
||||
assert network is not None
|
||||
assert isinstance(network, nn.Module)
|
||||
assert network.input_channels == mock_config['input_channels']
|
||||
assert network.output_channels == mock_config['output_channels']
|
||||
assert network.use_attention == mock_config['use_attention']
|
||||
assert hasattr(network, 'encoder')
|
||||
assert hasattr(network, 'decoder')
|
||||
if mock_config['use_attention']:
|
||||
assert hasattr(network, 'attention')
|
||||
|
||||
def test_forward_pass_produces_correct_output_shape(self, translation_network, mock_csi_input):
|
||||
"""Test that forward pass produces correctly shaped output"""
|
||||
# Act
|
||||
output = translation_network(mock_csi_input)
|
||||
|
||||
# Assert
|
||||
assert output is not None
|
||||
assert isinstance(output, torch.Tensor)
|
||||
assert output.shape[0] == mock_csi_input.shape[0] # Batch size preserved
|
||||
assert output.shape[1] == translation_network.output_channels # Correct output channels
|
||||
assert output.shape[2] == mock_csi_input.shape[2] # Spatial height preserved
|
||||
assert output.shape[3] == mock_csi_input.shape[3] # Spatial width preserved
|
||||
|
||||
def test_forward_pass_handles_different_input_sizes(self, translation_network):
|
||||
"""Test that forward pass handles different input sizes"""
|
||||
# Arrange
|
||||
small_input = torch.randn(2, 6, 28, 50)
|
||||
large_input = torch.randn(8, 6, 112, 200)
|
||||
|
||||
# Act
|
||||
small_output = translation_network(small_input)
|
||||
large_output = translation_network(large_input)
|
||||
|
||||
# Assert
|
||||
assert small_output.shape == (2, 256, 28, 50)
|
||||
assert large_output.shape == (8, 256, 112, 200)
|
||||
|
||||
def test_encoder_extracts_hierarchical_features(self, translation_network, mock_csi_input):
|
||||
"""Test that encoder extracts hierarchical features"""
|
||||
# Act
|
||||
features = translation_network.encode(mock_csi_input)
|
||||
|
||||
# Assert
|
||||
assert features is not None
|
||||
assert isinstance(features, list)
|
||||
assert len(features) == len(translation_network.encoder)
|
||||
|
||||
# Check feature map sizes decrease with depth
|
||||
for i in range(1, len(features)):
|
||||
assert features[i].shape[2] <= features[i-1].shape[2] # Height decreases or stays same
|
||||
assert features[i].shape[3] <= features[i-1].shape[3] # Width decreases or stays same
|
||||
|
||||
def test_decoder_reconstructs_target_features(self, translation_network, mock_csi_input):
|
||||
"""Test that decoder reconstructs target feature representation"""
|
||||
# Arrange
|
||||
encoded_features = translation_network.encode(mock_csi_input)
|
||||
|
||||
# Act
|
||||
decoded_output = translation_network.decode(encoded_features)
|
||||
|
||||
# Assert
|
||||
assert decoded_output is not None
|
||||
assert isinstance(decoded_output, torch.Tensor)
|
||||
assert decoded_output.shape[1] == translation_network.output_channels
|
||||
assert decoded_output.shape[2:] == mock_csi_input.shape[2:]
|
||||
|
||||
def test_attention_mechanism_enhances_features(self, mock_config, mock_csi_input):
|
||||
"""Test that attention mechanism enhances feature representation"""
|
||||
# Arrange
|
||||
config_with_attention = mock_config.copy()
|
||||
config_with_attention['use_attention'] = True
|
||||
|
||||
config_without_attention = mock_config.copy()
|
||||
config_without_attention['use_attention'] = False
|
||||
|
||||
network_with_attention = ModalityTranslationNetwork(config_with_attention)
|
||||
network_without_attention = ModalityTranslationNetwork(config_without_attention)
|
||||
|
||||
# Act
|
||||
output_with_attention = network_with_attention(mock_csi_input)
|
||||
output_without_attention = network_without_attention(mock_csi_input)
|
||||
|
||||
# Assert
|
||||
assert output_with_attention.shape == output_without_attention.shape
|
||||
# Outputs should be different due to attention mechanism
|
||||
assert not torch.allclose(output_with_attention, output_without_attention, atol=1e-6)
|
||||
|
||||
def test_training_mode_enables_dropout(self, translation_network, mock_csi_input):
|
||||
"""Test that training mode enables dropout for regularization"""
|
||||
# Arrange
|
||||
translation_network.train()
|
||||
|
||||
# Act
|
||||
output1 = translation_network(mock_csi_input)
|
||||
output2 = translation_network(mock_csi_input)
|
||||
|
||||
# Assert - outputs should be different due to dropout
|
||||
assert not torch.allclose(output1, output2, atol=1e-6)
|
||||
|
||||
def test_evaluation_mode_disables_dropout(self, translation_network, mock_csi_input):
|
||||
"""Test that evaluation mode disables dropout for consistent inference"""
|
||||
# Arrange
|
||||
translation_network.eval()
|
||||
|
||||
# Act
|
||||
output1 = translation_network(mock_csi_input)
|
||||
output2 = translation_network(mock_csi_input)
|
||||
|
||||
# Assert - outputs should be identical in eval mode
|
||||
assert torch.allclose(output1, output2, atol=1e-6)
|
||||
|
||||
def test_compute_translation_loss_measures_feature_alignment(self, translation_network, mock_csi_input, mock_target_features):
|
||||
"""Test that compute_translation_loss measures feature alignment"""
|
||||
# Arrange
|
||||
predicted_features = translation_network(mock_csi_input)
|
||||
|
||||
# Act
|
||||
loss = translation_network.compute_translation_loss(predicted_features, mock_target_features)
|
||||
|
||||
# Assert
|
||||
assert loss is not None
|
||||
assert isinstance(loss, torch.Tensor)
|
||||
assert loss.dim() == 0 # Scalar loss
|
||||
assert loss.item() >= 0 # Loss should be non-negative
|
||||
|
||||
def test_compute_translation_loss_handles_different_loss_types(self, translation_network, mock_csi_input, mock_target_features):
|
||||
"""Test that compute_translation_loss handles different loss types"""
|
||||
# Arrange
|
||||
predicted_features = translation_network(mock_csi_input)
|
||||
|
||||
# Act
|
||||
mse_loss = translation_network.compute_translation_loss(predicted_features, mock_target_features, loss_type='mse')
|
||||
l1_loss = translation_network.compute_translation_loss(predicted_features, mock_target_features, loss_type='l1')
|
||||
|
||||
# Assert
|
||||
assert mse_loss is not None
|
||||
assert l1_loss is not None
|
||||
assert mse_loss.item() != l1_loss.item() # Different loss types should give different values
|
||||
|
||||
def test_get_feature_statistics_provides_analysis(self, translation_network, mock_csi_input):
|
||||
"""Test that get_feature_statistics provides feature analysis"""
|
||||
# Arrange
|
||||
output = translation_network(mock_csi_input)
|
||||
|
||||
# Act
|
||||
stats = translation_network.get_feature_statistics(output)
|
||||
|
||||
# Assert
|
||||
assert stats is not None
|
||||
assert isinstance(stats, dict)
|
||||
assert 'mean' in stats
|
||||
assert 'std' in stats
|
||||
assert 'min' in stats
|
||||
assert 'max' in stats
|
||||
assert 'sparsity' in stats
|
||||
|
||||
def test_network_supports_gradient_computation(self, translation_network, mock_csi_input, mock_target_features):
|
||||
"""Test that network supports gradient computation for training"""
|
||||
# Arrange
|
||||
translation_network.train()
|
||||
optimizer = torch.optim.Adam(translation_network.parameters(), lr=0.001)
|
||||
|
||||
# Act
|
||||
output = translation_network(mock_csi_input)
|
||||
loss = translation_network.compute_translation_loss(output, mock_target_features)
|
||||
|
||||
optimizer.zero_grad()
|
||||
loss.backward()
|
||||
|
||||
# Assert
|
||||
for param in translation_network.parameters():
|
||||
if param.requires_grad:
|
||||
assert param.grad is not None
|
||||
assert not torch.allclose(param.grad, torch.zeros_like(param.grad))
|
||||
|
||||
def test_network_validates_input_dimensions(self, translation_network):
|
||||
"""Test that network validates input dimensions"""
|
||||
# Arrange
|
||||
invalid_input = torch.randn(4, 3, 56, 100) # Wrong number of channels
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(ModalityTranslationError):
|
||||
translation_network(invalid_input)
|
||||
|
||||
def test_network_handles_batch_size_one(self, translation_network):
|
||||
"""Test that network handles single sample inference"""
|
||||
# Arrange
|
||||
single_input = torch.randn(1, 6, 56, 100)
|
||||
|
||||
# Act
|
||||
output = translation_network(single_input)
|
||||
|
||||
# Assert
|
||||
assert output.shape == (1, 256, 56, 100)
|
||||
|
||||
def test_save_and_load_model_state(self, translation_network, mock_csi_input):
|
||||
"""Test that model state can be saved and loaded"""
|
||||
# Arrange
|
||||
original_output = translation_network(mock_csi_input)
|
||||
|
||||
# Act - Save state
|
||||
state_dict = translation_network.state_dict()
|
||||
|
||||
# Create new network and load state
|
||||
new_network = ModalityTranslationNetwork(translation_network.config)
|
||||
new_network.load_state_dict(state_dict)
|
||||
new_output = new_network(mock_csi_input)
|
||||
|
||||
# Assert
|
||||
assert torch.allclose(original_output, new_output, atol=1e-6)
|
||||
|
||||
def test_network_configuration_validation(self):
|
||||
"""Test that network validates configuration parameters"""
|
||||
# Arrange
|
||||
invalid_config = {
|
||||
'input_channels': 0, # Invalid
|
||||
'hidden_channels': [], # Invalid
|
||||
'output_channels': 256
|
||||
}
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(ValueError):
|
||||
ModalityTranslationNetwork(invalid_config)
|
||||
|
||||
def test_feature_visualization_support(self, translation_network, mock_csi_input):
|
||||
"""Test that network supports feature visualization"""
|
||||
# Act
|
||||
features = translation_network.get_intermediate_features(mock_csi_input)
|
||||
|
||||
# Assert
|
||||
assert features is not None
|
||||
assert isinstance(features, dict)
|
||||
assert 'encoder_features' in features
|
||||
assert 'decoder_features' in features
|
||||
if translation_network.use_attention:
|
||||
assert 'attention_weights' in features
|
||||
@@ -0,0 +1,95 @@
|
||||
import pytest
|
||||
import numpy as np
|
||||
import time
|
||||
from unittest.mock import Mock, patch
|
||||
from src.core.phase_sanitizer import PhaseSanitizer, PhaseSanitizationError
|
||||
|
||||
|
||||
_SANITIZER_CONFIG = {
|
||||
"unwrapping_method": "numpy",
|
||||
"outlier_threshold": 3.0,
|
||||
"smoothing_window": 5,
|
||||
"enable_outlier_removal": True,
|
||||
"enable_smoothing": True,
|
||||
"enable_noise_filtering": True,
|
||||
"noise_threshold": 0.1,
|
||||
}
|
||||
|
||||
|
||||
class TestPhaseSanitizer:
|
||||
"""Test suite for Phase Sanitizer following London School TDD principles"""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_phase_data(self):
|
||||
"""Generate synthetic phase data strictly within valid [-π, π] range"""
|
||||
return np.array([
|
||||
[0.1, 0.2, 0.4, 0.3, 0.5],
|
||||
[-1.0, -0.1, 0.0, 0.1, 0.2],
|
||||
[0.0, 0.1, 0.2, 0.3, 0.4],
|
||||
])
|
||||
|
||||
@pytest.fixture
|
||||
def phase_sanitizer(self):
|
||||
"""Create Phase Sanitizer instance for testing"""
|
||||
return PhaseSanitizer(config=_SANITIZER_CONFIG)
|
||||
|
||||
def test_unwrap_phase_removes_discontinuities(self, phase_sanitizer):
|
||||
"""Test that phase unwrapping removes 2π discontinuities"""
|
||||
# Create data with explicit 2π jump
|
||||
jumpy = np.array([[0.1, 0.2, 0.2 + 2 * np.pi, 0.4, 0.5]])
|
||||
result = phase_sanitizer.unwrap_phase(jumpy)
|
||||
|
||||
assert result is not None
|
||||
assert isinstance(result, np.ndarray)
|
||||
assert result.shape == jumpy.shape
|
||||
phase_diffs = np.abs(np.diff(result[0]))
|
||||
assert np.all(phase_diffs < np.pi) # No jumps larger than π
|
||||
|
||||
def test_remove_outliers_returns_same_shape(self, phase_sanitizer, mock_phase_data):
|
||||
"""Test that outlier removal preserves array shape"""
|
||||
result = phase_sanitizer.remove_outliers(mock_phase_data)
|
||||
|
||||
assert result is not None
|
||||
assert isinstance(result, np.ndarray)
|
||||
assert result.shape == mock_phase_data.shape
|
||||
|
||||
def test_smooth_phase_reduces_noise(self, phase_sanitizer, mock_phase_data):
|
||||
"""Test that phase smoothing reduces noise while preserving trends"""
|
||||
rng = np.random.default_rng(42)
|
||||
noisy_data = mock_phase_data + rng.normal(0, 0.05, mock_phase_data.shape)
|
||||
# Clip to valid range after adding noise
|
||||
noisy_data = np.clip(noisy_data, -np.pi, np.pi)
|
||||
|
||||
result = phase_sanitizer.smooth_phase(noisy_data)
|
||||
|
||||
assert result is not None
|
||||
assert isinstance(result, np.ndarray)
|
||||
assert result.shape == noisy_data.shape
|
||||
assert np.var(result) <= np.var(noisy_data)
|
||||
|
||||
def test_sanitize_raises_for_1d_input(self, phase_sanitizer):
|
||||
"""Sanitizer should raise PhaseSanitizationError on 1D input"""
|
||||
with pytest.raises(PhaseSanitizationError, match="Phase data must be 2D array"):
|
||||
phase_sanitizer.sanitize_phase(np.array([0.1, 0.2, 0.3]))
|
||||
|
||||
def test_sanitize_raises_for_empty_2d_input(self, phase_sanitizer):
|
||||
"""Sanitizer should raise PhaseSanitizationError on empty 2D input"""
|
||||
with pytest.raises(PhaseSanitizationError, match="Phase data cannot be empty"):
|
||||
phase_sanitizer.sanitize_phase(np.empty((0, 5)))
|
||||
|
||||
def test_sanitize_full_pipeline_integration(self, phase_sanitizer, mock_phase_data):
|
||||
"""Test that full sanitization pipeline works correctly"""
|
||||
result = phase_sanitizer.sanitize_phase(mock_phase_data)
|
||||
|
||||
assert result is not None
|
||||
assert isinstance(result, np.ndarray)
|
||||
assert result.shape == mock_phase_data.shape
|
||||
assert np.all(np.isfinite(result))
|
||||
|
||||
def test_sanitize_performance_requirement(self, phase_sanitizer, mock_phase_data):
|
||||
"""Test that phase sanitization meets performance requirements (<5ms)"""
|
||||
start_time = time.perf_counter()
|
||||
phase_sanitizer.sanitize_phase(mock_phase_data)
|
||||
processing_time = time.perf_counter() - start_time
|
||||
|
||||
assert processing_time < 0.005 # < 5 ms
|
||||
@@ -0,0 +1,413 @@
|
||||
"""TDD tests for phase sanitizer following London School approach."""
|
||||
|
||||
import pytest
|
||||
import numpy as np
|
||||
import sys
|
||||
import os
|
||||
from unittest.mock import Mock, patch, AsyncMock
|
||||
from datetime import datetime, timezone
|
||||
import importlib.util
|
||||
|
||||
# Resolve paths relative to v1/ (this file lives at v1/tests/unit/)
|
||||
_TESTS_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
_V1_DIR = os.path.abspath(os.path.join(_TESTS_DIR, '..', '..'))
|
||||
if _V1_DIR not in sys.path:
|
||||
sys.path.insert(0, _V1_DIR)
|
||||
|
||||
# Import the phase sanitizer module directly
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
'phase_sanitizer',
|
||||
os.path.join(_V1_DIR, 'src', 'core', 'phase_sanitizer.py')
|
||||
)
|
||||
phase_sanitizer_module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(phase_sanitizer_module)
|
||||
|
||||
# Get classes from the module
|
||||
PhaseSanitizer = phase_sanitizer_module.PhaseSanitizer
|
||||
PhaseSanitizationError = phase_sanitizer_module.PhaseSanitizationError
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.tdd
|
||||
@pytest.mark.london
|
||||
class TestPhaseSanitizer:
|
||||
"""Test phase sanitizer using London School TDD."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_logger(self):
|
||||
"""Mock logger for testing."""
|
||||
return Mock()
|
||||
|
||||
@pytest.fixture
|
||||
def sanitizer_config(self):
|
||||
"""Phase sanitizer configuration for testing."""
|
||||
return {
|
||||
'unwrapping_method': 'numpy',
|
||||
'outlier_threshold': 3.0,
|
||||
'smoothing_window': 5,
|
||||
'enable_outlier_removal': True,
|
||||
'enable_smoothing': True,
|
||||
'enable_noise_filtering': True,
|
||||
'noise_threshold': 0.1,
|
||||
'phase_range': (-np.pi, np.pi)
|
||||
}
|
||||
|
||||
@pytest.fixture
|
||||
def phase_sanitizer(self, sanitizer_config, mock_logger):
|
||||
"""Create phase sanitizer for testing."""
|
||||
return PhaseSanitizer(config=sanitizer_config, logger=mock_logger)
|
||||
|
||||
@pytest.fixture
|
||||
def sample_wrapped_phase(self):
|
||||
"""Sample wrapped phase data with discontinuities."""
|
||||
# Create phase data with wrapping
|
||||
phase = np.linspace(0, 4*np.pi, 100)
|
||||
wrapped_phase = np.angle(np.exp(1j * phase)) # Wrap to [-π, π]
|
||||
return wrapped_phase.reshape(1, -1) # Shape: (1, 100)
|
||||
|
||||
@pytest.fixture
|
||||
def sample_noisy_phase(self):
|
||||
"""Sample phase data with noise and outliers."""
|
||||
clean_phase = np.linspace(-np.pi, np.pi, 50)
|
||||
noise = np.random.normal(0, 0.05, 50)
|
||||
# Add some outliers
|
||||
outliers = np.random.choice(50, 5, replace=False)
|
||||
noisy_phase = clean_phase + noise
|
||||
noisy_phase[outliers] += np.random.uniform(-2, 2, 5) # Add outliers
|
||||
return noisy_phase.reshape(1, -1)
|
||||
|
||||
# Initialization tests
|
||||
def test_should_initialize_with_valid_config(self, sanitizer_config, mock_logger):
|
||||
"""Should initialize phase sanitizer with valid configuration."""
|
||||
sanitizer = PhaseSanitizer(config=sanitizer_config, logger=mock_logger)
|
||||
|
||||
assert sanitizer.config == sanitizer_config
|
||||
assert sanitizer.logger == mock_logger
|
||||
assert sanitizer.unwrapping_method == 'numpy'
|
||||
assert sanitizer.outlier_threshold == 3.0
|
||||
assert sanitizer.smoothing_window == 5
|
||||
assert sanitizer.enable_outlier_removal == True
|
||||
assert sanitizer.enable_smoothing == True
|
||||
assert sanitizer.enable_noise_filtering == True
|
||||
assert sanitizer.noise_threshold == 0.1
|
||||
assert sanitizer.phase_range == (-np.pi, np.pi)
|
||||
|
||||
def test_should_raise_error_with_invalid_config(self, mock_logger):
|
||||
"""Should raise error when initialized with invalid configuration."""
|
||||
invalid_config = {'invalid': 'config'}
|
||||
|
||||
with pytest.raises(ValueError, match="Missing required configuration"):
|
||||
PhaseSanitizer(config=invalid_config, logger=mock_logger)
|
||||
|
||||
def test_should_validate_required_fields(self, mock_logger):
|
||||
"""Should validate required configuration fields."""
|
||||
required_fields = ['unwrapping_method', 'outlier_threshold', 'smoothing_window']
|
||||
base_config = {
|
||||
'unwrapping_method': 'numpy',
|
||||
'outlier_threshold': 3.0,
|
||||
'smoothing_window': 5
|
||||
}
|
||||
|
||||
for field in required_fields:
|
||||
config = base_config.copy()
|
||||
del config[field]
|
||||
|
||||
with pytest.raises(ValueError, match="Missing required configuration"):
|
||||
PhaseSanitizer(config=config, logger=mock_logger)
|
||||
|
||||
def test_should_use_default_values(self, mock_logger):
|
||||
"""Should use default values for optional parameters."""
|
||||
minimal_config = {
|
||||
'unwrapping_method': 'numpy',
|
||||
'outlier_threshold': 3.0,
|
||||
'smoothing_window': 5
|
||||
}
|
||||
|
||||
sanitizer = PhaseSanitizer(config=minimal_config, logger=mock_logger)
|
||||
|
||||
assert sanitizer.enable_outlier_removal == True # default
|
||||
assert sanitizer.enable_smoothing == True # default
|
||||
assert sanitizer.enable_noise_filtering == False # default
|
||||
assert sanitizer.noise_threshold == 0.05 # default
|
||||
assert sanitizer.phase_range == (-np.pi, np.pi) # default
|
||||
|
||||
def test_should_initialize_without_logger(self, sanitizer_config):
|
||||
"""Should initialize without logger provided."""
|
||||
sanitizer = PhaseSanitizer(config=sanitizer_config)
|
||||
|
||||
assert sanitizer.logger is not None # Should create default logger
|
||||
|
||||
# Phase unwrapping tests
|
||||
def test_should_unwrap_phase_successfully(self, phase_sanitizer, sample_wrapped_phase):
|
||||
"""Should unwrap phase data successfully."""
|
||||
result = phase_sanitizer.unwrap_phase(sample_wrapped_phase)
|
||||
|
||||
# Check that result has same shape
|
||||
assert result.shape == sample_wrapped_phase.shape
|
||||
|
||||
# Check that unwrapping removed discontinuities
|
||||
phase_diff = np.diff(result.flatten())
|
||||
large_jumps = np.abs(phase_diff) > np.pi
|
||||
assert np.sum(large_jumps) < np.sum(np.abs(np.diff(sample_wrapped_phase.flatten())) > np.pi)
|
||||
|
||||
def test_should_handle_different_unwrapping_methods(self, sanitizer_config, mock_logger):
|
||||
"""Should handle different unwrapping methods."""
|
||||
for method in ['numpy', 'scipy', 'custom']:
|
||||
sanitizer_config['unwrapping_method'] = method
|
||||
sanitizer = PhaseSanitizer(config=sanitizer_config, logger=mock_logger)
|
||||
|
||||
phase_data = np.random.uniform(-np.pi, np.pi, (2, 50))
|
||||
|
||||
with patch.object(sanitizer, f'_unwrap_{method}', return_value=phase_data) as mock_unwrap:
|
||||
result = sanitizer.unwrap_phase(phase_data)
|
||||
|
||||
assert result.shape == phase_data.shape
|
||||
mock_unwrap.assert_called_once()
|
||||
|
||||
def test_should_handle_unwrapping_error(self, phase_sanitizer):
|
||||
"""Should handle phase unwrapping errors gracefully."""
|
||||
invalid_phase = np.array([[]]) # Empty array
|
||||
|
||||
with pytest.raises(PhaseSanitizationError, match="Failed to unwrap phase"):
|
||||
phase_sanitizer.unwrap_phase(invalid_phase)
|
||||
|
||||
# Outlier removal tests
|
||||
def test_should_remove_outliers_successfully(self, phase_sanitizer, sample_noisy_phase):
|
||||
"""Should remove outliers from phase data successfully."""
|
||||
with patch.object(phase_sanitizer, '_detect_outliers') as mock_detect:
|
||||
with patch.object(phase_sanitizer, '_interpolate_outliers') as mock_interpolate:
|
||||
outlier_mask = np.zeros(sample_noisy_phase.shape, dtype=bool)
|
||||
outlier_mask[0, [10, 20, 30]] = True # Mark some outliers
|
||||
clean_phase = sample_noisy_phase.copy()
|
||||
|
||||
mock_detect.return_value = outlier_mask
|
||||
mock_interpolate.return_value = clean_phase
|
||||
|
||||
result = phase_sanitizer.remove_outliers(sample_noisy_phase)
|
||||
|
||||
assert result.shape == sample_noisy_phase.shape
|
||||
mock_detect.assert_called_once_with(sample_noisy_phase)
|
||||
mock_interpolate.assert_called_once()
|
||||
|
||||
def test_should_skip_outlier_removal_when_disabled(self, sanitizer_config, mock_logger, sample_noisy_phase):
|
||||
"""Should skip outlier removal when disabled."""
|
||||
sanitizer_config['enable_outlier_removal'] = False
|
||||
sanitizer = PhaseSanitizer(config=sanitizer_config, logger=mock_logger)
|
||||
|
||||
result = sanitizer.remove_outliers(sample_noisy_phase)
|
||||
|
||||
assert np.array_equal(result, sample_noisy_phase)
|
||||
|
||||
def test_should_handle_outlier_removal_error(self, phase_sanitizer):
|
||||
"""Should handle outlier removal errors gracefully."""
|
||||
with patch.object(phase_sanitizer, '_detect_outliers') as mock_detect:
|
||||
mock_detect.side_effect = Exception("Detection error")
|
||||
|
||||
phase_data = np.random.uniform(-np.pi, np.pi, (2, 50))
|
||||
|
||||
with pytest.raises(PhaseSanitizationError, match="Failed to remove outliers"):
|
||||
phase_sanitizer.remove_outliers(phase_data)
|
||||
|
||||
# Smoothing tests
|
||||
def test_should_smooth_phase_successfully(self, phase_sanitizer, sample_noisy_phase):
|
||||
"""Should smooth phase data successfully."""
|
||||
with patch.object(phase_sanitizer, '_apply_moving_average') as mock_smooth:
|
||||
smoothed_phase = sample_noisy_phase * 0.9 # Simulate smoothing
|
||||
mock_smooth.return_value = smoothed_phase
|
||||
|
||||
result = phase_sanitizer.smooth_phase(sample_noisy_phase)
|
||||
|
||||
assert result.shape == sample_noisy_phase.shape
|
||||
mock_smooth.assert_called_once_with(sample_noisy_phase, phase_sanitizer.smoothing_window)
|
||||
|
||||
def test_should_skip_smoothing_when_disabled(self, sanitizer_config, mock_logger, sample_noisy_phase):
|
||||
"""Should skip smoothing when disabled."""
|
||||
sanitizer_config['enable_smoothing'] = False
|
||||
sanitizer = PhaseSanitizer(config=sanitizer_config, logger=mock_logger)
|
||||
|
||||
result = sanitizer.smooth_phase(sample_noisy_phase)
|
||||
|
||||
assert np.array_equal(result, sample_noisy_phase)
|
||||
|
||||
def test_should_handle_smoothing_error(self, phase_sanitizer):
|
||||
"""Should handle smoothing errors gracefully."""
|
||||
with patch.object(phase_sanitizer, '_apply_moving_average') as mock_smooth:
|
||||
mock_smooth.side_effect = Exception("Smoothing error")
|
||||
|
||||
phase_data = np.random.uniform(-np.pi, np.pi, (2, 50))
|
||||
|
||||
with pytest.raises(PhaseSanitizationError, match="Failed to smooth phase"):
|
||||
phase_sanitizer.smooth_phase(phase_data)
|
||||
|
||||
# Noise filtering tests
|
||||
def test_should_filter_noise_successfully(self, phase_sanitizer, sample_noisy_phase):
|
||||
"""Should filter noise from phase data successfully."""
|
||||
with patch.object(phase_sanitizer, '_apply_low_pass_filter') as mock_filter:
|
||||
filtered_phase = sample_noisy_phase * 0.95 # Simulate filtering
|
||||
mock_filter.return_value = filtered_phase
|
||||
|
||||
result = phase_sanitizer.filter_noise(sample_noisy_phase)
|
||||
|
||||
assert result.shape == sample_noisy_phase.shape
|
||||
mock_filter.assert_called_once_with(sample_noisy_phase, phase_sanitizer.noise_threshold)
|
||||
|
||||
def test_should_skip_noise_filtering_when_disabled(self, sanitizer_config, mock_logger, sample_noisy_phase):
|
||||
"""Should skip noise filtering when disabled."""
|
||||
sanitizer_config['enable_noise_filtering'] = False
|
||||
sanitizer = PhaseSanitizer(config=sanitizer_config, logger=mock_logger)
|
||||
|
||||
result = sanitizer.filter_noise(sample_noisy_phase)
|
||||
|
||||
assert np.array_equal(result, sample_noisy_phase)
|
||||
|
||||
def test_should_handle_noise_filtering_error(self, phase_sanitizer):
|
||||
"""Should handle noise filtering errors gracefully."""
|
||||
with patch.object(phase_sanitizer, '_apply_low_pass_filter') as mock_filter:
|
||||
mock_filter.side_effect = Exception("Filtering error")
|
||||
|
||||
phase_data = np.random.uniform(-np.pi, np.pi, (2, 50))
|
||||
|
||||
with pytest.raises(PhaseSanitizationError, match="Failed to filter noise"):
|
||||
phase_sanitizer.filter_noise(phase_data)
|
||||
|
||||
# Complete sanitization pipeline tests
|
||||
def test_should_sanitize_phase_pipeline_successfully(self, phase_sanitizer, sample_wrapped_phase):
|
||||
"""Should sanitize phase through complete pipeline successfully."""
|
||||
with patch.object(phase_sanitizer, 'unwrap_phase', return_value=sample_wrapped_phase) as mock_unwrap:
|
||||
with patch.object(phase_sanitizer, 'remove_outliers', return_value=sample_wrapped_phase) as mock_outliers:
|
||||
with patch.object(phase_sanitizer, 'smooth_phase', return_value=sample_wrapped_phase) as mock_smooth:
|
||||
with patch.object(phase_sanitizer, 'filter_noise', return_value=sample_wrapped_phase) as mock_filter:
|
||||
|
||||
result = phase_sanitizer.sanitize_phase(sample_wrapped_phase)
|
||||
|
||||
assert result.shape == sample_wrapped_phase.shape
|
||||
mock_unwrap.assert_called_once_with(sample_wrapped_phase)
|
||||
mock_outliers.assert_called_once()
|
||||
mock_smooth.assert_called_once()
|
||||
mock_filter.assert_called_once()
|
||||
|
||||
def test_should_handle_sanitization_pipeline_error(self, phase_sanitizer, sample_wrapped_phase):
|
||||
"""Should handle sanitization pipeline errors gracefully."""
|
||||
with patch.object(phase_sanitizer, 'unwrap_phase') as mock_unwrap:
|
||||
mock_unwrap.side_effect = PhaseSanitizationError("Unwrapping failed")
|
||||
|
||||
with pytest.raises(PhaseSanitizationError):
|
||||
phase_sanitizer.sanitize_phase(sample_wrapped_phase)
|
||||
|
||||
# Phase validation tests
|
||||
def test_should_validate_phase_data_successfully(self, phase_sanitizer):
|
||||
"""Should validate phase data successfully."""
|
||||
valid_phase = np.random.uniform(-np.pi, np.pi, (3, 56))
|
||||
|
||||
result = phase_sanitizer.validate_phase_data(valid_phase)
|
||||
|
||||
assert result == True
|
||||
|
||||
def test_should_reject_invalid_phase_shape(self, phase_sanitizer):
|
||||
"""Should reject phase data with invalid shape."""
|
||||
invalid_phase = np.array([1, 2, 3]) # 1D array
|
||||
|
||||
with pytest.raises(PhaseSanitizationError, match="Phase data must be 2D"):
|
||||
phase_sanitizer.validate_phase_data(invalid_phase)
|
||||
|
||||
def test_should_reject_empty_phase_data(self, phase_sanitizer):
|
||||
"""Should reject empty phase data."""
|
||||
empty_phase = np.array([]).reshape(0, 0)
|
||||
|
||||
with pytest.raises(PhaseSanitizationError, match="Phase data cannot be empty"):
|
||||
phase_sanitizer.validate_phase_data(empty_phase)
|
||||
|
||||
def test_should_reject_phase_out_of_range(self, phase_sanitizer):
|
||||
"""Should reject phase data outside valid range."""
|
||||
invalid_phase = np.array([[10.0, -10.0, 5.0, -5.0]]) # Outside [-π, π]
|
||||
|
||||
with pytest.raises(PhaseSanitizationError, match="Phase values outside valid range"):
|
||||
phase_sanitizer.validate_phase_data(invalid_phase)
|
||||
|
||||
# Statistics and monitoring tests
|
||||
def test_should_get_sanitization_statistics(self, phase_sanitizer):
|
||||
"""Should get sanitization statistics."""
|
||||
# Simulate some processing
|
||||
phase_sanitizer._total_processed = 50
|
||||
phase_sanitizer._outliers_removed = 5
|
||||
phase_sanitizer._sanitization_errors = 2
|
||||
|
||||
stats = phase_sanitizer.get_sanitization_statistics()
|
||||
|
||||
assert isinstance(stats, dict)
|
||||
assert stats['total_processed'] == 50
|
||||
assert stats['outliers_removed'] == 5
|
||||
assert stats['sanitization_errors'] == 2
|
||||
assert stats['outlier_rate'] == 0.1
|
||||
assert stats['error_rate'] == 0.04
|
||||
|
||||
def test_should_reset_statistics(self, phase_sanitizer):
|
||||
"""Should reset sanitization statistics."""
|
||||
phase_sanitizer._total_processed = 50
|
||||
phase_sanitizer._outliers_removed = 5
|
||||
phase_sanitizer._sanitization_errors = 2
|
||||
|
||||
phase_sanitizer.reset_statistics()
|
||||
|
||||
assert phase_sanitizer._total_processed == 0
|
||||
assert phase_sanitizer._outliers_removed == 0
|
||||
assert phase_sanitizer._sanitization_errors == 0
|
||||
|
||||
# Configuration validation tests
|
||||
def test_should_validate_unwrapping_method(self, mock_logger):
|
||||
"""Should validate unwrapping method."""
|
||||
invalid_config = {
|
||||
'unwrapping_method': 'invalid_method',
|
||||
'outlier_threshold': 3.0,
|
||||
'smoothing_window': 5
|
||||
}
|
||||
|
||||
with pytest.raises(ValueError, match="Invalid unwrapping method"):
|
||||
PhaseSanitizer(config=invalid_config, logger=mock_logger)
|
||||
|
||||
def test_should_validate_outlier_threshold(self, mock_logger):
|
||||
"""Should validate outlier threshold."""
|
||||
invalid_config = {
|
||||
'unwrapping_method': 'numpy',
|
||||
'outlier_threshold': -1.0, # Negative threshold
|
||||
'smoothing_window': 5
|
||||
}
|
||||
|
||||
with pytest.raises(ValueError, match="outlier_threshold must be positive"):
|
||||
PhaseSanitizer(config=invalid_config, logger=mock_logger)
|
||||
|
||||
def test_should_validate_smoothing_window(self, mock_logger):
|
||||
"""Should validate smoothing window."""
|
||||
invalid_config = {
|
||||
'unwrapping_method': 'numpy',
|
||||
'outlier_threshold': 3.0,
|
||||
'smoothing_window': 0 # Invalid window size
|
||||
}
|
||||
|
||||
with pytest.raises(ValueError, match="smoothing_window must be positive"):
|
||||
PhaseSanitizer(config=invalid_config, logger=mock_logger)
|
||||
|
||||
# Edge case tests
|
||||
def test_should_handle_single_antenna_data(self, phase_sanitizer):
|
||||
"""Should handle single antenna phase data."""
|
||||
single_antenna_phase = np.random.uniform(-np.pi, np.pi, (1, 56))
|
||||
|
||||
result = phase_sanitizer.sanitize_phase(single_antenna_phase)
|
||||
|
||||
assert result.shape == single_antenna_phase.shape
|
||||
|
||||
def test_should_handle_small_phase_arrays(self, phase_sanitizer):
|
||||
"""Should handle small phase arrays."""
|
||||
small_phase = np.random.uniform(-np.pi, np.pi, (2, 5))
|
||||
|
||||
result = phase_sanitizer.sanitize_phase(small_phase)
|
||||
|
||||
assert result.shape == small_phase.shape
|
||||
|
||||
def test_should_handle_constant_phase_data(self, phase_sanitizer):
|
||||
"""Should handle constant phase data."""
|
||||
constant_phase = np.full((3, 20), 0.5)
|
||||
|
||||
result = phase_sanitizer.sanitize_phase(constant_phase)
|
||||
|
||||
assert result.shape == constant_phase.shape
|
||||
@@ -0,0 +1,73 @@
|
||||
"""Tests for PoseService."""
|
||||
|
||||
import pytest
|
||||
import asyncio
|
||||
from unittest.mock import MagicMock, AsyncMock, patch
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class TestPoseServiceInit:
|
||||
def test_init_sets_defaults(self, mock_settings, mock_domain_config):
|
||||
with patch.dict("sys.modules", {
|
||||
"torch": MagicMock(),
|
||||
"src.models.densepose_head": MagicMock(),
|
||||
"src.models.modality_translation": MagicMock(),
|
||||
}):
|
||||
from src.services.pose_service import PoseService
|
||||
svc = PoseService(mock_settings, mock_domain_config)
|
||||
assert svc.is_initialized is False
|
||||
assert svc.is_running is False
|
||||
assert svc.stats["total_processed"] == 0
|
||||
|
||||
def test_stats_are_zero_on_init(self, mock_settings, mock_domain_config):
|
||||
with patch.dict("sys.modules", {
|
||||
"torch": MagicMock(),
|
||||
"src.models.densepose_head": MagicMock(),
|
||||
"src.models.modality_translation": MagicMock(),
|
||||
}):
|
||||
from src.services.pose_service import PoseService
|
||||
svc = PoseService(mock_settings, mock_domain_config)
|
||||
assert svc.stats["successful_detections"] == 0
|
||||
assert svc.stats["failed_detections"] == 0
|
||||
assert svc.stats["average_confidence"] == 0.0
|
||||
|
||||
|
||||
class TestPoseServiceLifecycle:
|
||||
@pytest.mark.asyncio
|
||||
async def test_initialize_sets_flag(self, mock_settings, mock_domain_config):
|
||||
with patch.dict("sys.modules", {
|
||||
"torch": MagicMock(),
|
||||
"src.models.densepose_head": MagicMock(),
|
||||
"src.models.modality_translation": MagicMock(),
|
||||
}):
|
||||
from src.services.pose_service import PoseService
|
||||
svc = PoseService(mock_settings, mock_domain_config)
|
||||
await svc.initialize()
|
||||
assert svc.is_initialized is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_stop(self, mock_settings, mock_domain_config):
|
||||
with patch.dict("sys.modules", {
|
||||
"torch": MagicMock(),
|
||||
"src.models.densepose_head": MagicMock(),
|
||||
"src.models.modality_translation": MagicMock(),
|
||||
}):
|
||||
from src.services.pose_service import PoseService
|
||||
svc = PoseService(mock_settings, mock_domain_config)
|
||||
await svc.initialize()
|
||||
await svc.start()
|
||||
assert svc.is_running is True
|
||||
await svc.stop()
|
||||
assert svc.is_running is False
|
||||
|
||||
|
||||
class TestPoseServiceStats:
|
||||
def test_initial_classification(self, mock_settings, mock_domain_config):
|
||||
with patch.dict("sys.modules", {
|
||||
"torch": MagicMock(),
|
||||
"src.models.densepose_head": MagicMock(),
|
||||
"src.models.modality_translation": MagicMock(),
|
||||
}):
|
||||
from src.services.pose_service import PoseService
|
||||
svc = PoseService(mock_settings, mock_domain_config)
|
||||
assert svc.last_error is None
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Tests for rate limiting middleware."""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, AsyncMock, patch
|
||||
|
||||
|
||||
class TestRateLimitMiddleware:
|
||||
def test_init(self, mock_settings):
|
||||
with patch("src.api.middleware.rate_limit.get_settings", return_value=mock_settings):
|
||||
from src.api.middleware.rate_limit import RateLimitMiddleware
|
||||
app = MagicMock()
|
||||
mw = RateLimitMiddleware(app)
|
||||
assert "anonymous" in mw.rate_limits
|
||||
assert "authenticated" in mw.rate_limits
|
||||
assert "admin" in mw.rate_limits
|
||||
|
||||
def test_exempt_paths(self, mock_settings):
|
||||
with patch("src.api.middleware.rate_limit.get_settings", return_value=mock_settings):
|
||||
from src.api.middleware.rate_limit import RateLimitMiddleware
|
||||
app = MagicMock()
|
||||
mw = RateLimitMiddleware(app)
|
||||
assert "/health" in mw.exempt_paths
|
||||
assert "/metrics" in mw.exempt_paths
|
||||
|
||||
def test_is_exempt(self, mock_settings):
|
||||
with patch("src.api.middleware.rate_limit.get_settings", return_value=mock_settings):
|
||||
from src.api.middleware.rate_limit import RateLimitMiddleware
|
||||
app = MagicMock()
|
||||
mw = RateLimitMiddleware(app)
|
||||
assert mw._is_exempt_path("/health") is True
|
||||
assert mw._is_exempt_path("/api/v1/pose/current") is False
|
||||
|
||||
def test_path_specific_limits(self, mock_settings):
|
||||
with patch("src.api.middleware.rate_limit.get_settings", return_value=mock_settings):
|
||||
from src.api.middleware.rate_limit import RateLimitMiddleware
|
||||
app = MagicMock()
|
||||
mw = RateLimitMiddleware(app)
|
||||
assert "/api/v1/pose/current" in mw.path_limits
|
||||
assert mw.path_limits["/api/v1/pose/current"]["requests"] == 60
|
||||
|
||||
def test_trusted_proxies_not_blocked(self, mock_settings):
|
||||
with patch("src.api.middleware.rate_limit.get_settings", return_value=mock_settings):
|
||||
from src.api.middleware.rate_limit import RateLimitMiddleware
|
||||
app = MagicMock()
|
||||
mw = RateLimitMiddleware(app)
|
||||
assert not mw._is_client_blocked("new-client-id")
|
||||
|
||||
|
||||
class TestRateLimitConfig:
|
||||
def test_anonymous_limit(self, mock_settings):
|
||||
with patch("src.api.middleware.rate_limit.get_settings", return_value=mock_settings):
|
||||
from src.api.middleware.rate_limit import RateLimitMiddleware
|
||||
app = MagicMock()
|
||||
mw = RateLimitMiddleware(app)
|
||||
assert mw.rate_limits["anonymous"]["burst"] == 10
|
||||
|
||||
def test_admin_limit(self, mock_settings):
|
||||
with patch("src.api.middleware.rate_limit.get_settings", return_value=mock_settings):
|
||||
from src.api.middleware.rate_limit import RateLimitMiddleware
|
||||
app = MagicMock()
|
||||
mw = RateLimitMiddleware(app)
|
||||
assert mw.rate_limits["admin"]["requests"] == 10000
|
||||
@@ -0,0 +1,244 @@
|
||||
import pytest
|
||||
import numpy as np
|
||||
from unittest.mock import Mock, patch, MagicMock
|
||||
from src.hardware.router_interface import RouterInterface, RouterConnectionError
|
||||
|
||||
|
||||
class TestRouterInterface:
|
||||
"""Test suite for Router Interface following London School TDD principles"""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_config(self):
|
||||
"""Configuration for router interface"""
|
||||
return {
|
||||
'router_ip': '192.168.1.1',
|
||||
'username': 'admin',
|
||||
'password': 'password',
|
||||
'ssh_port': 22,
|
||||
'timeout': 30,
|
||||
'max_retries': 3
|
||||
}
|
||||
|
||||
@pytest.fixture
|
||||
def router_interface(self, mock_config):
|
||||
"""Create router interface instance for testing"""
|
||||
return RouterInterface(mock_config)
|
||||
|
||||
@pytest.fixture
|
||||
def mock_ssh_client(self):
|
||||
"""Mock SSH client for testing"""
|
||||
mock_client = Mock()
|
||||
mock_client.connect = Mock()
|
||||
mock_client.exec_command = Mock()
|
||||
mock_client.close = Mock()
|
||||
return mock_client
|
||||
|
||||
def test_interface_initialization_creates_correct_configuration(self, mock_config):
|
||||
"""Test that router interface initializes with correct configuration"""
|
||||
# Act
|
||||
interface = RouterInterface(mock_config)
|
||||
|
||||
# Assert
|
||||
assert interface is not None
|
||||
assert interface.router_ip == mock_config['router_ip']
|
||||
assert interface.username == mock_config['username']
|
||||
assert interface.password == mock_config['password']
|
||||
assert interface.ssh_port == mock_config['ssh_port']
|
||||
assert interface.timeout == mock_config['timeout']
|
||||
assert interface.max_retries == mock_config['max_retries']
|
||||
assert not interface.is_connected
|
||||
|
||||
@patch('paramiko.SSHClient')
|
||||
def test_connect_establishes_ssh_connection(self, mock_ssh_class, router_interface, mock_ssh_client):
|
||||
"""Test that connect method establishes SSH connection"""
|
||||
# Arrange
|
||||
mock_ssh_class.return_value = mock_ssh_client
|
||||
|
||||
# Act
|
||||
result = router_interface.connect()
|
||||
|
||||
# Assert
|
||||
assert result is True
|
||||
assert router_interface.is_connected is True
|
||||
mock_ssh_client.set_missing_host_key_policy.assert_called_once()
|
||||
mock_ssh_client.connect.assert_called_once_with(
|
||||
hostname=router_interface.router_ip,
|
||||
port=router_interface.ssh_port,
|
||||
username=router_interface.username,
|
||||
password=router_interface.password,
|
||||
timeout=router_interface.timeout
|
||||
)
|
||||
|
||||
@patch('paramiko.SSHClient')
|
||||
def test_connect_handles_connection_failure(self, mock_ssh_class, router_interface, mock_ssh_client):
|
||||
"""Test that connect method handles connection failures gracefully"""
|
||||
# Arrange
|
||||
mock_ssh_class.return_value = mock_ssh_client
|
||||
mock_ssh_client.connect.side_effect = Exception("Connection failed")
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(RouterConnectionError):
|
||||
router_interface.connect()
|
||||
|
||||
assert router_interface.is_connected is False
|
||||
|
||||
@patch('paramiko.SSHClient')
|
||||
def test_disconnect_closes_ssh_connection(self, mock_ssh_class, router_interface, mock_ssh_client):
|
||||
"""Test that disconnect method closes SSH connection"""
|
||||
# Arrange
|
||||
mock_ssh_class.return_value = mock_ssh_client
|
||||
router_interface.connect()
|
||||
|
||||
# Act
|
||||
router_interface.disconnect()
|
||||
|
||||
# Assert
|
||||
assert router_interface.is_connected is False
|
||||
mock_ssh_client.close.assert_called_once()
|
||||
|
||||
@patch('paramiko.SSHClient')
|
||||
def test_execute_command_runs_ssh_command(self, mock_ssh_class, router_interface, mock_ssh_client):
|
||||
"""Test that execute_command runs SSH commands correctly"""
|
||||
# Arrange
|
||||
mock_ssh_class.return_value = mock_ssh_client
|
||||
mock_stdout = Mock()
|
||||
mock_stdout.read.return_value = b"command output"
|
||||
mock_stderr = Mock()
|
||||
mock_stderr.read.return_value = b""
|
||||
mock_ssh_client.exec_command.return_value = (None, mock_stdout, mock_stderr)
|
||||
|
||||
router_interface.connect()
|
||||
|
||||
# Act
|
||||
result = router_interface.execute_command("test command")
|
||||
|
||||
# Assert
|
||||
assert result == "command output"
|
||||
mock_ssh_client.exec_command.assert_called_with("test command")
|
||||
|
||||
@patch('paramiko.SSHClient')
|
||||
def test_execute_command_handles_command_errors(self, mock_ssh_class, router_interface, mock_ssh_client):
|
||||
"""Test that execute_command handles command errors"""
|
||||
# Arrange
|
||||
mock_ssh_class.return_value = mock_ssh_client
|
||||
mock_stdout = Mock()
|
||||
mock_stdout.read.return_value = b""
|
||||
mock_stderr = Mock()
|
||||
mock_stderr.read.return_value = b"command error"
|
||||
mock_ssh_client.exec_command.return_value = (None, mock_stdout, mock_stderr)
|
||||
|
||||
router_interface.connect()
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(RouterConnectionError):
|
||||
router_interface.execute_command("failing command")
|
||||
|
||||
def test_execute_command_requires_connection(self, router_interface):
|
||||
"""Test that execute_command requires active connection"""
|
||||
# Act & Assert
|
||||
with pytest.raises(RouterConnectionError):
|
||||
router_interface.execute_command("test command")
|
||||
|
||||
@patch('paramiko.SSHClient')
|
||||
def test_get_router_info_retrieves_system_information(self, mock_ssh_class, router_interface, mock_ssh_client):
|
||||
"""Test that get_router_info retrieves router system information"""
|
||||
# Arrange
|
||||
mock_ssh_class.return_value = mock_ssh_client
|
||||
mock_stdout = Mock()
|
||||
mock_stdout.read.return_value = b"Router Model: AC1900\nFirmware: 1.2.3"
|
||||
mock_stderr = Mock()
|
||||
mock_stderr.read.return_value = b""
|
||||
mock_ssh_client.exec_command.return_value = (None, mock_stdout, mock_stderr)
|
||||
|
||||
router_interface.connect()
|
||||
|
||||
# Act
|
||||
info = router_interface.get_router_info()
|
||||
|
||||
# Assert
|
||||
assert info is not None
|
||||
assert isinstance(info, dict)
|
||||
assert 'model' in info
|
||||
assert 'firmware' in info
|
||||
|
||||
@patch('paramiko.SSHClient')
|
||||
def test_enable_monitor_mode_configures_wifi_monitoring(self, mock_ssh_class, router_interface, mock_ssh_client):
|
||||
"""Test that enable_monitor_mode configures WiFi monitoring"""
|
||||
# Arrange
|
||||
mock_ssh_class.return_value = mock_ssh_client
|
||||
mock_stdout = Mock()
|
||||
mock_stdout.read.return_value = b"Monitor mode enabled"
|
||||
mock_stderr = Mock()
|
||||
mock_stderr.read.return_value = b""
|
||||
mock_ssh_client.exec_command.return_value = (None, mock_stdout, mock_stderr)
|
||||
|
||||
router_interface.connect()
|
||||
|
||||
# Act
|
||||
result = router_interface.enable_monitor_mode("wlan0")
|
||||
|
||||
# Assert
|
||||
assert result is True
|
||||
mock_ssh_client.exec_command.assert_called()
|
||||
|
||||
@patch('paramiko.SSHClient')
|
||||
def test_disable_monitor_mode_disables_wifi_monitoring(self, mock_ssh_class, router_interface, mock_ssh_client):
|
||||
"""Test that disable_monitor_mode disables WiFi monitoring"""
|
||||
# Arrange
|
||||
mock_ssh_class.return_value = mock_ssh_client
|
||||
mock_stdout = Mock()
|
||||
mock_stdout.read.return_value = b"Monitor mode disabled"
|
||||
mock_stderr = Mock()
|
||||
mock_stderr.read.return_value = b""
|
||||
mock_ssh_client.exec_command.return_value = (None, mock_stdout, mock_stderr)
|
||||
|
||||
router_interface.connect()
|
||||
|
||||
# Act
|
||||
result = router_interface.disable_monitor_mode("wlan0")
|
||||
|
||||
# Assert
|
||||
assert result is True
|
||||
mock_ssh_client.exec_command.assert_called()
|
||||
|
||||
@patch('paramiko.SSHClient')
|
||||
def test_interface_supports_context_manager(self, mock_ssh_class, router_interface, mock_ssh_client):
|
||||
"""Test that router interface supports context manager protocol"""
|
||||
# Arrange
|
||||
mock_ssh_class.return_value = mock_ssh_client
|
||||
|
||||
# Act
|
||||
with router_interface as interface:
|
||||
# Assert
|
||||
assert interface.is_connected is True
|
||||
|
||||
# Assert - connection should be closed after context
|
||||
assert router_interface.is_connected is False
|
||||
mock_ssh_client.close.assert_called_once()
|
||||
|
||||
def test_interface_validates_configuration(self):
|
||||
"""Test that router interface validates configuration parameters"""
|
||||
# Arrange
|
||||
invalid_config = {
|
||||
'router_ip': '', # Invalid IP
|
||||
'username': 'admin',
|
||||
'password': 'password'
|
||||
}
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(ValueError):
|
||||
RouterInterface(invalid_config)
|
||||
|
||||
@patch('paramiko.SSHClient')
|
||||
def test_interface_implements_retry_logic(self, mock_ssh_class, router_interface, mock_ssh_client):
|
||||
"""Test that interface implements retry logic for failed operations"""
|
||||
# Arrange
|
||||
mock_ssh_class.return_value = mock_ssh_client
|
||||
mock_ssh_client.connect.side_effect = [Exception("Temp failure"), None] # Fail once, then succeed
|
||||
|
||||
# Act
|
||||
result = router_interface.connect()
|
||||
|
||||
# Assert
|
||||
assert result is True
|
||||
assert mock_ssh_client.connect.call_count == 2 # Should retry once
|
||||
@@ -0,0 +1,415 @@
|
||||
"""TDD tests for router interface following London School approach."""
|
||||
|
||||
import pytest
|
||||
import asyncio
|
||||
import sys
|
||||
import os
|
||||
from unittest.mock import Mock, patch, AsyncMock, MagicMock
|
||||
from datetime import datetime, timezone
|
||||
import importlib.util
|
||||
|
||||
# Import the router interface module directly
|
||||
import unittest.mock
|
||||
|
||||
# Resolve paths relative to v1/ (this file lives at v1/tests/unit/)
|
||||
_TESTS_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
_V1_DIR = os.path.abspath(os.path.join(_TESTS_DIR, '..', '..'))
|
||||
if _V1_DIR not in sys.path:
|
||||
sys.path.insert(0, _V1_DIR)
|
||||
|
||||
# Mock asyncssh before importing
|
||||
with unittest.mock.patch.dict('sys.modules', {'asyncssh': unittest.mock.MagicMock()}):
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
'router_interface',
|
||||
os.path.join(_V1_DIR, 'src', 'hardware', 'router_interface.py')
|
||||
)
|
||||
router_module = importlib.util.module_from_spec(spec)
|
||||
|
||||
# Import CSI extractor for dependency
|
||||
csi_spec = importlib.util.spec_from_file_location(
|
||||
'csi_extractor',
|
||||
os.path.join(_V1_DIR, 'src', 'hardware', 'csi_extractor.py')
|
||||
)
|
||||
csi_module = importlib.util.module_from_spec(csi_spec)
|
||||
csi_spec.loader.exec_module(csi_module)
|
||||
|
||||
# Now load the router interface
|
||||
router_module.CSIData = csi_module.CSIData # Make CSIData available
|
||||
spec.loader.exec_module(router_module)
|
||||
# Register under the src path so patch('src.hardware.router_interface...') resolves
|
||||
sys.modules['src.hardware.router_interface'] = router_module
|
||||
# Set as attribute on parent package so the patch resolver can walk it
|
||||
if 'src.hardware' in sys.modules:
|
||||
sys.modules['src.hardware'].router_interface = router_module
|
||||
|
||||
# Get classes from modules
|
||||
RouterInterface = router_module.RouterInterface
|
||||
RouterConnectionError = router_module.RouterConnectionError
|
||||
CSIData = csi_module.CSIData
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.tdd
|
||||
@pytest.mark.london
|
||||
class TestRouterInterface:
|
||||
"""Test router interface using London School TDD."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_logger(self):
|
||||
"""Mock logger for testing."""
|
||||
return Mock()
|
||||
|
||||
@pytest.fixture
|
||||
def router_config(self):
|
||||
"""Router configuration for testing."""
|
||||
return {
|
||||
'host': '192.168.1.1',
|
||||
'port': 22,
|
||||
'username': 'admin',
|
||||
'password': 'password',
|
||||
'command_timeout': 30,
|
||||
'connection_timeout': 10,
|
||||
'max_retries': 3,
|
||||
'retry_delay': 1.0
|
||||
}
|
||||
|
||||
@pytest.fixture
|
||||
def router_interface(self, router_config, mock_logger):
|
||||
"""Create router interface for testing."""
|
||||
return RouterInterface(config=router_config, logger=mock_logger)
|
||||
|
||||
# Initialization tests
|
||||
def test_should_initialize_with_valid_config(self, router_config, mock_logger):
|
||||
"""Should initialize router interface with valid configuration."""
|
||||
interface = RouterInterface(config=router_config, logger=mock_logger)
|
||||
|
||||
assert interface.host == '192.168.1.1'
|
||||
assert interface.port == 22
|
||||
assert interface.username == 'admin'
|
||||
assert interface.password == 'password'
|
||||
assert interface.command_timeout == 30
|
||||
assert interface.connection_timeout == 10
|
||||
assert interface.max_retries == 3
|
||||
assert interface.retry_delay == 1.0
|
||||
assert interface.is_connected == False
|
||||
assert interface.logger == mock_logger
|
||||
|
||||
def test_should_raise_error_with_invalid_config(self, mock_logger):
|
||||
"""Should raise error when initialized with invalid configuration."""
|
||||
invalid_config = {'invalid': 'config'}
|
||||
|
||||
with pytest.raises(ValueError, match="Missing required configuration"):
|
||||
RouterInterface(config=invalid_config, logger=mock_logger)
|
||||
|
||||
def test_should_validate_required_fields(self, mock_logger):
|
||||
"""Should validate all required configuration fields."""
|
||||
required_fields = ['host', 'port', 'username', 'password']
|
||||
base_config = {
|
||||
'host': '192.168.1.1',
|
||||
'port': 22,
|
||||
'username': 'admin',
|
||||
'password': 'password'
|
||||
}
|
||||
|
||||
for field in required_fields:
|
||||
config = base_config.copy()
|
||||
del config[field]
|
||||
|
||||
with pytest.raises(ValueError, match="Missing required configuration"):
|
||||
RouterInterface(config=config, logger=mock_logger)
|
||||
|
||||
def test_should_use_default_values(self, mock_logger):
|
||||
"""Should use default values for optional parameters."""
|
||||
minimal_config = {
|
||||
'host': '192.168.1.1',
|
||||
'port': 22,
|
||||
'username': 'admin',
|
||||
'password': 'password'
|
||||
}
|
||||
|
||||
interface = RouterInterface(config=minimal_config, logger=mock_logger)
|
||||
|
||||
assert interface.command_timeout == 30 # default
|
||||
assert interface.connection_timeout == 10 # default
|
||||
assert interface.max_retries == 3 # default
|
||||
assert interface.retry_delay == 1.0 # default
|
||||
|
||||
def test_should_initialize_without_logger(self, router_config):
|
||||
"""Should initialize without logger provided."""
|
||||
interface = RouterInterface(config=router_config)
|
||||
|
||||
assert interface.logger is not None # Should create default logger
|
||||
|
||||
# Connection tests
|
||||
@pytest.mark.asyncio
|
||||
async def test_should_connect_successfully(self, router_interface):
|
||||
"""Should establish SSH connection successfully."""
|
||||
mock_ssh_client = Mock()
|
||||
|
||||
with patch('src.hardware.router_interface.asyncssh.connect', new_callable=AsyncMock) as mock_connect:
|
||||
mock_connect.return_value = mock_ssh_client
|
||||
|
||||
result = await router_interface.connect()
|
||||
|
||||
assert result == True
|
||||
assert router_interface.is_connected == True
|
||||
assert router_interface.ssh_client == mock_ssh_client
|
||||
mock_connect.assert_called_once_with(
|
||||
'192.168.1.1',
|
||||
port=22,
|
||||
username='admin',
|
||||
password='password',
|
||||
connect_timeout=10
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_should_handle_connection_failure(self, router_interface):
|
||||
"""Should handle SSH connection failure gracefully."""
|
||||
with patch('src.hardware.router_interface.asyncssh.connect', new_callable=AsyncMock) as mock_connect:
|
||||
mock_connect.side_effect = ConnectionError("Connection failed")
|
||||
|
||||
result = await router_interface.connect()
|
||||
|
||||
assert result == False
|
||||
assert router_interface.is_connected == False
|
||||
assert router_interface.ssh_client is None
|
||||
router_interface.logger.error.assert_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_should_disconnect_when_connected(self, router_interface):
|
||||
"""Should disconnect SSH connection when connected."""
|
||||
mock_ssh_client = Mock()
|
||||
router_interface.is_connected = True
|
||||
router_interface.ssh_client = mock_ssh_client
|
||||
|
||||
await router_interface.disconnect()
|
||||
|
||||
assert router_interface.is_connected == False
|
||||
assert router_interface.ssh_client is None
|
||||
mock_ssh_client.close.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_should_handle_disconnect_when_not_connected(self, router_interface):
|
||||
"""Should handle disconnect when not connected."""
|
||||
router_interface.is_connected = False
|
||||
router_interface.ssh_client = None
|
||||
|
||||
await router_interface.disconnect()
|
||||
|
||||
# Should not raise any exception
|
||||
assert router_interface.is_connected == False
|
||||
|
||||
# Command execution tests
|
||||
@pytest.mark.asyncio
|
||||
async def test_should_execute_command_successfully(self, router_interface):
|
||||
"""Should execute SSH command successfully."""
|
||||
mock_ssh_client = Mock()
|
||||
mock_result = Mock()
|
||||
mock_result.stdout = "command output"
|
||||
mock_result.stderr = ""
|
||||
mock_result.returncode = 0
|
||||
|
||||
router_interface.is_connected = True
|
||||
router_interface.ssh_client = mock_ssh_client
|
||||
|
||||
with patch.object(mock_ssh_client, 'run', new_callable=AsyncMock) as mock_run:
|
||||
mock_run.return_value = mock_result
|
||||
|
||||
result = await router_interface.execute_command("test command")
|
||||
|
||||
assert result == "command output"
|
||||
mock_run.assert_called_once_with("test command", timeout=30)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_should_handle_command_execution_when_not_connected(self, router_interface):
|
||||
"""Should handle command execution when not connected."""
|
||||
router_interface.is_connected = False
|
||||
|
||||
with pytest.raises(RouterConnectionError, match="Not connected to router"):
|
||||
await router_interface.execute_command("test command")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_should_handle_command_execution_error(self, router_interface):
|
||||
"""Should handle command execution errors."""
|
||||
mock_ssh_client = Mock()
|
||||
mock_result = Mock()
|
||||
mock_result.stdout = ""
|
||||
mock_result.stderr = "command error"
|
||||
mock_result.returncode = 1
|
||||
|
||||
router_interface.is_connected = True
|
||||
router_interface.ssh_client = mock_ssh_client
|
||||
|
||||
with patch.object(mock_ssh_client, 'run', new_callable=AsyncMock) as mock_run:
|
||||
mock_run.return_value = mock_result
|
||||
|
||||
with pytest.raises(RouterConnectionError, match="Command failed"):
|
||||
await router_interface.execute_command("test command")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_should_retry_command_execution_on_failure(self, router_interface):
|
||||
"""Should retry command execution on temporary failure."""
|
||||
mock_ssh_client = Mock()
|
||||
mock_success_result = Mock()
|
||||
mock_success_result.stdout = "success output"
|
||||
mock_success_result.stderr = ""
|
||||
mock_success_result.returncode = 0
|
||||
|
||||
router_interface.is_connected = True
|
||||
router_interface.ssh_client = mock_ssh_client
|
||||
|
||||
with patch.object(mock_ssh_client, 'run', new_callable=AsyncMock) as mock_run:
|
||||
# First two calls fail, third succeeds
|
||||
mock_run.side_effect = [
|
||||
ConnectionError("Network error"),
|
||||
ConnectionError("Network error"),
|
||||
mock_success_result
|
||||
]
|
||||
|
||||
result = await router_interface.execute_command("test command")
|
||||
|
||||
assert result == "success output"
|
||||
assert mock_run.call_count == 3
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_should_fail_after_max_retries(self, router_interface):
|
||||
"""Should fail after maximum retries exceeded."""
|
||||
mock_ssh_client = Mock()
|
||||
|
||||
router_interface.is_connected = True
|
||||
router_interface.ssh_client = mock_ssh_client
|
||||
|
||||
with patch.object(mock_ssh_client, 'run', new_callable=AsyncMock) as mock_run:
|
||||
mock_run.side_effect = ConnectionError("Network error")
|
||||
|
||||
with pytest.raises(RouterConnectionError, match="Command execution failed after 3 retries"):
|
||||
await router_interface.execute_command("test command")
|
||||
|
||||
assert mock_run.call_count == 3
|
||||
|
||||
# CSI data retrieval tests
|
||||
@pytest.mark.asyncio
|
||||
async def test_should_get_csi_data_successfully(self, router_interface):
|
||||
"""Should retrieve CSI data successfully."""
|
||||
expected_csi_data = Mock(spec=CSIData)
|
||||
|
||||
with patch.object(router_interface, 'execute_command', new_callable=AsyncMock) as mock_execute:
|
||||
with patch.object(router_interface, '_parse_csi_response', return_value=expected_csi_data) as mock_parse:
|
||||
mock_execute.return_value = "csi data response"
|
||||
|
||||
result = await router_interface.get_csi_data()
|
||||
|
||||
assert result == expected_csi_data
|
||||
mock_execute.assert_called_once_with("iwlist scan | grep CSI")
|
||||
mock_parse.assert_called_once_with("csi data response")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_should_handle_csi_data_retrieval_failure(self, router_interface):
|
||||
"""Should handle CSI data retrieval failure."""
|
||||
with patch.object(router_interface, 'execute_command', new_callable=AsyncMock) as mock_execute:
|
||||
mock_execute.side_effect = RouterConnectionError("Command failed")
|
||||
|
||||
with pytest.raises(RouterConnectionError):
|
||||
await router_interface.get_csi_data()
|
||||
|
||||
# Router status tests
|
||||
@pytest.mark.asyncio
|
||||
async def test_should_get_router_status_successfully(self, router_interface):
|
||||
"""Should get router status successfully."""
|
||||
expected_status = {
|
||||
'cpu_usage': 25.5,
|
||||
'memory_usage': 60.2,
|
||||
'wifi_status': 'active',
|
||||
'uptime': '5 days, 3 hours'
|
||||
}
|
||||
|
||||
with patch.object(router_interface, 'execute_command', new_callable=AsyncMock) as mock_execute:
|
||||
with patch.object(router_interface, '_parse_status_response', return_value=expected_status) as mock_parse:
|
||||
mock_execute.return_value = "status response"
|
||||
|
||||
result = await router_interface.get_router_status()
|
||||
|
||||
assert result == expected_status
|
||||
mock_execute.assert_called_once_with("cat /proc/stat && free && iwconfig")
|
||||
mock_parse.assert_called_once_with("status response")
|
||||
|
||||
# Configuration tests
|
||||
@pytest.mark.asyncio
|
||||
async def test_should_configure_csi_monitoring_successfully(self, router_interface):
|
||||
"""Should configure CSI monitoring successfully."""
|
||||
config = {
|
||||
'channel': 6,
|
||||
'bandwidth': 20,
|
||||
'sample_rate': 100
|
||||
}
|
||||
|
||||
with patch.object(router_interface, 'execute_command', new_callable=AsyncMock) as mock_execute:
|
||||
mock_execute.return_value = "Configuration applied"
|
||||
|
||||
result = await router_interface.configure_csi_monitoring(config)
|
||||
|
||||
assert result == True
|
||||
mock_execute.assert_called_once_with(
|
||||
"iwconfig wlan0 channel 6 && echo 'CSI monitoring configured'"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_should_handle_csi_monitoring_configuration_failure(self, router_interface):
|
||||
"""Should handle CSI monitoring configuration failure."""
|
||||
config = {
|
||||
'channel': 6,
|
||||
'bandwidth': 20,
|
||||
'sample_rate': 100
|
||||
}
|
||||
|
||||
with patch.object(router_interface, 'execute_command', new_callable=AsyncMock) as mock_execute:
|
||||
mock_execute.side_effect = RouterConnectionError("Command failed")
|
||||
|
||||
result = await router_interface.configure_csi_monitoring(config)
|
||||
|
||||
assert result == False
|
||||
|
||||
# Health check tests
|
||||
@pytest.mark.asyncio
|
||||
async def test_should_perform_health_check_successfully(self, router_interface):
|
||||
"""Should perform health check successfully."""
|
||||
with patch.object(router_interface, 'execute_command', new_callable=AsyncMock) as mock_execute:
|
||||
mock_execute.return_value = "pong"
|
||||
|
||||
result = await router_interface.health_check()
|
||||
|
||||
assert result == True
|
||||
mock_execute.assert_called_once_with("echo 'ping' && echo 'pong'")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_should_handle_health_check_failure(self, router_interface):
|
||||
"""Should handle health check failure."""
|
||||
with patch.object(router_interface, 'execute_command', new_callable=AsyncMock) as mock_execute:
|
||||
mock_execute.side_effect = RouterConnectionError("Command failed")
|
||||
|
||||
result = await router_interface.health_check()
|
||||
|
||||
assert result == False
|
||||
|
||||
# Parsing method tests
|
||||
def test_should_parse_csi_response(self, router_interface):
|
||||
"""Should raise RouterConnectionError — real router-format CSI parser not yet implemented."""
|
||||
mock_response = "CSI_DATA:timestamp,antennas,subcarriers,frequency,bandwidth"
|
||||
with pytest.raises(RouterConnectionError, match="Real CSI data parsing from router responses is not yet implemented"):
|
||||
router_interface._parse_csi_response(mock_response)
|
||||
|
||||
def test_should_parse_status_response(self, router_interface):
|
||||
"""Should parse router status response."""
|
||||
mock_response = """
|
||||
cpu 123456 0 78901 234567 0 0 0 0 0 0
|
||||
MemTotal: 1024000 kB
|
||||
MemFree: 512000 kB
|
||||
wlan0 IEEE 802.11 ESSID:"TestNetwork"
|
||||
"""
|
||||
|
||||
result = router_interface._parse_status_response(mock_response)
|
||||
|
||||
assert isinstance(result, dict)
|
||||
assert 'cpu_usage' in result
|
||||
assert 'memory_usage' in result
|
||||
assert 'wifi_status' in result
|
||||
@@ -0,0 +1,807 @@
|
||||
"""
|
||||
Unit tests for the commodity sensing module (ADR-013).
|
||||
|
||||
Tests cover:
|
||||
- Feature extraction from known sinusoidal RSSI input
|
||||
- Classifier producing correct presence/motion from known features
|
||||
- SimulatedCollector determinism (same seed = same output)
|
||||
- CUSUM change-point detection catching step changes
|
||||
- Band power extraction isolating correct frequencies
|
||||
- Backend capabilities and pipeline integration
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
from numpy.typing import NDArray
|
||||
|
||||
from v1.src.sensing.rssi_collector import (
|
||||
RingBuffer,
|
||||
SimulatedCollector,
|
||||
WifiSample,
|
||||
)
|
||||
from v1.src.sensing.feature_extractor import (
|
||||
RssiFeatureExtractor,
|
||||
RssiFeatures,
|
||||
cusum_detect,
|
||||
_band_power,
|
||||
)
|
||||
from v1.src.sensing.classifier import (
|
||||
MotionLevel,
|
||||
PresenceClassifier,
|
||||
SensingResult,
|
||||
)
|
||||
from v1.src.sensing.backend import (
|
||||
Capability,
|
||||
CommodityBackend,
|
||||
SensingBackend,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def make_sinusoidal_rssi(
|
||||
freq_hz: float,
|
||||
amplitude: float,
|
||||
baseline: float,
|
||||
duration_s: float,
|
||||
sample_rate: float,
|
||||
) -> NDArray[np.float64]:
|
||||
"""Generate a clean sinusoidal RSSI signal (no noise)."""
|
||||
n = int(duration_s * sample_rate)
|
||||
t = np.arange(n) / sample_rate
|
||||
return baseline + amplitude * np.sin(2 * np.pi * freq_hz * t)
|
||||
|
||||
|
||||
def make_step_signal(
|
||||
baseline: float,
|
||||
step_value: float,
|
||||
step_at_sample: int,
|
||||
n_samples: int,
|
||||
) -> NDArray[np.float64]:
|
||||
"""Generate a signal with a step change at a specific sample."""
|
||||
signal = np.full(n_samples, baseline, dtype=np.float64)
|
||||
signal[step_at_sample:] = step_value
|
||||
return signal
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# RingBuffer tests
|
||||
# ===========================================================================
|
||||
|
||||
class TestRingBuffer:
|
||||
def test_append_and_get_all(self):
|
||||
buf = RingBuffer(max_size=5)
|
||||
for i in range(3):
|
||||
buf.append(WifiSample(
|
||||
timestamp=float(i), rssi_dbm=-50.0 + i, noise_dbm=-95.0,
|
||||
link_quality=0.8, tx_bytes=0, rx_bytes=0, retry_count=0,
|
||||
interface="test0",
|
||||
))
|
||||
assert len(buf) == 3
|
||||
samples = buf.get_all()
|
||||
assert len(samples) == 3
|
||||
assert samples[0].rssi_dbm == -50.0
|
||||
assert samples[2].rssi_dbm == -48.0
|
||||
|
||||
def test_ring_buffer_overflow(self):
|
||||
buf = RingBuffer(max_size=3)
|
||||
for i in range(5):
|
||||
buf.append(WifiSample(
|
||||
timestamp=float(i), rssi_dbm=float(i), noise_dbm=-95.0,
|
||||
link_quality=0.8, tx_bytes=0, rx_bytes=0, retry_count=0,
|
||||
interface="test0",
|
||||
))
|
||||
assert len(buf) == 3
|
||||
samples = buf.get_all()
|
||||
# Oldest two should have been evicted; remaining: 2, 3, 4
|
||||
assert samples[0].rssi_dbm == 2.0
|
||||
assert samples[2].rssi_dbm == 4.0
|
||||
|
||||
def test_get_last_n(self):
|
||||
buf = RingBuffer(max_size=10)
|
||||
for i in range(7):
|
||||
buf.append(WifiSample(
|
||||
timestamp=float(i), rssi_dbm=float(i), noise_dbm=-95.0,
|
||||
link_quality=0.8, tx_bytes=0, rx_bytes=0, retry_count=0,
|
||||
interface="test0",
|
||||
))
|
||||
last_3 = buf.get_last_n(3)
|
||||
assert len(last_3) == 3
|
||||
assert last_3[0].rssi_dbm == 4.0
|
||||
assert last_3[2].rssi_dbm == 6.0
|
||||
|
||||
def test_clear(self):
|
||||
buf = RingBuffer(max_size=10)
|
||||
buf.append(WifiSample(
|
||||
timestamp=0.0, rssi_dbm=-50.0, noise_dbm=-95.0,
|
||||
link_quality=0.8, tx_bytes=0, rx_bytes=0, retry_count=0,
|
||||
interface="test0",
|
||||
))
|
||||
buf.clear()
|
||||
assert len(buf) == 0
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# SimulatedCollector tests
|
||||
# ===========================================================================
|
||||
|
||||
class TestSimulatedCollector:
|
||||
def test_deterministic_output_same_seed(self):
|
||||
"""Same seed must produce identical samples."""
|
||||
c1 = SimulatedCollector(seed=123, sample_rate_hz=10.0)
|
||||
c2 = SimulatedCollector(seed=123, sample_rate_hz=10.0)
|
||||
|
||||
s1 = c1.generate_samples(5.0)
|
||||
s2 = c2.generate_samples(5.0)
|
||||
|
||||
assert len(s1) == len(s2) == 50
|
||||
for a, b in zip(s1, s2):
|
||||
assert a.rssi_dbm == b.rssi_dbm, (
|
||||
f"RSSI mismatch at same seed: {a.rssi_dbm} != {b.rssi_dbm}"
|
||||
)
|
||||
assert a.noise_dbm == b.noise_dbm
|
||||
assert a.link_quality == b.link_quality
|
||||
|
||||
def test_different_seeds_differ(self):
|
||||
"""Different seeds must produce different samples."""
|
||||
c1 = SimulatedCollector(seed=1, sample_rate_hz=10.0)
|
||||
c2 = SimulatedCollector(seed=999, sample_rate_hz=10.0)
|
||||
|
||||
s1 = c1.generate_samples(2.0)
|
||||
s2 = c2.generate_samples(2.0)
|
||||
|
||||
rssi1 = [s.rssi_dbm for s in s1]
|
||||
rssi2 = [s.rssi_dbm for s in s2]
|
||||
# Not all values should match
|
||||
assert rssi1 != rssi2
|
||||
|
||||
def test_sinusoidal_component(self):
|
||||
"""With zero noise, should see a clean sinusoid."""
|
||||
c = SimulatedCollector(
|
||||
seed=0,
|
||||
sample_rate_hz=100.0,
|
||||
baseline_dbm=-50.0,
|
||||
sine_freq_hz=1.0,
|
||||
sine_amplitude_dbm=5.0,
|
||||
noise_std_dbm=0.0, # no noise
|
||||
)
|
||||
samples = c.generate_samples(2.0)
|
||||
rssi = np.array([s.rssi_dbm for s in samples])
|
||||
|
||||
# Mean should be very close to baseline
|
||||
assert abs(np.mean(rssi) - (-50.0)) < 0.5
|
||||
|
||||
# Amplitude should be close to 5 dBm (peak-to-peak ~10)
|
||||
assert np.ptp(rssi) > 9.0
|
||||
assert np.ptp(rssi) < 11.0
|
||||
|
||||
def test_step_change_injection(self):
|
||||
"""Step change should shift the signal at the specified time."""
|
||||
c = SimulatedCollector(
|
||||
seed=42,
|
||||
sample_rate_hz=10.0,
|
||||
baseline_dbm=-50.0,
|
||||
sine_amplitude_dbm=0.0,
|
||||
noise_std_dbm=0.0,
|
||||
step_change_at=2.0,
|
||||
step_change_dbm=-10.0,
|
||||
)
|
||||
samples = c.generate_samples(4.0)
|
||||
rssi = np.array([s.rssi_dbm for s in samples])
|
||||
|
||||
# Before step (first 20 samples at 10 Hz = 2 seconds)
|
||||
mean_before = np.mean(rssi[:20])
|
||||
# After step (samples 20-39)
|
||||
mean_after = np.mean(rssi[20:])
|
||||
|
||||
assert abs(mean_before - (-50.0)) < 0.1
|
||||
assert abs(mean_after - (-60.0)) < 0.1
|
||||
|
||||
def test_sample_count(self):
|
||||
"""generate_samples should produce exactly rate * duration samples."""
|
||||
c = SimulatedCollector(seed=0, sample_rate_hz=20.0)
|
||||
samples = c.generate_samples(3.0)
|
||||
assert len(samples) == 60
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Feature extraction tests
|
||||
# ===========================================================================
|
||||
|
||||
class TestFeatureExtractor:
|
||||
def test_time_domain_from_known_sine(self):
|
||||
"""
|
||||
A pure sinusoid at -50 dBm baseline with 2 dBm amplitude should
|
||||
produce known statistical properties.
|
||||
"""
|
||||
sample_rate = 100.0
|
||||
rssi = make_sinusoidal_rssi(
|
||||
freq_hz=1.0, amplitude=2.0, baseline=-50.0,
|
||||
duration_s=10.0, sample_rate=sample_rate,
|
||||
)
|
||||
|
||||
ext = RssiFeatureExtractor(window_seconds=30.0)
|
||||
features = ext.extract_from_array(rssi, sample_rate)
|
||||
|
||||
# Mean should be close to -50
|
||||
assert abs(features.mean - (-50.0)) < 0.1
|
||||
|
||||
# Variance of A*sin(x) is A^2/2
|
||||
expected_var = 2.0**2 / 2.0 # = 2.0
|
||||
assert abs(features.variance - expected_var) < 0.2
|
||||
|
||||
# Skewness of a pure sinusoid is ~0
|
||||
assert abs(features.skewness) < 0.2
|
||||
|
||||
# Range should be close to 2*amplitude = 4.0
|
||||
assert abs(features.range - 4.0) < 0.2
|
||||
|
||||
def test_frequency_domain_dominant_frequency(self):
|
||||
"""
|
||||
A 0.3 Hz sinusoid should produce a dominant frequency near 0.3 Hz.
|
||||
"""
|
||||
sample_rate = 10.0
|
||||
rssi = make_sinusoidal_rssi(
|
||||
freq_hz=0.3, amplitude=3.0, baseline=-50.0,
|
||||
duration_s=30.0, sample_rate=sample_rate,
|
||||
)
|
||||
|
||||
ext = RssiFeatureExtractor(window_seconds=60.0)
|
||||
features = ext.extract_from_array(rssi, sample_rate)
|
||||
|
||||
# Dominant frequency should be close to 0.3 Hz
|
||||
assert abs(features.dominant_freq_hz - 0.3) < 0.1, (
|
||||
f"Dominant freq {features.dominant_freq_hz} != ~0.3 Hz"
|
||||
)
|
||||
|
||||
def test_breathing_band_power(self):
|
||||
"""
|
||||
A 0.3 Hz signal should produce significant power in the breathing
|
||||
band (0.1-0.5 Hz) and negligible power in the motion band (0.5-3 Hz).
|
||||
"""
|
||||
sample_rate = 10.0
|
||||
rssi = make_sinusoidal_rssi(
|
||||
freq_hz=0.3, amplitude=3.0, baseline=-50.0,
|
||||
duration_s=30.0, sample_rate=sample_rate,
|
||||
)
|
||||
|
||||
ext = RssiFeatureExtractor(window_seconds=60.0)
|
||||
features = ext.extract_from_array(rssi, sample_rate)
|
||||
|
||||
assert features.breathing_band_power > 0.1, (
|
||||
f"Breathing band power too low: {features.breathing_band_power}"
|
||||
)
|
||||
# Motion band should have much less power than breathing band
|
||||
assert features.motion_band_power < features.breathing_band_power, (
|
||||
f"Motion band ({features.motion_band_power}) should be less than "
|
||||
f"breathing band ({features.breathing_band_power})"
|
||||
)
|
||||
|
||||
def test_motion_band_power(self):
|
||||
"""
|
||||
A 1.5 Hz signal should produce significant power in the motion
|
||||
band (0.5-3.0 Hz) and negligible power in the breathing band.
|
||||
"""
|
||||
sample_rate = 10.0
|
||||
rssi = make_sinusoidal_rssi(
|
||||
freq_hz=1.5, amplitude=3.0, baseline=-50.0,
|
||||
duration_s=30.0, sample_rate=sample_rate,
|
||||
)
|
||||
|
||||
ext = RssiFeatureExtractor(window_seconds=60.0)
|
||||
features = ext.extract_from_array(rssi, sample_rate)
|
||||
|
||||
assert features.motion_band_power > 0.1, (
|
||||
f"Motion band power too low: {features.motion_band_power}"
|
||||
)
|
||||
assert features.motion_band_power > features.breathing_band_power, (
|
||||
f"Motion band ({features.motion_band_power}) should dominate over "
|
||||
f"breathing band ({features.breathing_band_power})"
|
||||
)
|
||||
|
||||
def test_band_isolation_multi_frequency(self):
|
||||
"""
|
||||
A signal with components at 0.2 Hz AND 2.0 Hz should produce power
|
||||
in both bands, each dominated by the correct component.
|
||||
"""
|
||||
sample_rate = 10.0
|
||||
n = int(30.0 * sample_rate)
|
||||
t = np.arange(n) / sample_rate
|
||||
# 0.2 Hz component (breathing) + 2.0 Hz component (motion)
|
||||
rssi = -50.0 + 3.0 * np.sin(2 * np.pi * 0.2 * t) + 2.0 * np.sin(2 * np.pi * 2.0 * t)
|
||||
|
||||
ext = RssiFeatureExtractor(window_seconds=60.0)
|
||||
features = ext.extract_from_array(rssi, sample_rate)
|
||||
|
||||
# Both bands should have significant power
|
||||
assert features.breathing_band_power > 0.05
|
||||
assert features.motion_band_power > 0.05
|
||||
|
||||
def test_constant_signal_features(self):
|
||||
"""A constant signal should have zero variance and no spectral content."""
|
||||
rssi = np.full(200, -50.0)
|
||||
ext = RssiFeatureExtractor()
|
||||
features = ext.extract_from_array(rssi, 10.0)
|
||||
|
||||
assert features.variance == 0.0
|
||||
assert features.std == 0.0
|
||||
assert features.range == 0.0
|
||||
assert features.iqr == 0.0
|
||||
assert features.total_spectral_power < 1e-10
|
||||
|
||||
def test_too_few_samples(self):
|
||||
"""Fewer than 4 samples should return empty features."""
|
||||
rssi = np.array([-50.0, -51.0])
|
||||
ext = RssiFeatureExtractor()
|
||||
features = ext.extract_from_array(rssi, 10.0)
|
||||
assert features.n_samples == 2
|
||||
assert features.variance == 0.0
|
||||
|
||||
def test_extract_from_wifi_samples(self):
|
||||
"""Test extraction from WifiSample objects (the normal path)."""
|
||||
collector = SimulatedCollector(
|
||||
seed=42, sample_rate_hz=10.0,
|
||||
baseline_dbm=-50.0, sine_freq_hz=0.3,
|
||||
sine_amplitude_dbm=2.0, noise_std_dbm=0.1,
|
||||
)
|
||||
samples = collector.generate_samples(10.0)
|
||||
|
||||
ext = RssiFeatureExtractor(window_seconds=30.0)
|
||||
features = ext.extract(samples)
|
||||
|
||||
assert features.n_samples == 100
|
||||
assert abs(features.mean - (-50.0)) < 1.0
|
||||
assert features.variance > 0.0
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# CUSUM change-point detection tests
|
||||
# ===========================================================================
|
||||
|
||||
class TestCusum:
|
||||
def test_step_change_detected(self):
|
||||
"""CUSUM should detect a step change in the signal."""
|
||||
signal = make_step_signal(
|
||||
baseline=0.0, step_value=5.0,
|
||||
step_at_sample=100, n_samples=200,
|
||||
)
|
||||
target = float(np.mean(signal))
|
||||
std = float(np.std(signal, ddof=1))
|
||||
threshold = 3.0 * std
|
||||
drift = 0.5 * std
|
||||
|
||||
change_points = cusum_detect(signal, target, threshold, drift)
|
||||
|
||||
assert len(change_points) > 0, "No change points detected for step change"
|
||||
# At least one change point should be near the step (sample 100)
|
||||
nearest = min(change_points, key=lambda x: abs(x - 100))
|
||||
assert abs(nearest - 100) < 20, (
|
||||
f"Nearest change point at {nearest}, expected near 100"
|
||||
)
|
||||
|
||||
def test_no_change_point_in_constant(self):
|
||||
"""A constant signal should produce no change points."""
|
||||
signal = np.full(200, 0.0)
|
||||
change_points = cusum_detect(signal, 0.0, 1.0, 0.1)
|
||||
assert len(change_points) == 0
|
||||
|
||||
def test_multiple_step_changes(self):
|
||||
"""CUSUM should detect multiple step changes."""
|
||||
n = 300
|
||||
signal = np.zeros(n, dtype=np.float64)
|
||||
signal[100:200] = 5.0
|
||||
signal[200:] = 0.0
|
||||
|
||||
target = float(np.mean(signal))
|
||||
std = float(np.std(signal, ddof=1))
|
||||
threshold = 2.0 * std
|
||||
drift = 0.3 * std
|
||||
|
||||
change_points = cusum_detect(signal, target, threshold, drift)
|
||||
# Should detect at least the step up and the step down
|
||||
assert len(change_points) >= 2, (
|
||||
f"Expected >= 2 change points, got {len(change_points)}"
|
||||
)
|
||||
|
||||
def test_cusum_with_feature_extractor(self):
|
||||
"""Feature extractor should detect step change via CUSUM."""
|
||||
signal = make_step_signal(
|
||||
baseline=-50.0, step_value=-60.0,
|
||||
step_at_sample=150, n_samples=300,
|
||||
)
|
||||
|
||||
ext = RssiFeatureExtractor(cusum_threshold=2.0, cusum_drift=0.3)
|
||||
features = ext.extract_from_array(signal, 10.0)
|
||||
|
||||
assert features.n_change_points > 0, (
|
||||
f"Expected change points but got {features.n_change_points}"
|
||||
)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Classifier tests
|
||||
# ===========================================================================
|
||||
|
||||
class TestPresenceClassifier:
|
||||
def test_absent_when_low_variance(self):
|
||||
"""Low variance should classify as ABSENT."""
|
||||
features = RssiFeatures(
|
||||
variance=0.1,
|
||||
motion_band_power=0.0,
|
||||
breathing_band_power=0.0,
|
||||
n_samples=100,
|
||||
)
|
||||
clf = PresenceClassifier(presence_variance_threshold=0.5)
|
||||
result = clf.classify(features)
|
||||
|
||||
assert result.motion_level == MotionLevel.ABSENT
|
||||
assert result.presence_detected is False
|
||||
|
||||
def test_present_still_when_high_variance_low_motion(self):
|
||||
"""High variance but low motion energy should classify as PRESENT_STILL."""
|
||||
features = RssiFeatures(
|
||||
variance=2.0,
|
||||
motion_band_power=0.05,
|
||||
breathing_band_power=0.3,
|
||||
n_samples=100,
|
||||
)
|
||||
clf = PresenceClassifier(
|
||||
presence_variance_threshold=0.5,
|
||||
motion_energy_threshold=0.1,
|
||||
)
|
||||
result = clf.classify(features)
|
||||
|
||||
assert result.motion_level == MotionLevel.PRESENT_STILL
|
||||
assert result.presence_detected is True
|
||||
|
||||
def test_active_when_high_variance_high_motion(self):
|
||||
"""High variance and high motion energy should classify as ACTIVE."""
|
||||
features = RssiFeatures(
|
||||
variance=3.0,
|
||||
motion_band_power=0.5,
|
||||
breathing_band_power=0.1,
|
||||
n_samples=100,
|
||||
)
|
||||
clf = PresenceClassifier(
|
||||
presence_variance_threshold=0.5,
|
||||
motion_energy_threshold=0.1,
|
||||
)
|
||||
result = clf.classify(features)
|
||||
|
||||
assert result.motion_level == MotionLevel.ACTIVE
|
||||
assert result.presence_detected is True
|
||||
|
||||
def test_confidence_for_absent_decreases_with_rising_variance(self):
|
||||
"""
|
||||
When classified as ABSENT, confidence should decrease as variance
|
||||
approaches the presence threshold (less certain about absence).
|
||||
"""
|
||||
clf = PresenceClassifier(presence_variance_threshold=10.0)
|
||||
|
||||
clearly_absent = clf.classify(RssiFeatures(
|
||||
variance=0.5, motion_band_power=0.0, n_samples=100
|
||||
))
|
||||
borderline_absent = clf.classify(RssiFeatures(
|
||||
variance=9.0, motion_band_power=0.0, n_samples=100
|
||||
))
|
||||
|
||||
assert clearly_absent.motion_level == MotionLevel.ABSENT
|
||||
assert borderline_absent.motion_level == MotionLevel.ABSENT
|
||||
assert clearly_absent.confidence > borderline_absent.confidence, (
|
||||
f"Clearly absent ({clearly_absent.confidence}) should have higher "
|
||||
f"confidence than borderline absent ({borderline_absent.confidence})"
|
||||
)
|
||||
|
||||
def test_confidence_bounded_0_to_1(self):
|
||||
"""Confidence should always be in [0, 1]."""
|
||||
clf = PresenceClassifier()
|
||||
|
||||
for var in [0.0, 0.1, 1.0, 10.0, 100.0]:
|
||||
result = clf.classify(
|
||||
RssiFeatures(variance=var, motion_band_power=var, n_samples=100)
|
||||
)
|
||||
assert 0.0 <= result.confidence <= 1.0, (
|
||||
f"Confidence {result.confidence} out of bounds for var={var}"
|
||||
)
|
||||
|
||||
def test_cross_receiver_agreement_boosts_confidence(self):
|
||||
"""Matching results from other receivers should boost confidence."""
|
||||
clf = PresenceClassifier(presence_variance_threshold=0.5)
|
||||
features = RssiFeatures(variance=2.0, motion_band_power=0.0, n_samples=100)
|
||||
|
||||
result_solo = clf.classify(features)
|
||||
|
||||
# Other receivers also report PRESENT_STILL
|
||||
other = [
|
||||
SensingResult(
|
||||
motion_level=MotionLevel.PRESENT_STILL,
|
||||
confidence=0.8,
|
||||
presence_detected=True,
|
||||
rssi_variance=1.5,
|
||||
motion_band_energy=0.0,
|
||||
breathing_band_energy=0.0,
|
||||
n_change_points=0,
|
||||
)
|
||||
]
|
||||
result_agreed = clf.classify(features, other_receiver_results=other)
|
||||
|
||||
assert result_agreed.confidence >= result_solo.confidence
|
||||
|
||||
def test_result_dataclass_fields(self):
|
||||
"""SensingResult should contain all expected fields."""
|
||||
clf = PresenceClassifier()
|
||||
features = RssiFeatures(
|
||||
variance=1.0,
|
||||
motion_band_power=0.2,
|
||||
breathing_band_power=0.3,
|
||||
n_change_points=2,
|
||||
n_samples=100,
|
||||
)
|
||||
result = clf.classify(features)
|
||||
|
||||
assert hasattr(result, "motion_level")
|
||||
assert hasattr(result, "confidence")
|
||||
assert hasattr(result, "presence_detected")
|
||||
assert hasattr(result, "rssi_variance")
|
||||
assert hasattr(result, "motion_band_energy")
|
||||
assert hasattr(result, "breathing_band_energy")
|
||||
assert hasattr(result, "n_change_points")
|
||||
assert hasattr(result, "details")
|
||||
assert isinstance(result.details, str)
|
||||
assert len(result.details) > 0
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Backend tests
|
||||
# ===========================================================================
|
||||
|
||||
class TestCommodityBackend:
|
||||
def test_capabilities(self):
|
||||
"""CommodityBackend should only report PRESENCE and MOTION."""
|
||||
collector = SimulatedCollector(seed=0)
|
||||
backend = CommodityBackend(collector=collector)
|
||||
|
||||
caps = backend.get_capabilities()
|
||||
assert Capability.PRESENCE in caps
|
||||
assert Capability.MOTION in caps
|
||||
assert Capability.RESPIRATION not in caps
|
||||
assert Capability.LOCATION not in caps
|
||||
assert Capability.POSE not in caps
|
||||
|
||||
def test_is_capable(self):
|
||||
collector = SimulatedCollector(seed=0)
|
||||
backend = CommodityBackend(collector=collector)
|
||||
|
||||
assert backend.is_capable(Capability.PRESENCE) is True
|
||||
assert backend.is_capable(Capability.MOTION) is True
|
||||
assert backend.is_capable(Capability.RESPIRATION) is False
|
||||
assert backend.is_capable(Capability.POSE) is False
|
||||
|
||||
def test_protocol_conformance(self):
|
||||
"""CommodityBackend should satisfy the SensingBackend protocol."""
|
||||
collector = SimulatedCollector(seed=0)
|
||||
backend = CommodityBackend(collector=collector)
|
||||
assert isinstance(backend, SensingBackend)
|
||||
|
||||
def test_full_pipeline(self):
|
||||
"""
|
||||
End-to-end: SimulatedCollector -> features -> classification.
|
||||
|
||||
With a 0.3 Hz sine and some noise, the pipeline should detect
|
||||
presence (variance > threshold).
|
||||
"""
|
||||
collector = SimulatedCollector(
|
||||
seed=42,
|
||||
sample_rate_hz=10.0,
|
||||
baseline_dbm=-50.0,
|
||||
sine_freq_hz=0.3,
|
||||
sine_amplitude_dbm=3.0,
|
||||
noise_std_dbm=0.3,
|
||||
)
|
||||
backend = CommodityBackend(
|
||||
collector=collector,
|
||||
extractor=RssiFeatureExtractor(window_seconds=10.0),
|
||||
classifier=PresenceClassifier(
|
||||
presence_variance_threshold=0.5,
|
||||
motion_energy_threshold=0.1,
|
||||
),
|
||||
)
|
||||
|
||||
# Pre-fill the collector buffer with generated samples
|
||||
samples = collector.generate_samples(10.0)
|
||||
for s in samples:
|
||||
collector._buffer.append(s)
|
||||
|
||||
result = backend.get_result()
|
||||
features = backend.get_features()
|
||||
|
||||
# With amplitude 3 dBm, variance should be about 4.5
|
||||
assert features.variance > 0.5, (
|
||||
f"Expected variance > 0.5, got {features.variance}"
|
||||
)
|
||||
assert result.presence_detected is True
|
||||
assert result.motion_level in (MotionLevel.PRESENT_STILL, MotionLevel.ACTIVE)
|
||||
|
||||
def test_absent_with_constant_signal(self):
|
||||
"""
|
||||
A collector producing a near-constant signal should result in ABSENT.
|
||||
"""
|
||||
collector = SimulatedCollector(
|
||||
seed=0,
|
||||
sample_rate_hz=10.0,
|
||||
baseline_dbm=-50.0,
|
||||
sine_amplitude_dbm=0.0,
|
||||
noise_std_dbm=0.05, # very low noise
|
||||
)
|
||||
backend = CommodityBackend(
|
||||
collector=collector,
|
||||
extractor=RssiFeatureExtractor(window_seconds=10.0),
|
||||
classifier=PresenceClassifier(presence_variance_threshold=0.5),
|
||||
)
|
||||
|
||||
samples = collector.generate_samples(10.0)
|
||||
for s in samples:
|
||||
collector._buffer.append(s)
|
||||
|
||||
result = backend.get_result()
|
||||
assert result.motion_level == MotionLevel.ABSENT
|
||||
assert result.presence_detected is False
|
||||
|
||||
def test_repr(self):
|
||||
collector = SimulatedCollector(seed=0)
|
||||
backend = CommodityBackend(collector=collector)
|
||||
r = repr(backend)
|
||||
assert "CommodityBackend" in r
|
||||
assert "PRESENCE" in r
|
||||
assert "MOTION" in r
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Band power helper tests
|
||||
# ===========================================================================
|
||||
|
||||
class TestBandPower:
|
||||
def test_band_power_single_frequency(self):
|
||||
"""Power of a single frequency should concentrate in the correct band."""
|
||||
sample_rate = 10.0
|
||||
n = 300
|
||||
t = np.arange(n) / sample_rate
|
||||
signal = 5.0 * np.sin(2 * np.pi * 0.3 * t)
|
||||
|
||||
# Apply window and compute FFT
|
||||
window = np.hanning(n)
|
||||
windowed = signal * window
|
||||
from scipy import fft as scipy_fft
|
||||
fft_vals = scipy_fft.rfft(windowed)
|
||||
freqs = scipy_fft.rfftfreq(n, d=1.0 / sample_rate)
|
||||
psd = (np.abs(fft_vals) ** 2) / n
|
||||
|
||||
# Skip DC
|
||||
freqs_no_dc = freqs[1:]
|
||||
psd_no_dc = psd[1:]
|
||||
|
||||
breathing = _band_power(freqs_no_dc, psd_no_dc, 0.1, 0.5)
|
||||
motion = _band_power(freqs_no_dc, psd_no_dc, 0.5, 3.0)
|
||||
|
||||
assert breathing > motion, (
|
||||
f"0.3 Hz signal should have more breathing band power ({breathing}) "
|
||||
f"than motion band power ({motion})"
|
||||
)
|
||||
|
||||
def test_band_power_zero_for_empty_band(self):
|
||||
"""Band with no frequency content should return ~0 power."""
|
||||
freqs = np.array([0.1, 0.2, 0.3, 0.4, 0.5])
|
||||
psd = np.array([1.0, 0.0, 0.0, 0.0, 1.0])
|
||||
|
||||
# Band 0.21-0.39 has no power
|
||||
p = _band_power(freqs, psd, 0.21, 0.39)
|
||||
assert p == 0.0
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# LinuxWifiCollector.is_available() tests (ADR-049)
|
||||
# ===========================================================================
|
||||
|
||||
from unittest.mock import patch, mock_open
|
||||
from v1.src.sensing.rssi_collector import LinuxWifiCollector, create_collector
|
||||
|
||||
|
||||
class TestLinuxWifiCollectorAvailability:
|
||||
def test_unavailable_when_proc_missing(self):
|
||||
"""is_available returns False when /proc/net/wireless doesn't exist."""
|
||||
with patch("v1.src.sensing.rssi_collector.os.path.exists", return_value=False):
|
||||
available, reason = LinuxWifiCollector.is_available("wlan0")
|
||||
assert available is False
|
||||
assert "/proc/net/wireless not found" in reason
|
||||
|
||||
def test_unavailable_when_interface_not_listed(self):
|
||||
"""is_available returns False when the interface isn't in proc."""
|
||||
proc_content = (
|
||||
"Inter-| sta-| Quality | Discarded packets\n"
|
||||
" face | tus | link level noise | nwid crypt frag retry misc\n"
|
||||
" wlan1: 0000 60. -50. -95. 0 0 0 0 0\n"
|
||||
)
|
||||
with patch("v1.src.sensing.rssi_collector.os.path.exists", return_value=True):
|
||||
with patch("builtins.open", mock_open(read_data=proc_content)):
|
||||
available, reason = LinuxWifiCollector.is_available("wlan0")
|
||||
assert available is False
|
||||
assert "wlan0" in reason
|
||||
assert "wlan1" in reason
|
||||
|
||||
def test_available_when_interface_listed(self):
|
||||
"""is_available returns True when the interface is present."""
|
||||
proc_content = (
|
||||
"Inter-| sta-| Quality | Discarded packets\n"
|
||||
" face | tus | link level noise | nwid crypt frag retry misc\n"
|
||||
" wlan0: 0000 60. -50. -95. 0 0 0 0 0\n"
|
||||
)
|
||||
with patch("v1.src.sensing.rssi_collector.os.path.exists", return_value=True):
|
||||
with patch("builtins.open", mock_open(read_data=proc_content)):
|
||||
available, reason = LinuxWifiCollector.is_available("wlan0")
|
||||
assert available is True
|
||||
assert reason == "ok"
|
||||
|
||||
def test_unavailable_when_file_unreadable(self):
|
||||
"""is_available returns False when /proc/net/wireless exists but can't be read."""
|
||||
with patch("v1.src.sensing.rssi_collector.os.path.exists", return_value=True):
|
||||
with patch("builtins.open", side_effect=PermissionError("Permission denied")):
|
||||
available, reason = LinuxWifiCollector.is_available("wlan0")
|
||||
assert available is False
|
||||
assert "Cannot read" in reason
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# create_collector() factory tests (ADR-049)
|
||||
# ===========================================================================
|
||||
|
||||
class TestCreateCollector:
|
||||
def test_returns_simulated_when_no_wifi(self):
|
||||
"""On Linux without /proc/net/wireless, should return SimulatedCollector."""
|
||||
with patch("v1.src.sensing.rssi_collector.platform.system", return_value="Linux"):
|
||||
with patch("v1.src.sensing.rssi_collector.os.path.exists", return_value=False):
|
||||
collector = create_collector(preferred="auto")
|
||||
assert isinstance(collector, SimulatedCollector)
|
||||
|
||||
def test_returns_simulated_for_explicit_preference(self):
|
||||
"""preferred='simulated' always returns SimulatedCollector."""
|
||||
collector = create_collector(preferred="simulated")
|
||||
assert isinstance(collector, SimulatedCollector)
|
||||
|
||||
def test_returns_linux_collector_when_available(self):
|
||||
"""On Linux with /proc/net/wireless, should return LinuxWifiCollector."""
|
||||
proc_content = (
|
||||
"Inter-| sta-| Quality | Discarded packets\n"
|
||||
" face | tus | link level noise | nwid crypt frag retry misc\n"
|
||||
" wlan0: 0000 60. -50. -95. 0 0 0 0 0\n"
|
||||
)
|
||||
with patch("v1.src.sensing.rssi_collector.platform.system", return_value="Linux"):
|
||||
with patch("v1.src.sensing.rssi_collector.os.path.exists", return_value=True):
|
||||
with patch("builtins.open", mock_open(read_data=proc_content)):
|
||||
collector = create_collector(preferred="auto", interface="wlan0")
|
||||
assert isinstance(collector, LinuxWifiCollector)
|
||||
|
||||
def test_never_raises(self):
|
||||
"""create_collector should never raise, regardless of platform."""
|
||||
for plat in ["Linux", "Windows", "Darwin", "FreeBSD", "SunOS"]:
|
||||
with patch("v1.src.sensing.rssi_collector.platform.system", return_value=plat):
|
||||
with patch("v1.src.sensing.rssi_collector.os.path.exists", return_value=False):
|
||||
with patch("subprocess.run", side_effect=FileNotFoundError("not found")):
|
||||
try:
|
||||
collector = create_collector(preferred="auto")
|
||||
assert collector is not None
|
||||
except Exception as exc:
|
||||
pytest.fail(f"create_collector raised on {plat}: {exc}")
|
||||
|
||||
def test_windows_default_interface_mapping(self):
|
||||
"""On Windows with default interface='wlan0', should map to 'Wi-Fi'."""
|
||||
with patch("v1.src.sensing.rssi_collector.platform.system", return_value="Windows"):
|
||||
with patch("subprocess.run", side_effect=FileNotFoundError("netsh not found")):
|
||||
collector = create_collector(preferred="auto", interface="wlan0")
|
||||
# Should fall back to SimulatedCollector since netsh isn't available
|
||||
assert isinstance(collector, SimulatedCollector)
|
||||
@@ -0,0 +1,68 @@
|
||||
"""Tests for StreamService."""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, AsyncMock, patch
|
||||
|
||||
|
||||
class TestStreamServiceLifecycle:
|
||||
def test_init(self, mock_settings, mock_domain_config):
|
||||
from src.services.stream_service import StreamService
|
||||
svc = StreamService(mock_settings, mock_domain_config)
|
||||
assert svc.is_running is False
|
||||
assert len(svc.connections) == 0
|
||||
assert svc.stats["active_connections"] == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_initialize(self, mock_settings, mock_domain_config):
|
||||
from src.services.stream_service import StreamService
|
||||
svc = StreamService(mock_settings, mock_domain_config)
|
||||
await svc.initialize()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start(self, mock_settings, mock_domain_config):
|
||||
mock_settings.enable_real_time_processing = False
|
||||
from src.services.stream_service import StreamService
|
||||
svc = StreamService(mock_settings, mock_domain_config)
|
||||
await svc.start()
|
||||
assert svc.is_running is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop(self, mock_settings, mock_domain_config):
|
||||
mock_settings.enable_real_time_processing = False
|
||||
from src.services.stream_service import StreamService
|
||||
svc = StreamService(mock_settings, mock_domain_config)
|
||||
await svc.start()
|
||||
await svc.stop()
|
||||
assert svc.is_running is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_double_start(self, mock_settings, mock_domain_config):
|
||||
mock_settings.enable_real_time_processing = False
|
||||
from src.services.stream_service import StreamService
|
||||
svc = StreamService(mock_settings, mock_domain_config)
|
||||
await svc.start()
|
||||
await svc.start() # should be idempotent
|
||||
assert svc.is_running is True
|
||||
|
||||
|
||||
class TestStreamServiceConnections:
|
||||
def test_no_connections_on_init(self, mock_settings, mock_domain_config):
|
||||
from src.services.stream_service import StreamService
|
||||
svc = StreamService(mock_settings, mock_domain_config)
|
||||
assert svc.stats["total_connections"] == 0
|
||||
assert svc.stats["messages_sent"] == 0
|
||||
|
||||
def test_buffer_sizes(self, mock_settings, mock_domain_config):
|
||||
mock_settings.stream_buffer_size = 50
|
||||
from src.services.stream_service import StreamService
|
||||
svc = StreamService(mock_settings, mock_domain_config)
|
||||
assert svc.pose_buffer.maxlen == 50
|
||||
assert svc.csi_buffer.maxlen == 50
|
||||
|
||||
|
||||
class TestStreamServiceBroadcast:
|
||||
def test_stats_messages_failed_init_zero(self, mock_settings, mock_domain_config):
|
||||
from src.services.stream_service import StreamService
|
||||
svc = StreamService(mock_settings, mock_domain_config)
|
||||
assert svc.stats["messages_failed"] == 0
|
||||
assert svc.stats["data_points_streamed"] == 0
|
||||
Reference in New Issue
Block a user