feat(domain): add ModbusAddress type and HealthStatus enum
Implements T025-T027 from TDD workflow (red-green-refactor): - T025 (red): Tests for ModbusAddress with From<RelayId> 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)
This commit is contained in:
295
backend/src/domain/health.rs
Normal file
295
backend/src/domain/health.rs
Normal file
@@ -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<String>) -> 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<String>) -> 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());
|
||||
}
|
||||
}
|
||||
@@ -36,3 +36,5 @@
|
||||
//! - Domain specification: `specs/001-modbus-relay-control/spec.md`
|
||||
|
||||
pub mod relay;
|
||||
pub mod modbus;
|
||||
pub mod health;
|
||||
|
||||
89
backend/src/domain/modbus.rs
Normal file
89
backend/src/domain/modbus.rs
Normal file
@@ -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<RelayId> 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user