diff --git a/backend/src/application/mod.rs b/backend/src/application/mod.rs index 152ee9c..964868b 100644 --- a/backend/src/application/mod.rs +++ b/backend/src/application/mod.rs @@ -65,3 +65,4 @@ //! - Domain types: [`crate::domain`] - Domain entities and value objects pub mod health; +pub mod use_cases; diff --git a/backend/src/application/use_cases/get_all_relays.rs b/backend/src/application/use_cases/get_all_relays.rs new file mode 100644 index 0000000..342404b --- /dev/null +++ b/backend/src/application/use_cases/get_all_relays.rs @@ -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, + repository: Arc, +} + +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, + repository: Arc, + ) -> 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, 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 = labels + .into_iter() + .map(|(id, label)| (id.as_u8(), label)) + .collect(); + let relays: Vec = 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"); + } + } + } +} diff --git a/backend/src/application/use_cases/mod.rs b/backend/src/application/use_cases/mod.rs new file mode 100644 index 0000000..3411d3c --- /dev/null +++ b/backend/src/application/use_cases/mod.rs @@ -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; diff --git a/backend/src/application/use_cases/toggle_relay.rs b/backend/src/application/use_cases/toggle_relay.rs new file mode 100644 index 0000000..ecd88d5 --- /dev/null +++ b/backend/src/application/use_cases/toggle_relay.rs @@ -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, + repository: Arc, +} + +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, + repository: Arc, + ) -> 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 { + 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); + } +} diff --git a/backend/src/domain/relay/entity.rs b/backend/src/domain/relay/entity.rs index 265cfe8..b6579c5 100644 --- a/backend/src/domain/relay/entity.rs +++ b/backend/src/domain/relay/entity.rs @@ -6,6 +6,7 @@ use super::types::{RelayId, RelayLabel, RelayState}; /// /// Encapsulates the relay's identity, current state, and optional human-readable label. /// This is the primary domain entity for relay control operations. +#[derive(Debug, Clone, PartialEq, Eq)] pub struct Relay { id: RelayId, state: RelayState, diff --git a/specs/001-modbus-relay-control/tasks.org b/specs/001-modbus-relay-control/tasks.org index 6677d21..c4d2e4b 100644 --- a/specs/001-modbus-relay-control/tasks.org +++ b/specs/001-modbus-relay-control/tasks.org @@ -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 *Independent Test*: =GET /api/relays= returns 8 relays, =POST /api/relays/{id}/toggle= changes state -*** TODO Application Layer [0/4] -- [ ] *T041* [US1] [TDD] Write tests for =ToggleRelayUseCase= +*** DONE Application Layer [4/4] +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()= returns error if controller fails - *File*: =src/application/use_cases/toggle_relay.rs= - *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 - *File*: =src/application/use_cases/toggle_relay.rs= - *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 - *File*: =src/application/use_cases/get_all_relays.rs= - *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 - *File*: =src/application/use_cases/get_all_relays.rs= - *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 - Fields: =id= (=u8=), =state= ("on"/"off"), =label= (=Option=) - Implement =From= for =RelayDto=