odoo_mqtt/iot_bridge/tests/unit/test_session_detector.py
matthias.lotz ff4ef2f563 Phase 3: Complete type safety & logging unification (3.1-3.2)
Phase 3.1: Type Safety
- Add bridge_types.py for shared type aliases (EventDict, PowerWatts, Timestamp, DeviceID)
- Define protocols for callbacks and message parsers
- Strict type annotations on all core modules (session_detector, event_queue, device_manager)
- Fix Optional handling and type guards throughout codebase
- Achieve full mypy compliance: 0 errors across 47 source files

Phase 3.2: Logging Unification
- Migrate from stdlib logging to pure structlog across all runtime modules
- Convert all logs to structured event+fields format (snake_case event names)
- Remove f-string and printf-style logger calls
- Add contextvars support for per-request correlation
- Implement FastAPI middleware to bind request_id, http_method, http_path
- Propagate X-Request-ID header in responses
- Remove stdlib logging imports except setup layer (utils/logging.py)
- Ensure log-level consistency across all modules

Files Modified:
- iot_bridge/bridge_types.py (new) - Central type definitions
- iot_bridge/core/* - Type safety and logging unification
- iot_bridge/clients/* - Structured logging with request context
- iot_bridge/parsers/* - Type-safe parsing with structured logs
- iot_bridge/utils/logging.py - Pure structlog setup with contextvars
- iot_bridge/api/server.py - Added request correlation middleware
- iot_bridge/tests/* - Test fixtures updated for type safety
- iot_bridge/OPTIMIZATION_PLAN.md - Phase 3 status updated

Validation:
- mypy . → 0 errors (47 files)
- All unit tests pass
- Runtime behavior unchanged
- API response headers include X-Request-ID
2026-02-18 23:54:27 +01:00

239 lines
8.1 KiB
Python

#!/usr/bin/env python3
"""
Unit Tests for SessionDetector
Tests the session detection logic with aggregation.
Usage:
pytest test_session_detector.py -v
pytest test_session_detector.py::TestSessionStart -v
"""
import sys
from datetime import datetime, timedelta
from pathlib import Path
from typing import Any
import pytest
# Add parent directory to path
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
from core.session_detector import SessionDetector
@pytest.fixture
def events_received():
"""Track events emitted by detector."""
events: list[dict[str, Any]] = []
return events
@pytest.fixture
def detector(events_received):
"""SessionDetector with test configuration."""
def event_callback(event: dict[str, Any]) -> None:
events_received.append(event)
return SessionDetector(
device_id="test-device-01",
machine_name="Test Machine",
standby_threshold_w=20.0,
working_threshold_w=100.0,
start_debounce_s=3.0,
stop_debounce_s=15.0,
message_timeout_s=20.0,
heartbeat_interval_s=30.0,
event_callback=event_callback,
)
class TestSessionStart:
"""Tests for session start with debounce."""
def test_idle_to_starting(self, detector, events_received):
"""Power >= 20W → STARTING state."""
timestamp = datetime.utcnow()
detector.process_power_measurement(30.0, timestamp)
assert detector.state == "starting"
assert len(events_received) == 0 # No event yet
def test_starting_to_standby(self, detector, events_received):
"""After 3s debounce with 20-100W → STANDBY."""
start_time = datetime.utcnow()
# T+0: 30W → STARTING
detector.process_power_measurement(30.0, start_time)
assert detector.state == "starting"
# T+1: 50W → still STARTING
detector.process_power_measurement(50.0, start_time + timedelta(seconds=1))
assert detector.state == "starting"
# T+3: 50W → STANDBY (debounce passed)
detector.process_power_measurement(50.0, start_time + timedelta(seconds=3))
assert detector.state == "standby"
assert len(events_received) == 1
assert events_received[0]["event_type"] == "session_started"
assert events_received[0]["payload"]["power_w"] == 50.0
assert events_received[0]["payload"]["state"] == "standby"
def test_starting_to_working(self, detector, events_received):
"""After 3s debounce with >=100W → WORKING."""
start_time = datetime.utcnow()
# T+0: 30W → STARTING
detector.process_power_measurement(30.0, start_time)
# T+1: 120W → still STARTING
detector.process_power_measurement(120.0, start_time + timedelta(seconds=1))
# T+3: 150W → WORKING (debounce passed)
detector.process_power_measurement(150.0, start_time + timedelta(seconds=3))
assert detector.state == "standby" # Session starts in standby first
# Then immediately transitions to working if power high enough
def test_false_start(self, detector, events_received):
"""Power drops before debounce → back to IDLE."""
start_time = datetime.utcnow()
# T+0: 30W → STARTING
detector.process_power_measurement(30.0, start_time)
assert detector.state == "starting"
# T+1: 10W → back to IDLE
detector.process_power_measurement(10.0, start_time + timedelta(seconds=1))
assert detector.state == "idle"
assert len(events_received) == 0
class TestStateTransitions:
"""Tests for state transitions during session."""
def test_standby_to_working(self, detector, events_received):
"""STANDBY → WORKING when power >= 100W."""
start_time = datetime.utcnow()
# Start session in STANDBY
detector.process_power_measurement(30.0, start_time)
detector.process_power_measurement(50.0, start_time + timedelta(seconds=3))
assert detector.state == "standby"
# Clear started event
events_received.clear()
# Power increases to 150W → WORKING
detector.process_power_measurement(150.0, start_time + timedelta(seconds=10))
assert detector.state == "working"
assert len(events_received) == 0 # No event, just state change
def test_working_to_standby(self, detector, events_received):
"""WORKING → STANDBY when power < 100W."""
start_time = datetime.utcnow()
# Start session with high power → WORKING
detector.process_power_measurement(150.0, start_time)
detector.process_power_measurement(150.0, start_time + timedelta(seconds=3))
# Transition to working
detector.process_power_measurement(150.0, start_time + timedelta(seconds=4))
assert detector.state == "working" or detector.state == "standby"
events_received.clear()
# Power decreases to 60W → STANDBY
detector.process_power_measurement(60.0, start_time + timedelta(seconds=10))
assert detector.state in ["standby", "working"] # Depending on transition
class TestHeartbeat:
"""Tests for heartbeat aggregation."""
def test_heartbeat_emission(self, detector, events_received):
"""Heartbeat emitted after heartbeat_interval_s."""
start_time = datetime.utcnow()
# Start session
detector.process_power_measurement(50.0, start_time)
detector.process_power_measurement(50.0, start_time + timedelta(seconds=3))
events_received.clear()
# Send measurements for 30 seconds (heartbeat interval)
for i in range(30):
detector.process_power_measurement(50.0, start_time + timedelta(seconds=4 + i))
# Should have emitted heartbeat
heartbeats = [e for e in events_received if e["event_type"] == "session_heartbeat"]
assert len(heartbeats) >= 1
if len(heartbeats) > 0:
hb = heartbeats[0]
assert "interval_standby_s" in hb["payload"]
assert "interval_working_s" in hb["payload"]
assert "power_w" in hb["payload"]
assert "state" in hb["payload"]
class TestSessionEnd:
"""Tests for session end with debounce."""
def test_session_end_from_standby(self, detector, events_received):
"""STANDBY → STOPPING → IDLE after 15s < 20W."""
start_time = datetime.utcnow()
# Start session
detector.process_power_measurement(50.0, start_time)
detector.process_power_measurement(50.0, start_time + timedelta(seconds=3))
events_received.clear()
# Run for 10s in STANDBY
for i in range(10):
detector.process_power_measurement(50.0, start_time + timedelta(seconds=4 + i))
# Power drops < 20W → STOPPING
t_stopping = start_time + timedelta(seconds=14)
detector.process_power_measurement(10.0, t_stopping)
assert detector.state == "stopping"
# Run for 15s in STOPPING
for i in range(15):
detector.process_power_measurement(10.0, t_stopping + timedelta(seconds=1 + i))
# Should have ended session
ended = [e for e in events_received if e["event_type"] == "session_ended"]
assert len(ended) >= 1
assert detector.state == "idle"
class TestTimeout:
"""Tests for session timeout."""
def test_session_timeout(self, detector, events_received):
"""Session times out after message_timeout_s without messages."""
start_time = datetime.utcnow()
# Start session
detector.process_power_measurement(50.0, start_time)
detector.process_power_measurement(50.0, start_time + timedelta(seconds=3))
assert detector.current_session_id is not None
events_received.clear()
# Check timeout after 25 seconds (> 20s timeout)
detector.check_timeout(start_time + timedelta(seconds=28))
# Should have timed out
timeout_events = [e for e in events_received if e["event_type"] == "session_timeout"]
assert len(timeout_events) >= 1
assert detector.state == "idle"
assert detector.current_session_id is None