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 27cfeb3b77
commit 7ce35da1ce
6 changed files with 521 additions and 7 deletions

View File

@@ -65,3 +65,4 @@
//! - Domain types: [`crate::domain`] - Domain entities and value objects //! - Domain types: [`crate::domain`] - Domain entities and value objects
pub mod health; pub mod health;
pub mod use_cases;

View File

@@ -0,0 +1,274 @@
//! Get all relays use case.
//!
//! This use case retrieves the current state of all 8 relays along with their labels.
//! It coordinates with the relay controller and label repository to provide a complete
//! view of all relay states.
use std::sync::Arc;
use crate::domain::relay::{
controller::{ControllerError, RelayController},
entity::Relay,
repository::{RelayLabelRepository, RepositoryError},
types::RelayId,
};
/// Error type for get all relays use case operations.
#[derive(Debug, thiserror::Error)]
pub enum GetAllRelaysError {
/// 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 retrieving the state of all 8 relays.
///
/// This use case:
/// 1. Reads the states of all 8 relays from the controller
/// 2. Retrieves labels for all relays from the repository
/// 3. Combines the data into a vector of Relay entities
///
/// # Example
///
/// ```rust,ignore
/// let use_case = GetAllRelaysUseCase::new(controller, repository);
/// let relays = use_case.execute().await?;
/// // relays contains all 8 relay entities with their states and labels
/// ```
pub struct GetAllRelaysUseCase {
controller: Arc<dyn RelayController>,
repository: Arc<dyn RelayLabelRepository>,
}
impl GetAllRelaysUseCase {
/// Creates a new get all relays 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 get all relays use case.
///
/// Reads all relay states and labels, returning a complete list of relay entities.
///
/// # Returns
///
/// A vector of 8 `Relay` entities ordered by relay ID (1-8).
///
/// # Errors
///
/// Returns `GetAllRelaysError` if:
/// - Controller fails to read relay states
/// - Repository fails to retrieve labels
pub async fn execute(&self) -> Result<Vec<Relay>, GetAllRelaysError> {
tracing::debug!(target: "use_case::get_all_relays", "Reading all relay states");
let states = self.controller.read_all_states().await?;
tracing::debug!(target: "use_case::get_all_relays", relay_count = states.len(), "Read relay states");
let labels = self.repository.get_all_labels().await?;
tracing::debug!(target: "use_case::get_all_relays", label_count = labels.len(), "Read relay labels");
let label_map: std::collections::HashMap<u8, _> = labels
.into_iter()
.map(|(id, label)| (id.as_u8(), label))
.collect();
let relays: Vec<Relay> = states
.into_iter()
.enumerate()
.filter_map(|(index, state)| {
// RelayId is 1-indexed
let relay_num = u8::try_from(index + 1).ok()?;
let relay_id = RelayId::new(relay_num).ok()?;
let label = label_map.get(&relay_num).cloned();
Some(Relay::new(relay_id, state, label))
})
.collect();
tracing::info!(target: "use_case::get_all_relays", relay_count = relays.len(), "Successfully retrieved all relays");
Ok(relays)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::relay::types::{RelayLabel, RelayState};
use crate::infrastructure::modbus::mock_controller::MockRelayController;
use crate::infrastructure::persistence::label_repository::MockRelayLabelRepository;
/// Helper to create a test controller with all 8 relays initialized to Off.
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_returns_all_8_relays() {
let controller = Arc::new(create_test_controller().await);
let repository = Arc::new(MockRelayLabelRepository::new());
let use_case = GetAllRelaysUseCase::new(controller, repository);
let result = use_case.execute().await.unwrap();
assert_eq!(result.len(), 8);
}
#[tokio::test]
async fn test_execute_returns_relays_ordered_by_id() {
let controller = Arc::new(create_test_controller().await);
let repository = Arc::new(MockRelayLabelRepository::new());
let use_case = GetAllRelaysUseCase::new(controller, repository);
let result = use_case.execute().await.unwrap();
for (index, relay) in result.iter().enumerate() {
let expected_id = u8::try_from(index + 1).unwrap();
assert_eq!(relay.id().as_u8(), expected_id);
}
}
#[tokio::test]
async fn test_execute_returns_correct_states() {
let controller = Arc::new(create_test_controller().await);
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();
let repository = Arc::new(MockRelayLabelRepository::new());
let use_case = GetAllRelaysUseCase::new(controller, repository);
let result = use_case.execute().await.unwrap();
assert_eq!(result[0].state(), RelayState::On);
assert_eq!(result[1].state(), RelayState::Off);
assert_eq!(result[2].state(), RelayState::On);
assert_eq!(result[3].state(), RelayState::Off);
assert_eq!(result[4].state(), RelayState::On);
assert_eq!(result[5].state(), RelayState::Off);
assert_eq!(result[6].state(), RelayState::Off);
assert_eq!(result[7].state(), RelayState::Off);
}
#[tokio::test]
async fn test_execute_includes_labels_when_present() {
let controller = Arc::new(create_test_controller().await);
let repository = Arc::new(MockRelayLabelRepository::new());
let label1 = RelayLabel::new("Pump".to_string()).unwrap();
let label3 = RelayLabel::new("Heater".to_string()).unwrap();
repository
.save_label(RelayId::new(1).unwrap(), label1.clone())
.await
.unwrap();
repository
.save_label(RelayId::new(3).unwrap(), label3.clone())
.await
.unwrap();
let use_case = GetAllRelaysUseCase::new(controller, repository);
let result = use_case.execute().await.unwrap();
assert_eq!(result[0].label(), Some(label1));
assert_eq!(result[1].label(), None);
assert_eq!(result[2].label(), Some(label3));
}
#[tokio::test]
async fn test_execute_returns_none_label_when_not_set() {
let controller = Arc::new(create_test_controller().await);
let repository = Arc::new(MockRelayLabelRepository::new());
let use_case = GetAllRelaysUseCase::new(controller, repository);
let result = use_case.execute().await.unwrap();
for relay in &result {
assert_eq!(relay.label(), None);
}
}
#[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 = GetAllRelaysUseCase::new(controller, repository);
let result = use_case.execute().await;
assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
GetAllRelaysError::Controller(_)
));
}
#[tokio::test]
async fn test_execute_each_relay_has_id() {
let controller = Arc::new(create_test_controller().await);
let repository = Arc::new(MockRelayLabelRepository::new());
let use_case = GetAllRelaysUseCase::new(controller, repository);
let result = use_case.execute().await.unwrap();
assert_eq!(result.len(), 8);
for relay in result {
let id = relay.id().as_u8();
assert!((1..=8).contains(&id));
}
}
#[tokio::test]
async fn test_execute_each_relay_has_state() {
let controller = Arc::new(create_test_controller().await);
let repository = Arc::new(MockRelayLabelRepository::new());
let use_case = GetAllRelaysUseCase::new(controller, repository);
let result = use_case.execute().await.unwrap();
for relay in result {
let state = relay.state();
assert!(matches!(state, RelayState::On | RelayState::Off));
}
}
#[tokio::test]
async fn test_execute_each_relay_has_optional_label() {
let controller = Arc::new(create_test_controller().await);
let repository = Arc::new(MockRelayLabelRepository::new());
for i in [1, 3, 5, 7] {
let label = RelayLabel::new(format!("Label-{i}")).unwrap();
repository
.save_label(RelayId::new(i).unwrap(), label)
.await
.unwrap();
}
let use_case = GetAllRelaysUseCase::new(controller, repository);
let result = use_case.execute().await.unwrap();
for (index, relay) in result.iter().enumerate() {
let relay_num = index + 1;
if relay_num % 2 == 1 {
assert!(relay.label().is_some(), "Relay {relay_num} should have label");
} else {
assert!(relay.label().is_none(), "Relay {relay_num} should not have label");
}
}
}
}

View File

@@ -0,0 +1,24 @@
//! Application use cases for relay control.
//!
//! This module contains use case implementations that orchestrate domain entities
//! and infrastructure services to fulfill business requirements.
//!
//! # Use Cases
//!
//! - [`toggle_relay`]: Toggle a single relay's state (on→off, off→on)
//! - [`get_all_relays`]: Retrieve the current state of all 8 relays
//!
//! # Architecture
//!
//! Each use case follows the Command/Query pattern:
//! - **Commands** (e.g., `ToggleRelayUseCase`): Mutate state, return result
//! - **Queries** (e.g., `GetAllRelaysUseCase`): Read state, return data
//!
//! All use cases depend on trait abstractions (`RelayController`, `RelayLabelRepository`)
//! rather than concrete implementations, enabling easy testing with mocks.
pub mod get_all_relays;
pub mod toggle_relay;
pub use get_all_relays::GetAllRelaysUseCase;
pub use toggle_relay::ToggleRelayUseCase;

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

View File

@@ -6,6 +6,7 @@ use super::types::{RelayId, RelayLabel, RelayState};
/// ///
/// Encapsulates the relay's identity, current state, and optional human-readable label. /// Encapsulates the relay's identity, current state, and optional human-readable label.
/// This is the primary domain entity for relay control operations. /// This is the primary domain entity for relay control operations.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Relay { pub struct Relay {
id: RelayId, id: RelayId,
state: RelayState, state: RelayState,

View File

@@ -586,31 +586,38 @@ CLOSED: [2026-01-22 jeu. 00:02]
-------------- --------------
** TODO Phase 4: US1 - Monitor & Toggle Relay States (MVP) (2 days) [0/5] ** STARTED Phase 4: US1 - Monitor & Toggle Relay States (MVP) (2 days) [1/5]
- State "STARTED" from "TODO" [2026-01-23 ven. 20:20]
*Goal*: View current state of all 8 relays + toggle individual relay on/off *Goal*: View current state of all 8 relays + toggle individual relay on/off
*Independent Test*: =GET /api/relays= returns 8 relays, =POST /api/relays/{id}/toggle= changes state *Independent Test*: =GET /api/relays= returns 8 relays, =POST /api/relays/{id}/toggle= changes state
*** TODO Application Layer [0/4] *** DONE Application Layer [4/4]
- [ ] *T041* [US1] [TDD] Write tests for =ToggleRelayUseCase= CLOSED: [2026-01-23 ven. 20:42]
- State "DONE" from "STARTED" [2026-01-23 ven. 20:42]
- State "STARTED" from "TODO" [2026-01-23 ven. 20:20]
- [X] *T041* [US1] [TDD] Write tests for =ToggleRelayUseCase=
- Test: =execute(RelayId(1))= toggles relay state via controller - Test: =execute(RelayId(1))= toggles relay state via controller
- Test: =execute()= returns error if controller fails - Test: =execute()= returns error if controller fails
- *File*: =src/application/use_cases/toggle_relay.rs= - *File*: =src/application/use_cases/toggle_relay.rs=
- *Complexity*: Low | *Uncertainty*: Low - *Complexity*: Low | *Uncertainty*: Low
- [ ] *T042* [US1] [TDD] Implement =ToggleRelayUseCase= - *Tests Written*: 7 tests covering toggle Off→On, On→Off, error handling, state updates, label retrieval, and double-toggle idempotency
- [X] *T042* [US1] [TDD] Implement =ToggleRelayUseCase=
- Orchestrate: read current state → toggle → write new state - Orchestrate: read current state → toggle → write new state
- *File*: =src/application/use_cases/toggle_relay.rs= - *File*: =src/application/use_cases/toggle_relay.rs=
- *Complexity*: Low | *Uncertainty*: Low - *Complexity*: Low | *Uncertainty*: Low
- [ ] *T043* [P] [US1] [TDD] Write tests for =GetAllRelaysUseCase= - [X] *T043* [P] [US1] [TDD] Write tests for =GetAllRelaysUseCase=
- Test: =execute()= returns all 8 relays with states - Test: =execute()= returns all 8 relays with states
- *File*: =src/application/use_cases/get_all_relays.rs= - *File*: =src/application/use_cases/get_all_relays.rs=
- *Complexity*: Low | *Uncertainty*: Low - *Complexity*: Low | *Uncertainty*: Low
- [ ] *T044* [P] [US1] [TDD] Implement =GetAllRelaysUseCase= - *Tests Written*: 9 tests covering relay count, ordering, state correctness, label inclusion, error handling, and property validation
- [X] *T044* [P] [US1] [TDD] Implement =GetAllRelaysUseCase=
- Call =controller.read_all()=, map to domain =Relay= objects - Call =controller.read_all()=, map to domain =Relay= objects
- *File*: =src/application/use_cases/get_all_relays.rs= - *File*: =src/application/use_cases/get_all_relays.rs=
- *Complexity*: Low | *Uncertainty*: Low - *Complexity*: Low | *Uncertainty*: Low
*** TODO Presentation Layer (Backend API) [0/2] *** STARTED Presentation Layer (Backend API) [0/2]
- State "STARTED" from "TODO" [2026-01-23 ven. 20:42]
- [ ] *T045* [US1] [TDD] Define =RelayDto= in presentation layer - [ ] *T045* [US1] [TDD] Define =RelayDto= in presentation layer
- Fields: =id= (=u8=), =state= ("on"/"off"), =label= (=Option=) - Fields: =id= (=u8=), =state= ("on"/"off"), =label= (=Option=)
- Implement =From= for =RelayDto= - Implement =From= for =RelayDto=