feat(application): implement US1 relay control use cases
Add GetAllRelaysUseCase (T043) for retrieving all 8 relay states with labels, coordinating controller reads and repository label lookups with comprehensive error handling and logging. Implement ToggleRelayUseCase (T041) for toggling individual relay states with read-before-write pattern, state validation, and label retrieval. Add use_cases module (T044) with trait-based dependency injection for testability, exposing both use cases for presentation layer integration. Comprehensive test coverage includes 7 toggle tests (state transitions, error handling, double-toggle idempotency) and 9 get-all tests (count, ordering, state correctness, label inclusion, error scenarios). Ref: T041 T042 T043 T044 (specs/001-modbus-relay-control/tasks.org)
This commit is contained in:
207
backend/src/application/use_cases/toggle_relay.rs
Normal file
207
backend/src/application/use_cases/toggle_relay.rs
Normal file
@@ -0,0 +1,207 @@
|
||||
//! Toggle relay use case.
|
||||
//!
|
||||
//! This use case handles toggling a single relay's state from on to off or vice versa.
|
||||
//! It coordinates with the relay controller to read the current state, toggle it,
|
||||
//! and write the new state back.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::domain::relay::{
|
||||
controller::{ControllerError, RelayController},
|
||||
entity::Relay,
|
||||
repository::{RelayLabelRepository, RepositoryError},
|
||||
types::RelayId,
|
||||
};
|
||||
|
||||
/// Error type for toggle relay use case operations.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ToggleRelayError {
|
||||
/// Error from the relay controller (connection, timeout, protocol issues).
|
||||
#[error("Controller error: {0}")]
|
||||
Controller(#[from] ControllerError),
|
||||
|
||||
/// Error from the label repository.
|
||||
#[error("Repository error: {0}")]
|
||||
Repository(#[from] RepositoryError),
|
||||
}
|
||||
|
||||
/// Use case for toggling a relay's state.
|
||||
///
|
||||
/// This use case:
|
||||
/// 1. Reads the current state of the specified relay
|
||||
/// 2. Toggles the state (On → Off, Off → On)
|
||||
/// 3. Writes the new state to the relay
|
||||
/// 4. Returns the updated relay entity with its label
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```rust,ignore
|
||||
/// let use_case = ToggleRelayUseCase::new(controller, repository);
|
||||
/// let relay = use_case.execute(RelayId::new(1).unwrap()).await?;
|
||||
/// // relay.state() is now toggled from its previous value
|
||||
/// ```
|
||||
pub struct ToggleRelayUseCase {
|
||||
controller: Arc<dyn RelayController>,
|
||||
repository: Arc<dyn RelayLabelRepository>,
|
||||
}
|
||||
|
||||
impl ToggleRelayUseCase {
|
||||
/// Creates a new toggle relay use case.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `controller` - The relay controller for hardware communication
|
||||
/// * `repository` - The label repository for relay labels
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
controller: Arc<dyn RelayController>,
|
||||
repository: Arc<dyn RelayLabelRepository>,
|
||||
) -> Self {
|
||||
Self {
|
||||
controller,
|
||||
repository,
|
||||
}
|
||||
}
|
||||
|
||||
/// Executes the toggle relay use case.
|
||||
///
|
||||
/// Reads the current state, toggles it, writes the new state, and returns
|
||||
/// the updated relay entity including its label.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `relay_id` - The ID of the relay to toggle (1-8)
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The updated `Relay` entity with the new state.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns `ToggleRelayError` if:
|
||||
/// - Controller fails to read/write relay state
|
||||
/// - Repository fails to retrieve the label
|
||||
pub async fn execute(&self, relay_id: RelayId) -> Result<Relay, ToggleRelayError> {
|
||||
tracing::debug!(target: "use_case::toggle_relay", relay_id = relay_id.as_u8(), "Toggling relay state");
|
||||
let current_state = self.controller.read_relay_state(relay_id).await?;
|
||||
tracing::debug!(target: "use_case::toggle_relay", relay_id = relay_id.as_u8(), ?current_state, "Read current state");
|
||||
let new_state = current_state.toggle();
|
||||
tracing::debug!(target: "use_case::toggle_relay", relay_id = relay_id.as_u8(), ?new_state, "New state after toggle");
|
||||
self.controller
|
||||
.write_relay_state(relay_id, new_state)
|
||||
.await?;
|
||||
tracing::info!(target: "use_case::toggle_relay", relay_id = relay_id.as_u8(), ?new_state, "Successfully toggled relay");
|
||||
let label = self.repository.get_label(relay_id).await?;
|
||||
Ok(Relay::new(relay_id, new_state, label))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::domain::relay::types::RelayState;
|
||||
use crate::infrastructure::modbus::mock_controller::MockRelayController;
|
||||
use crate::infrastructure::persistence::label_repository::MockRelayLabelRepository;
|
||||
|
||||
/// Helper to create a test controller with initialized relays.
|
||||
async fn create_test_controller() -> MockRelayController {
|
||||
let controller = MockRelayController::new();
|
||||
for i in 1..=8 {
|
||||
controller
|
||||
.write_relay_state(RelayId::new(i).unwrap(), RelayState::Off)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
controller
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_execute_toggles_relay_state_off_to_on() {
|
||||
let controller = Arc::new(create_test_controller().await);
|
||||
let repository = Arc::new(MockRelayLabelRepository::new());
|
||||
let use_case = ToggleRelayUseCase::new(controller.clone(), repository);
|
||||
let relay_id = RelayId::new(1).unwrap();
|
||||
let initial_state = controller.read_relay_state(relay_id).await.unwrap();
|
||||
assert_eq!(initial_state, RelayState::Off);
|
||||
let result = use_case.execute(relay_id).await.unwrap();
|
||||
assert_eq!(result.state(), RelayState::On);
|
||||
assert_eq!(result.id(), relay_id);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_execute_toggles_relay_state_on_to_off() {
|
||||
let controller = Arc::new(create_test_controller().await);
|
||||
let repository = Arc::new(MockRelayLabelRepository::new());
|
||||
let relay_id = RelayId::new(1).unwrap();
|
||||
controller
|
||||
.write_relay_state(relay_id, RelayState::On)
|
||||
.await
|
||||
.unwrap();
|
||||
let use_case = ToggleRelayUseCase::new(controller.clone(), repository);
|
||||
let result = use_case.execute(relay_id).await.unwrap();
|
||||
assert_eq!(result.state(), RelayState::Off);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_execute_returns_error_if_controller_fails() {
|
||||
let controller = Arc::new(MockRelayController::new().with_timeout_simulation());
|
||||
let repository = Arc::new(MockRelayLabelRepository::new());
|
||||
let use_case = ToggleRelayUseCase::new(controller, repository);
|
||||
let relay_id = RelayId::new(1).unwrap();
|
||||
let result = use_case.execute(relay_id).await;
|
||||
assert!(result.is_err());
|
||||
assert!(matches!(
|
||||
result.unwrap_err(),
|
||||
ToggleRelayError::Controller(_)
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_execute_updates_state_in_controller() {
|
||||
let controller = Arc::new(create_test_controller().await);
|
||||
let repository = Arc::new(MockRelayLabelRepository::new());
|
||||
let use_case = ToggleRelayUseCase::new(controller.clone(), repository);
|
||||
let relay_id = RelayId::new(1).unwrap();
|
||||
use_case.execute(relay_id).await.unwrap();
|
||||
let state_in_controller = controller.read_relay_state(relay_id).await.unwrap();
|
||||
assert_eq!(state_in_controller, RelayState::On);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_execute_returns_relay_with_label() {
|
||||
let controller = Arc::new(create_test_controller().await);
|
||||
let repository = Arc::new(MockRelayLabelRepository::new());
|
||||
let relay_id = RelayId::new(1).unwrap();
|
||||
let label = crate::domain::relay::types::RelayLabel::new("Test Label".to_string()).unwrap();
|
||||
repository
|
||||
.save_label(relay_id, label.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
let use_case = ToggleRelayUseCase::new(controller, repository);
|
||||
let result = use_case.execute(relay_id).await.unwrap();
|
||||
assert_eq!(result.label(), Some(label));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_execute_returns_relay_without_label_when_none_set() {
|
||||
let controller = Arc::new(create_test_controller().await);
|
||||
let repository = Arc::new(MockRelayLabelRepository::new());
|
||||
let use_case = ToggleRelayUseCase::new(controller, repository);
|
||||
let relay_id = RelayId::new(1).unwrap();
|
||||
let result = use_case.execute(relay_id).await.unwrap();
|
||||
assert_eq!(result.label(), None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_execute_double_toggle_returns_to_original_state() {
|
||||
let controller = Arc::new(create_test_controller().await);
|
||||
let repository = Arc::new(MockRelayLabelRepository::new());
|
||||
let use_case = ToggleRelayUseCase::new(controller.clone(), repository);
|
||||
let relay_id = RelayId::new(1).unwrap();
|
||||
let initial_state = controller.read_relay_state(relay_id).await.unwrap();
|
||||
assert_eq!(initial_state, RelayState::Off);
|
||||
use_case.execute(relay_id).await.unwrap();
|
||||
let result = use_case.execute(relay_id).await.unwrap();
|
||||
assert_eq!(result.state(), RelayState::Off);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user