Replace 6 stubbed test implementations with fully functional tests that validate: - read_relay_state() returns correctly mocked state - write_relay_state() updates internal mocked state - read_all_states() returns 8 relays in known state - Independent relay state management for all 8 channel indices - Thread-safe concurrent state access with Arc<Mutex<>> Tests now pass after T029-T031 completed MockRelayController implementation. TDD phase: GREEN - tests validate implementation Ref: T032 (specs/001-modbus-relay-control/tasks.md)
348 lines
12 KiB
Rust
348 lines
12 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 super::MockRelayController;
|
|
use crate::domain::relay::types::RelayId;
|
|
|
|
#[tokio::test]
|
|
async fn test_read_state_returns_mocked_state() {
|
|
// Test: read_relay_state() returns mocked state
|
|
//
|
|
// Setup: Create a mock controller and set relay 1 to On
|
|
// Expected: read_relay_state(1) should return On
|
|
|
|
use crate::domain::relay::{controller::RelayController, types::RelayState};
|
|
|
|
let controller = MockRelayController::new();
|
|
let relay_id = RelayId::new(1).unwrap();
|
|
|
|
// Write a known state
|
|
controller
|
|
.write_relay_state(relay_id, RelayState::On)
|
|
.await
|
|
.unwrap();
|
|
|
|
// Read it back
|
|
let state = controller.read_relay_state(relay_id).await.unwrap();
|
|
assert_eq!(state, RelayState::On);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_write_state_updates_mocked_state() {
|
|
// Test: write_relay_state() updates mocked state
|
|
//
|
|
// Setup: Create a mock controller with relay 3 initialized to Off
|
|
// Action: Write relay 3 to On, then read it back
|
|
// Expected: State should be On
|
|
|
|
use crate::domain::relay::{controller::RelayController, types::RelayState};
|
|
|
|
let controller = MockRelayController::new();
|
|
let relay_id = RelayId::new(3).unwrap();
|
|
|
|
// Initialize relay 3 to Off
|
|
controller
|
|
.write_relay_state(relay_id, RelayState::Off)
|
|
.await
|
|
.unwrap();
|
|
|
|
// Verify initial state
|
|
let initial_state = controller.read_relay_state(relay_id).await.unwrap();
|
|
assert_eq!(initial_state, RelayState::Off);
|
|
|
|
// Write On
|
|
controller
|
|
.write_relay_state(relay_id, RelayState::On)
|
|
.await
|
|
.unwrap();
|
|
|
|
// Verify it changed
|
|
let updated_state = controller.read_relay_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_states() returns 8 relays in known state
|
|
//
|
|
// Setup: Create a mock controller, initialize all 8 relays, set relays 1, 3, 5 to On, others Off
|
|
// Action: Call read_all_states()
|
|
// Expected: Returns Vec of 8 RelayState values in correct order
|
|
|
|
use crate::domain::relay::{controller::RelayController, types::RelayState};
|
|
|
|
let controller = MockRelayController::new();
|
|
|
|
// Initialize all 8 relays to Off first
|
|
for i in 1..=8 {
|
|
controller
|
|
.write_relay_state(RelayId::new(i).unwrap(), RelayState::Off)
|
|
.await
|
|
.unwrap();
|
|
}
|
|
|
|
// Set specific relays to On
|
|
controller
|
|
.write_relay_state(RelayId::new(1).unwrap(), RelayState::On)
|
|
.await
|
|
.unwrap();
|
|
controller
|
|
.write_relay_state(RelayId::new(3).unwrap(), RelayState::On)
|
|
.await
|
|
.unwrap();
|
|
controller
|
|
.write_relay_state(RelayId::new(5).unwrap(), RelayState::On)
|
|
.await
|
|
.unwrap();
|
|
|
|
// Read all states
|
|
let all_states = controller.read_all_states().await.unwrap();
|
|
|
|
// Verify we have exactly 8 relays
|
|
assert_eq!(all_states.len(), 8);
|
|
|
|
// Verify specific states (indexed 0-7, corresponding to relays 1-8)
|
|
assert_eq!(all_states[0], RelayState::On); // Relay 1
|
|
assert_eq!(all_states[1], RelayState::Off); // Relay 2
|
|
assert_eq!(all_states[2], RelayState::On); // Relay 3
|
|
assert_eq!(all_states[3], RelayState::Off); // Relay 4
|
|
assert_eq!(all_states[4], RelayState::On); // Relay 5
|
|
assert_eq!(all_states[5], RelayState::Off); // Relay 6
|
|
assert_eq!(all_states[6], RelayState::Off); // Relay 7
|
|
assert_eq!(all_states[7], RelayState::Off); // Relay 8
|
|
}
|
|
|
|
#[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
|
|
|
|
use crate::domain::relay::{controller::RelayController, types::RelayState};
|
|
|
|
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_relay_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_relay_state(relay_id).await.unwrap();
|
|
assert_eq!(
|
|
actual_state, expected_state,
|
|
"Relay {i} has incorrect state",
|
|
);
|
|
}
|
|
}
|
|
|
|
#[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
|
|
|
|
use std::sync::Arc;
|
|
|
|
use crate::domain::relay::{controller::RelayController, types::RelayState};
|
|
|
|
let controller = Arc::new(MockRelayController::new());
|
|
let relay_id = RelayId::new(1).unwrap();
|
|
|
|
// Initialize relay 1 to Off
|
|
controller
|
|
.write_relay_state(relay_id, RelayState::Off)
|
|
.await
|
|
.unwrap();
|
|
|
|
// 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_relay_state(relay_id).await.unwrap();
|
|
let new_state = match current_state {
|
|
RelayState::On => RelayState::Off,
|
|
RelayState::Off => RelayState::On,
|
|
};
|
|
controller_clone
|
|
.write_relay_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_relay_state(relay_id).await.unwrap();
|
|
assert!(matches!(final_state, RelayState::On | RelayState::Off));
|
|
}
|
|
}
|