Files
sta/backend/src/infrastructure/modbus/mock_controller.rs
Lucien Cartier-Tilet e8e6a1e702 feat(domain): implement RelayController trait and error handling
Add RelayController async trait (T030) defining interface for Modbus
relay operations with methods for read/write state, bulk operations,
connection checks, and firmware queries.

Implement ControllerError enum (T031) with variants for connection
failures, timeouts, Modbus exceptions, and invalid relay IDs.

Provide MockRelayController (T029) in-memory implementation using
Arc<Mutex<HashMap>> for thread-safe state storage with timeout
simulation for error handling tests.

Add RelayLabelRepository trait abstraction.

Ref: T029 T030 T031 (specs/001-modbus-relay-control/tasks.md)
2026-01-22 00:57:11 +01:00

300 lines
11 KiB
Rust

//! Mock relay controller for testing without hardware.
//!
//! This module provides a mock implementation of the relay controller
//! that stores state in memory, enabling testing without physical Modbus hardware.
use std::{
collections::HashMap,
sync::{Arc, Mutex, MutexGuard},
};
use async_trait::async_trait;
use crate::domain::relay::{
controller::{ControllerError, RelayController},
types::{RelayId, RelayState},
};
/// Mock relay controller for testing without physical Modbus hardware.
///
/// This implementation stores relay states in memory using a thread-safe
/// `Arc<Mutex<HashMap>>`, enabling concurrent access in tests. It provides
/// optional timeout simulation for testing error handling scenarios.
#[derive(Debug, Clone)]
pub struct MockRelayController {
states: Arc<Mutex<HashMap<RelayId, RelayState>>>,
firmware_version: Option<String>,
simulate_timeout: bool,
}
impl MockRelayController {
/// Creates a new mock relay controller with default configuration.
///
/// The controller initializes with:
/// - Empty relay state map (no relays configured)
/// - Firmware version set to "v2.00"
/// - Timeout simulation disabled
#[must_use]
pub fn new() -> Self {
Self::default()
}
/// Enables timeout simulation for testing error handling.
///
/// When enabled, all operations will simulate a 4-second delay followed
/// by a timeout error. This is useful for testing timeout handling in
/// application code.
#[must_use]
pub const fn with_timeout_simulation(mut self) -> Self {
self.simulate_timeout = true;
self
}
async fn maybe_timeout(&self) -> Result<(), ControllerError> {
if self.simulate_timeout {
tokio::time::sleep(tokio::time::Duration::from_secs(4)).await;
Err(ControllerError::Timeout(3))
} else {
Ok(())
}
}
fn states(&self) -> Result<MutexGuard<'_, HashMap<RelayId, RelayState>>, ControllerError> {
self.states
.lock()
.map_err(|e| ControllerError::ModbusException(e.to_string()))
}
}
impl Default for MockRelayController {
fn default() -> Self {
let hashmap: HashMap<RelayId, RelayState> = HashMap::new();
Self {
states: Arc::new(Mutex::new(hashmap)),
firmware_version: Some("v2.00".to_string()),
simulate_timeout: false,
}
}
}
#[async_trait]
impl RelayController for MockRelayController {
async fn read_relay_state(&self, id: RelayId) -> Result<RelayState, ControllerError> {
self.maybe_timeout().await?;
let states = self.states()?;
states.get(&id).map_or_else(
|| Err(ControllerError::InvalidRelayId(id.as_u8())),
|state| Ok(*state),
)
}
async fn write_relay_state(
&self,
id: RelayId,
state: RelayState,
) -> Result<(), ControllerError> {
self.maybe_timeout().await?;
self.states()?.insert(id, state);
Ok(())
}
async fn read_all_states(&self) -> Result<Vec<RelayState>, ControllerError> {
self.maybe_timeout().await?;
let mut vec: Vec<(RelayId, RelayState)> = self
.states()?
.iter()
.map(|(id, state)| (*id, *state))
.collect();
vec.sort_by_key(|v| v.0);
Ok(vec.iter().map(|v| v.1).collect())
}
async fn write_all_states(&self, states: Vec<RelayState>) -> Result<(), ControllerError> {
self.maybe_timeout().await?;
let mut keys: Vec<RelayId> = self.states()?.keys().copied().collect();
keys.sort();
for update in keys.iter().zip(states) {
self.states()?.insert(*update.0, update.1);
}
Ok(())
}
async fn check_connection(&self) -> Result<(), ControllerError> {
Ok(())
}
async fn get_firmware_version(&self) -> Result<Option<String>, ControllerError> {
Ok(self.firmware_version.clone())
}
}
#[cfg(test)]
mod tests {
use crate::domain::relay::types::RelayId;
// NOTE: These tests will fail until MockRelayController and RelayController trait are implemented (T029, T030)
// This follows TDD - write failing tests FIRST, then implement.
#[tokio::test]
async fn test_read_state_returns_mocked_state() {
// Test: read_state() returns mocked state
//
// Setup: Create a mock controller and set relay 1 to On
// Expected: read_state(1) should return On
todo!("Implement after MockRelayController exists (T029)");
// let controller = MockRelayController::new();
// let relay_id = RelayId::new(1).unwrap();
//
// // Write a known state
// controller.write_state(relay_id, RelayState::On).await.unwrap();
//
// // Read it back
// let state = controller.read_state(relay_id).await.unwrap();
// assert_eq!(state, RelayState::On);
}
#[tokio::test]
async fn test_write_state_updates_mocked_state() {
// Test: write_state() updates mocked state
//
// Setup: Create a mock controller (all relays default to Off)
// Action: Write relay 3 to On, then read it back
// Expected: State should be On
todo!("Implement after MockRelayController exists (T029)");
// let controller = MockRelayController::new();
// let relay_id = RelayId::new(3).unwrap();
//
// // Initial state should be Off
// let initial_state = controller.read_state(relay_id).await.unwrap();
// assert_eq!(initial_state, RelayState::Off);
//
// // Write On
// controller.write_state(relay_id, RelayState::On).await.unwrap();
//
// // Verify it changed
// let updated_state = controller.read_state(relay_id).await.unwrap();
// assert_eq!(updated_state, RelayState::On);
}
#[tokio::test]
async fn test_read_all_returns_8_relays_in_known_state() {
// Test: read_all() returns 8 relays in known state
//
// Setup: Create a mock controller, set relays 1, 3, 5 to On, others Off
// Action: Call read_all()
// Expected: Returns Vec of 8 (RelayId, RelayState) tuples in correct state
todo!("Implement after MockRelayController exists (T029)");
// let controller = MockRelayController::new();
//
// // Set specific relays to On
// controller.write_state(RelayId::new(1).unwrap(), RelayState::On).await.unwrap();
// controller.write_state(RelayId::new(3).unwrap(), RelayState::On).await.unwrap();
// controller.write_state(RelayId::new(5).unwrap(), RelayState::On).await.unwrap();
//
// // Read all states
// let all_states = controller.read_all().await.unwrap();
//
// // Verify we have exactly 8 relays
// assert_eq!(all_states.len(), 8);
//
// // Verify specific states
// assert_eq!(all_states[0], (RelayId::new(1).unwrap(), RelayState::On));
// assert_eq!(all_states[1], (RelayId::new(2).unwrap(), RelayState::Off));
// assert_eq!(all_states[2], (RelayId::new(3).unwrap(), RelayState::On));
// assert_eq!(all_states[3], (RelayId::new(4).unwrap(), RelayState::Off));
// assert_eq!(all_states[4], (RelayId::new(5).unwrap(), RelayState::On));
// assert_eq!(all_states[5], (RelayId::new(6).unwrap(), RelayState::Off));
// assert_eq!(all_states[6], (RelayId::new(7).unwrap(), RelayState::Off));
// assert_eq!(all_states[7], (RelayId::new(8).unwrap(), RelayState::Off));
}
#[tokio::test]
async fn test_write_state_for_all_8_relays() {
// Test: Can write state to all 8 relays independently
//
// Setup: Create a mock controller
// Action: Write different states to each relay
// Expected: Each relay maintains its own independent state
todo!("Implement after MockRelayController exists (T029)");
// let controller = MockRelayController::new();
//
// // Write alternating states (On, Off, On, Off, ...)
// for i in 1..=8 {
// let relay_id = RelayId::new(i).unwrap();
// let state = if i % 2 == 1 { RelayState::On } else { RelayState::Off };
// controller.write_state(relay_id, state).await.unwrap();
// }
//
// // Verify each relay has correct state
// for i in 1..=8 {
// let relay_id = RelayId::new(i).unwrap();
// let expected_state = if i % 2 == 1 { RelayState::On } else { RelayState::Off };
// let actual_state = controller.read_state(relay_id).await.unwrap();
// assert_eq!(actual_state, expected_state, "Relay {} has incorrect state", i);
// }
}
#[tokio::test]
async fn test_read_state_with_invalid_relay_id() {
// Test: read_state() with out-of-range relay ID fails gracefully
//
// Note: RelayId::new() will already fail for invalid IDs (0 or 9+),
// so this test verifies the type system prevents invalid relay IDs
// at construction time (type-driven design)
// Verify RelayId construction fails for invalid IDs
assert!(RelayId::new(0).is_err(), "RelayId::new(0) should fail");
assert!(RelayId::new(9).is_err(), "RelayId::new(9) should fail");
// If we somehow get an invalid relay ID through (which shouldn't be possible),
// the controller should handle it gracefully (tested in T029 implementation)
}
#[tokio::test]
async fn test_concurrent_access_is_safe() {
// Test: MockRelayController is thread-safe (uses Arc<Mutex<HashMap>>)
//
// Setup: Create mock controller, spawn multiple tasks that read/write
// Expected: No data races, all operations complete successfully
todo!("Implement after MockRelayController exists (T029)");
// use std::sync::Arc;
//
// let controller = Arc::new(MockRelayController::new());
//
// // Spawn 10 tasks that toggle relay 1
// let mut handles = vec![];
// for _ in 0..10 {
// let controller_clone = Arc::clone(&controller);
// let handle = tokio::spawn(async move {
// let relay_id = RelayId::new(1).unwrap();
// let current_state = controller_clone.read_state(relay_id).await.unwrap();
// let new_state = match current_state {
// RelayState::On => RelayState::Off,
// RelayState::Off => RelayState::On,
// };
// controller_clone.write_state(relay_id, new_state).await.unwrap();
// });
// handles.push(handle);
// }
//
// // Wait for all tasks to complete
// for handle in handles {
// handle.await.unwrap();
// }
//
// // Controller should still be in valid state (either On or Off)
// let final_state = controller.read_state(RelayId::new(1).unwrap()).await.unwrap();
// assert!(matches!(final_state, RelayState::On | RelayState::Off));
}
}