From 7c89d48ac073b89a42a228c0fccb4ad393ec2b78 Mon Sep 17 00:00:00 2001 From: Lucien Cartier-Tilet Date: Sat, 3 Jan 2026 23:44:38 +0100 Subject: [PATCH] feat(domain): add ModbusAddress type and HealthStatus enum Implements T025-T027 from TDD workflow (red-green-refactor): - T025 (red): Tests for ModbusAddress with From conversion - T026 (green): ModbusAddress newtype (#[repr(transparent)]) with offset mapping - T027 (red+green): HealthStatus enum with state transitions ModbusAddress wraps u16 and converts user-facing relay IDs (1-8) to Modbus addresses (0-7) at the domain boundary. HealthStatus tracks relay health with Healthy, Degraded, and Unhealthy states supporting error tracking and recovery monitoring. Ref: T025, T026, T027 (specs/001-modbus-relay-control) --- backend/src/domain/health.rs | 295 +++++++++++++++++++ backend/src/domain/mod.rs | 2 + backend/src/domain/modbus.rs | 89 ++++++ backend/src/domain/relay/types/relaylabel.rs | 59 ++++ specs/001-modbus-relay-control/tasks.md | 6 +- 5 files changed, 448 insertions(+), 3 deletions(-) create mode 100644 backend/src/domain/health.rs create mode 100644 backend/src/domain/modbus.rs diff --git a/backend/src/domain/health.rs b/backend/src/domain/health.rs new file mode 100644 index 0000000..897d83e --- /dev/null +++ b/backend/src/domain/health.rs @@ -0,0 +1,295 @@ +//! Health monitoring domain module. +//! +//! This module provides health status tracking for the Modbus relay controller. +//! It defines the `HealthStatus` enum which represents the current health state +//! of the system and supports transitions between healthy, degraded, and unhealthy states. + +/// Health status of the Modbus relay controller. +/// +/// Represents the three possible health states of the system: +/// - `Healthy`: System is operating normally +/// - `Degraded`: System is experiencing errors but still operational +/// - `Unhealthy`: System has critical failures +/// +/// # State Transitions +/// +/// ```text +/// Healthy ──(errors)──> Degraded ──(more errors)──> Unhealthy +/// ^ | | +/// └──────(recovery)───────┘ | +/// └────────────────(recovery)────────────────────────┘ +/// ``` +/// +/// # Examples +/// +/// ``` +/// use sta::domain::health::HealthStatus; +/// +/// let status = HealthStatus::Healthy; +/// assert!(matches!(status, HealthStatus::Healthy)); +/// +/// let degraded = HealthStatus::Degraded { consecutive_errors: 3 }; +/// assert!(matches!(degraded, HealthStatus::Degraded { .. })); +/// +/// let unhealthy = HealthStatus::Unhealthy { +/// reason: "Connection timeout".to_string() +/// }; +/// assert!(matches!(unhealthy, HealthStatus::Unhealthy { .. })); +/// ``` +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum HealthStatus { + /// System is operating normally with no errors. + Healthy, + + /// System is experiencing errors but still operational. + /// + /// The `consecutive_errors` field tracks how many errors have occurred + /// without recovery. + Degraded { + /// Number of consecutive errors without recovery. + consecutive_errors: u32, + }, + + /// System has critical failures and is not operational. + /// + /// The `reason` field provides a human-readable description of the failure. + Unhealthy { + /// Human-readable description of the failure reason. + reason: String, + }, +} + +impl HealthStatus { + /// Creates a new `Healthy` status. + #[must_use] + pub const fn healthy() -> Self { + Self::Healthy + } + + /// Creates a new `Degraded` status with the given error count. + #[must_use] + pub const fn degraded(consecutive_errors: u32) -> Self { + Self::Degraded { consecutive_errors } + } + + /// Creates a new `Unhealthy` status with the given reason. + #[must_use] + pub fn unhealthy(reason: impl Into) -> Self { + Self::Unhealthy { + reason: reason.into(), + } + } + + /// Returns `true` if the status is `Healthy`. + #[must_use] + pub const fn is_healthy(&self) -> bool { + matches!(self, Self::Healthy) + } + + /// Returns `true` if the status is `Degraded`. + #[must_use] + pub const fn is_degraded(&self) -> bool { + matches!(self, Self::Degraded { .. }) + } + + /// Returns `true` if the status is `Unhealthy`. + #[must_use] + pub const fn is_unhealthy(&self) -> bool { + matches!(self, Self::Unhealthy { .. }) + } + + /// Transitions to a degraded state by incrementing the error count. + /// + /// If already degraded, increments the consecutive errors. + /// If healthy, transitions to degraded with 1 error. + /// If unhealthy, remains unhealthy. + #[must_use] + pub fn record_error(self) -> Self { + match self { + Self::Healthy => Self::Degraded { consecutive_errors: 1 }, + Self::Degraded { consecutive_errors } => { + Self::Degraded { + consecutive_errors: consecutive_errors + 1, + } + } + Self::Unhealthy { reason } => Self::Unhealthy { reason }, + } + } + + /// Transitions to a healthy state, resetting all error counts. + #[must_use] + pub fn record_success(self) -> Self { + Self::Healthy + } + + /// Transitions to unhealthy state with the given reason. + #[must_use] + pub fn mark_unhealthy(self, reason: impl Into) -> Self { + Self::Unhealthy { + reason: reason.into(), + } + } +} + +impl std::fmt::Display for HealthStatus { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Healthy => write!(f, "Healthy"), + Self::Degraded { consecutive_errors } => { + write!(f, "Degraded ({consecutive_errors} consecutive errors)") + } + Self::Unhealthy { reason } => write!(f, "Unhealthy: {reason}"), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_healthy_status_creation() { + let status = HealthStatus::healthy(); + assert!(status.is_healthy()); + assert!(!status.is_degraded()); + assert!(!status.is_unhealthy()); + } + + #[test] + fn test_degraded_status_creation() { + let status = HealthStatus::degraded(3); + assert!(!status.is_healthy()); + assert!(status.is_degraded()); + assert!(!status.is_unhealthy()); + assert_eq!(status, HealthStatus::Degraded { consecutive_errors: 3 }); + } + + #[test] + fn test_unhealthy_status_creation() { + let status = HealthStatus::unhealthy("Connection timeout"); + assert!(!status.is_healthy()); + assert!(!status.is_degraded()); + assert!(status.is_unhealthy()); + assert_eq!( + status, + HealthStatus::Unhealthy { + reason: "Connection timeout".to_string() + } + ); + } + + #[test] + fn test_transition_healthy_to_degraded() { + let status = HealthStatus::healthy(); + let status = status.record_error(); + assert!(status.is_degraded()); + assert_eq!(status, HealthStatus::Degraded { consecutive_errors: 1 }); + } + + #[test] + fn test_transition_degraded_increments_errors() { + let status = HealthStatus::degraded(2); + let status = status.record_error(); + assert!(status.is_degraded()); + assert_eq!(status, HealthStatus::Degraded { consecutive_errors: 3 }); + } + + #[test] + fn test_transition_unhealthy_stays_unhealthy_on_error() { + let status = HealthStatus::unhealthy("Device disconnected"); + let status = status.record_error(); + assert!(status.is_unhealthy()); + assert_eq!( + status, + HealthStatus::Unhealthy { + reason: "Device disconnected".to_string() + } + ); + } + + #[test] + fn test_transition_healthy_to_unhealthy() { + let status = HealthStatus::healthy(); + let status = status.mark_unhealthy("Critical failure"); + assert!(status.is_unhealthy()); + assert_eq!( + status, + HealthStatus::Unhealthy { + reason: "Critical failure".to_string() + } + ); + } + + #[test] + fn test_transition_degraded_to_unhealthy() { + let status = HealthStatus::degraded(5); + let status = status.mark_unhealthy("Too many errors"); + assert!(status.is_unhealthy()); + assert_eq!( + status, + HealthStatus::Unhealthy { + reason: "Too many errors".to_string() + } + ); + } + + #[test] + fn test_transition_degraded_to_healthy() { + let status = HealthStatus::degraded(3); + let status = status.record_success(); + assert!(status.is_healthy()); + } + + #[test] + fn test_transition_unhealthy_to_healthy() { + let status = HealthStatus::unhealthy("Device offline"); + let status = status.record_success(); + assert!(status.is_healthy()); + } + + #[test] + fn test_display_healthy() { + let status = HealthStatus::healthy(); + assert_eq!(status.to_string(), "Healthy"); + } + + #[test] + fn test_display_degraded() { + let status = HealthStatus::degraded(5); + assert_eq!(status.to_string(), "Degraded (5 consecutive errors)"); + } + + #[test] + fn test_display_unhealthy() { + let status = HealthStatus::unhealthy("Connection lost"); + assert_eq!(status.to_string(), "Unhealthy: Connection lost"); + } + + #[test] + fn test_multiple_transitions() { + // Start healthy + let status = HealthStatus::healthy(); + assert!(status.is_healthy()); + + // Record first error -> Degraded with 1 error + let status = status.record_error(); + assert!(status.is_degraded()); + assert_eq!(status, HealthStatus::Degraded { consecutive_errors: 1 }); + + // Record second error -> Degraded with 2 errors + let status = status.record_error(); + assert_eq!(status, HealthStatus::Degraded { consecutive_errors: 2 }); + + // Record third error -> Degraded with 3 errors + let status = status.record_error(); + assert_eq!(status, HealthStatus::Degraded { consecutive_errors: 3 }); + + // Mark unhealthy -> Unhealthy + let status = status.mark_unhealthy("Too many consecutive errors"); + assert!(status.is_unhealthy()); + + // Recover -> Healthy + let status = status.record_success(); + assert!(status.is_healthy()); + } +} diff --git a/backend/src/domain/mod.rs b/backend/src/domain/mod.rs index e38a835..b8505ff 100644 --- a/backend/src/domain/mod.rs +++ b/backend/src/domain/mod.rs @@ -36,3 +36,5 @@ //! - Domain specification: `specs/001-modbus-relay-control/spec.md` pub mod relay; +pub mod modbus; +pub mod health; diff --git a/backend/src/domain/modbus.rs b/backend/src/domain/modbus.rs new file mode 100644 index 0000000..2a54bda --- /dev/null +++ b/backend/src/domain/modbus.rs @@ -0,0 +1,89 @@ +//! Modbus domain module. +//! +//! This module contains Modbus-specific domain types and conversions. +//! It provides a clean abstraction layer between user-facing relay IDs +//! and Modbus protocol addresses. + +use super::relay::types::RelayId; + +/// Modbus address newtype wrapping u16. +/// +/// Represents a Modbus coil address (0-based indexing). +/// User-facing relay IDs (1-8) are converted to Modbus addresses (0-7). +/// +/// # Examples +/// +/// ``` +/// use sta::domain::modbus::ModbusAddress; +/// use sta::domain::relay::types::RelayId; +/// +/// let relay_id = RelayId::new(1).unwrap(); +/// let modbus_addr = ModbusAddress::from(relay_id); +/// assert_eq!(modbus_addr.as_u16(), 0); +/// ``` +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[repr(transparent)] +pub struct ModbusAddress(u16); + +impl ModbusAddress { + /// Returns the inner u16 value. + #[must_use] + pub const fn as_u16(self) -> u16 { + self.0 + } +} + +/// Converts a user-facing `RelayId` (1-8) to a Modbus address (0-7). +/// +/// # Offset Calculation +/// +/// Modbus uses 0-based addressing while our user-facing interface uses +/// 1-based relay numbering. This conversion applies the -1 offset. +/// +/// # Examples +/// +/// ``` +/// use sta::domain::modbus::ModbusAddress; +/// use sta::domain::relay::types::RelayId; +/// +/// let relay1 = RelayId::new(1).unwrap(); +/// assert_eq!(ModbusAddress::from(relay1).as_u16(), 0); +/// +/// let relay8 = RelayId::new(8).unwrap(); +/// assert_eq!(ModbusAddress::from(relay8).as_u16(), 7); +/// ``` +impl From for ModbusAddress { + fn from(relay_id: RelayId) -> Self { + // RelayId is guaranteed to be 1..=8 by its validation + // Convert to 0..=7 for Modbus addressing + Self(u16::from(relay_id.as_u8() - 1)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_relay_id_1_converts_to_modbus_address_0() { + let relay_id = RelayId::new(1).unwrap(); + let modbus_addr = ModbusAddress::from(relay_id); + assert_eq!(modbus_addr.as_u16(), 0); + } + + #[test] + fn test_relay_id_8_converts_to_modbus_address_7() { + let relay_id = RelayId::new(8).unwrap(); + let modbus_addr = ModbusAddress::from(relay_id); + assert_eq!(modbus_addr.as_u16(), 7); + } + + #[test] + fn test_all_relay_ids_convert_correctly() { + for i in 1..=8 { + let relay_id = RelayId::new(i).unwrap(); + let modbus_addr = ModbusAddress::from(relay_id); + assert_eq!(modbus_addr.as_u16(), u16::from(i - 1)); + } + } +} diff --git a/backend/src/domain/relay/types/relaylabel.rs b/backend/src/domain/relay/types/relaylabel.rs index 62e79aa..6ee06d9 100644 --- a/backend/src/domain/relay/types/relaylabel.rs +++ b/backend/src/domain/relay/types/relaylabel.rs @@ -1,3 +1,5 @@ +use std::fmt::Display; + use thiserror::Error; /// Human-readable label for a relay. @@ -45,3 +47,60 @@ impl Default for RelayLabel { Self(String::from("Unlabeled")) } } + +impl std::fmt::Display for RelayLabel { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_relay_label_new_valid_string() { + // Test: RelayLabel::new("Pump") → Ok + let result = RelayLabel::new("Pump".to_string()); + assert!(result.is_ok()); + assert_eq!(result.unwrap().as_str(), "Pump"); + } + + #[test] + fn test_relay_label_new_valid_max_length() { + // Test: RelayLabel::new("A".repeat(50)) → Ok + let label_str = "A".repeat(50); + let result = RelayLabel::new(label_str.clone()); + assert!(result.is_ok()); + assert_eq!(result.unwrap().as_str(), &label_str); + } + + #[test] + fn test_relay_label_new_empty_fails() { + // Test: RelayLabel::new("") → Err(EmptyLabel) + let result = RelayLabel::new(String::new()); + assert!(result.is_err()); + assert!(matches!(result.unwrap_err(), RelayLabelError::Empty)); + } + + #[test] + fn test_relay_label_new_too_long_fails() { + // Test: RelayLabel::new("A".repeat(51)) → Err(LabelTooLong) + let label_str = "A".repeat(51); + let result = RelayLabel::new(label_str); + assert!(result.is_err()); + assert!(matches!(result.unwrap_err(), RelayLabelError::TooLong(_))); + } + + #[test] + fn test_relay_label_default() { + let label = RelayLabel::default(); + assert_eq!(label.as_str(), "Unlabeled"); + } + + #[test] + fn test_relay_label_display() { + let label = RelayLabel::new("Test Label".to_string()).unwrap(); + assert_eq!(format!("{label}"), "Test Label"); + } +} diff --git a/specs/001-modbus-relay-control/tasks.md b/specs/001-modbus-relay-control/tasks.md index 02549e9..c7759b4 100644 --- a/specs/001-modbus-relay-control/tasks.md +++ b/specs/001-modbus-relay-control/tasks.md @@ -260,19 +260,19 @@ - **File**: src/domain/relay.rs - **Complexity**: Low | **Uncertainty**: Low -- [ ] **T025** [US1] [TDD] Write tests for ModbusAddress type +- [x] **T025** [US1] [TDD] Write tests for ModbusAddress type - Test: ModbusAddress::from(RelayId(1)) → ModbusAddress(0) - Test: ModbusAddress::from(RelayId(8)) → ModbusAddress(7) - **File**: src/domain/modbus.rs - **Complexity**: Low | **Uncertainty**: Low -- [ ] **T026** [US1] [TDD] Implement ModbusAddress type with From +- [x] **T026** [US1] [TDD] Implement ModbusAddress type with From - #[repr(transparent)] newtype wrapping u16 - Implement From with offset: user 1-8 → Modbus 0-7 - **File**: src/domain/modbus.rs - **Complexity**: Low | **Uncertainty**: Low -- [ ] **T027** [US3] [TDD] Write tests and implement HealthStatus enum +- [x] **T027** [US3] [TDD] Write tests and implement HealthStatus enum - Enum: Healthy, Degraded { consecutive_errors: u32 }, Unhealthy { reason: String } - Test transitions between states - **File**: src/domain/health.rs