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:
2026-01-23 20:46:48 +01:00
parent 29eef70dc8
commit 5287baadbb
6 changed files with 521 additions and 7 deletions

View 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);
}
}