feat(application): HealthMonitor service and hardware integration test

Add HealthMonitor service for tracking system health status with
comprehensive state transition logic and thread-safe operations.
Includes 16 unit tests covering all functionality including concurrent
access scenarios.

Add optional Modbus hardware integration tests with 7 test cases for
real device testing. Tests are marked as ignored and can be run with

Ref: T034, T039, T040 (specs/001-modbus-relay-control/tasks.org)
This commit is contained in:
2026-01-21 20:43:06 +01:00
parent 4636cb457a
commit 6d0a2bdb9e
9 changed files with 652 additions and 38 deletions
@@ -10,6 +10,10 @@ use super::*;
mod t025a_connection_setup_tests {
use super::*;
static HOST: &str = "192.168.1.200";
static PORT: u16 = 502;
static SLAVE_ID: u8 = 1;
/// T025a Test 1: `new()` with valid config connects successfully
///
/// This test verifies that `ModbusRelayController::new()` can establish
@@ -21,13 +25,10 @@ mod t025a_connection_setup_tests {
#[ignore = "Requires running Modbus TCP server"]
async fn test_new_with_valid_config_connects_successfully() {
// Arrange: Use localhost test server
let host = "127.0.0.1";
let port = 5020; // Test Modbus TCP port
let slave_id = 1;
let timeout_secs = 5;
// Act: Attempt to create controller
let result = ModbusRelayController::new(host, port, slave_id, timeout_secs).await;
let result = ModbusRelayController::new(HOST, PORT, SLAVE_ID, timeout_secs).await;
// Assert: Connection should succeed
assert!(
@@ -45,12 +46,10 @@ mod t025a_connection_setup_tests {
async fn test_new_with_invalid_host_returns_connection_error() {
// Arrange: Use invalid host format
let host = "not a valid host!!!";
let port = 502;
let slave_id = 1;
let timeout_secs = 5;
// Act: Attempt to create controller
let result = ModbusRelayController::new(host, port, slave_id, timeout_secs).await;
let result = ModbusRelayController::new(host, PORT, SLAVE_ID, timeout_secs).await;
// Assert: Should return ConnectionError
assert!(result.is_err(), "Expected ConnectionError for invalid host");
@@ -74,13 +73,11 @@ mod t025a_connection_setup_tests {
async fn test_new_with_unreachable_host_returns_connection_error() {
// Arrange: Use localhost with a closed port (port 1 is typically closed)
// This gives instant "connection refused" instead of waiting for TCP timeout
let host = "127.0.0.1";
let port = 1; // Closed port for instant connection failure
let slave_id = 1;
let timeout_secs = 1;
// Act: Attempt to create controller
let result = ModbusRelayController::new(host, port, slave_id, timeout_secs).await;
let result = ModbusRelayController::new(HOST, port, SLAVE_ID, timeout_secs).await;
// Assert: Should return ConnectionError
assert!(
@@ -100,13 +97,10 @@ mod t025a_connection_setup_tests {
#[ignore = "Requires running Modbus TCP server or refactoring to expose timeout"]
async fn test_new_stores_correct_timeout_duration() {
// Arrange
let host = "127.0.0.1";
let port = 5020;
let slave_id = 1;
let timeout_secs = 10;
// Act
let controller = ModbusRelayController::new(host, port, slave_id, timeout_secs)
let controller = ModbusRelayController::new(HOST, PORT, SLAVE_ID, timeout_secs)
.await
.expect("Failed to create controller");
@@ -137,6 +131,10 @@ mod t025b_read_coils_timeout_tests {
types::RelayId,
};
static HOST: &str = "192.168.1.200";
static PORT: u16 = 502;
static SLAVE_ID: u8 = 1;
/// T025b Test 1: `read_coils_with_timeout()` returns coil values on success
///
/// This test verifies that reading coils succeeds when the Modbus server
@@ -147,7 +145,7 @@ mod t025b_read_coils_timeout_tests {
#[ignore = "Requires running Modbus TCP server with known state"]
async fn test_read_coils_returns_coil_values_on_success() {
// Arrange: Connect to test server
let controller = ModbusRelayController::new("127.0.0.1", 5020, 1, 5)
let controller = ModbusRelayController::new(HOST, PORT, SLAVE_ID, 5)
.await
.expect("Failed to connect to test server");
@@ -251,6 +249,10 @@ mod t025c_write_single_coil_timeout_tests {
types::{RelayId, RelayState},
};
static HOST: &str = "192.168.1.200";
static PORT: u16 = 502;
static SLAVE_ID: u8 = 1;
/// T025c Test 1: `write_single_coil_with_timeout()` succeeds for valid write
///
/// This test verifies that writing to a coil succeeds when the Modbus server
@@ -261,7 +263,7 @@ mod t025c_write_single_coil_timeout_tests {
#[ignore = "Requires running Modbus TCP server"]
async fn test_write_single_coil_succeeds_for_valid_write() {
// Arrange: Connect to test server
let controller = ModbusRelayController::new("127.0.0.1", 5020, 1, 5)
let controller = ModbusRelayController::new(HOST, PORT, SLAVE_ID, 5)
.await
.expect("Failed to connect to test server");
@@ -336,6 +338,10 @@ mod t025d_read_relay_state_tests {
types::{RelayId, RelayState},
};
static HOST: &str = "192.168.1.200";
static PORT: u16 = 502;
static SLAVE_ID: u8 = 1;
/// T025d Test 1: `read_relay_state(RelayId(1))` returns On when coil is true
///
/// This test verifies that a true coil value is correctly converted to `RelayState::On`.
@@ -409,7 +415,7 @@ mod t025d_read_relay_state_tests {
#[ignore = "Requires Modbus server with specific relay states"]
async fn test_read_state_correctly_maps_relay_id_to_modbus_address() {
// Arrange: Connect to test server with known relay states
let controller = ModbusRelayController::new("127.0.0.1", 5020, 1, 5)
let controller = ModbusRelayController::new(HOST, PORT, SLAVE_ID, 5)
.await
.expect("Failed to connect to test server");
@@ -434,6 +440,10 @@ mod t025e_write_relay_state_tests {
types::{RelayId, RelayState},
};
static HOST: &str = "192.168.1.200";
static PORT: u16 = 502;
static SLAVE_ID: u8 = 1;
/// T025e Test 1: `write_relay_state(RelayId(1)`, `RelayState::On`) writes true to coil
///
/// This test verifies that `RelayState::On` is correctly converted to a true coil value.
@@ -441,7 +451,7 @@ mod t025e_write_relay_state_tests {
#[ignore = "Requires Modbus server that can verify written values"]
async fn test_write_state_on_writes_true_to_coil() {
// Arrange: Connect to test server
let controller = ModbusRelayController::new("127.0.0.1", 5020, 1, 5)
let controller = ModbusRelayController::new(HOST, PORT, SLAVE_ID, 5)
.await
.expect("Failed to connect to test server");
@@ -475,7 +485,7 @@ mod t025e_write_relay_state_tests {
#[ignore = "Requires Modbus server that can verify written values"]
async fn test_write_state_off_writes_false_to_coil() {
// Arrange: Connect to test server
let controller = ModbusRelayController::new("127.0.0.1", 5020, 1, 5)
let controller = ModbusRelayController::new(HOST, PORT, SLAVE_ID, 5)
.await
.expect("Failed to connect to test server");
@@ -509,7 +519,7 @@ mod t025e_write_relay_state_tests {
#[ignore = "Requires Modbus server"]
async fn test_write_state_correctly_maps_relay_id_to_modbus_address() {
// Arrange: Connect to test server
let controller = ModbusRelayController::new("127.0.0.1", 5020, 1, 5)
let controller = ModbusRelayController::new(HOST, PORT, SLAVE_ID, 5)
.await
.expect("Failed to connect to test server");
@@ -537,7 +547,7 @@ mod t025e_write_relay_state_tests {
#[ignore = "Requires Modbus server"]
async fn test_write_state_can_toggle_relay_multiple_times() {
// Arrange: Connect to test server
let controller = ModbusRelayController::new("127.0.0.1", 5020, 1, 5)
let controller = ModbusRelayController::new(HOST, PORT, SLAVE_ID, 5)
.await
.expect("Failed to connect to test server");
@@ -571,12 +581,16 @@ mod t025e_write_relay_state_tests {
mod write_all_states_validation_tests {
use super::*;
static HOST: &str = "192.168.1.200";
static PORT: u16 = 502;
static SLAVE_ID: u8 = 1;
/// Test: `write_all_states()` returns `InvalidInput` when given 0 states
#[tokio::test]
#[ignore = "Requires Modbus server"]
async fn test_write_all_states_with_empty_vector_returns_invalid_input() {
// Arrange: Connect to test server
let controller = ModbusRelayController::new("127.0.0.1", 5020, 1, 5)
let controller = ModbusRelayController::new(HOST, PORT, SLAVE_ID, 5)
.await
.expect("Failed to connect to test server");
@@ -596,7 +610,7 @@ mod write_all_states_validation_tests {
#[ignore = "Requires Modbus server"]
async fn test_write_all_states_with_7_states_returns_invalid_input() {
// Arrange: Connect to test server
let controller = ModbusRelayController::new("127.0.0.1", 5020, 1, 5)
let controller = ModbusRelayController::new(HOST, PORT, SLAVE_ID, 5)
.await
.expect("Failed to connect to test server");
@@ -626,7 +640,7 @@ mod write_all_states_validation_tests {
#[ignore = "Requires Modbus server"]
async fn test_write_all_states_with_9_states_returns_invalid_input() {
// Arrange: Connect to test server
let controller = ModbusRelayController::new("127.0.0.1", 5020, 1, 5)
let controller = ModbusRelayController::new(HOST, PORT, SLAVE_ID, 5)
.await
.expect("Failed to connect to test server");
@@ -656,7 +670,7 @@ mod write_all_states_validation_tests {
#[ignore = "Requires Modbus server"]
async fn test_write_all_states_with_8_states_succeeds() {
// Arrange: Connect to test server
let controller = ModbusRelayController::new("127.0.0.1", 5020, 1, 5)
let controller = ModbusRelayController::new(HOST, PORT, SLAVE_ID, 5)
.await
.expect("Failed to connect to test server");