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:
rUv
2026-04-25 23:07:52 -04:00
committed by GitHub
parent 74233cfb23
commit 81cc241b9e
183 changed files with 290 additions and 216 deletions
+56
View File
@@ -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
+264
View File
@@ -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
+70
View File
@@ -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
+62
View File
@@ -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
+807
View File
@@ -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