//! Tests for `ModbusRelayController` //! //! These tests cover T025a through T025e of the implementation plan. //! Note: These tests require mocking or a test Modbus server since they test //! real TCP connections and Modbus protocol interactions. use super::*; #[cfg(test)] mod t025a_connection_setup_tests { use super::*; /// T025a Test 1: `new()` with valid config connects successfully /// /// This test verifies that `ModbusRelayController::new()` can establish /// a connection to a valid Modbus TCP server. /// /// NOTE: This test requires a running Modbus TCP server at 127.0.0.1:5020 /// or should be modified to use a mock server. Mark with #[ignore] if no server available. #[tokio::test] #[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; // Assert: Connection should succeed assert!( result.is_ok(), "Expected successful connection to test Modbus server, got error: {:?}", result.err() ); } /// T025a Test 2: `new()` with invalid host returns `ConnectionError` /// /// This test verifies that `ModbusRelayController::new()` returns a proper /// `ConnectionError` when given an invalid hostname. #[tokio::test] 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; // Assert: Should return ConnectionError assert!(result.is_err(), "Expected ConnectionError for invalid host"); let error = result.unwrap_err(); assert!( matches!( error, crate::domain::relay::controller::ControllerError::ConnectionError(_) ), "Expected ControllerError::ConnectionError, got {error:?}" ); } /// T025a Test 3: `new()` with unreachable port returns `ConnectionError` /// /// This test verifies that attempting to connect to a closed/unreachable port /// results in a `ConnectionError`. Uses localhost port 1 (closed) for instant /// "connection refused" error without waiting for TCP timeout. #[tokio::test] 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; // Assert: Should return ConnectionError assert!( result.is_err(), "Expected ConnectionError for unreachable host" ); } /// T025a Test 4: `new()` stores correct `timeout_duration` /// /// This test verifies that the `timeout_secs` parameter is correctly /// stored in the controller instance. /// /// NOTE: This test requires access to internal state or a working connection /// to verify timeout behavior. Mark with #[ignore] if no server available. #[tokio::test] #[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) .await .expect("Failed to create controller"); // Assert: Verify timeout is stored correctly // Note: This requires either: // 1. A getter method for timeout_duration, or // 2. Testing timeout behavior by triggering an actual timeout, or // 3. Refactoring to make timeout_duration accessible for testing // // For now, this is a placeholder that documents the requirement drop(controller); } } #[cfg(test)] mod t025b_read_coils_timeout_tests { // Note: These tests require access to private method read_coils_with_timeout() // Options: // 1. Make the method pub(crate) for testing // 2. Test indirectly through public RelayController methods // 3. Use a test-only feature flag to expose for testing // // For now, we'll test through the public interface (read_relay_state) use super::*; use crate::domain::relay::{ controller::{ControllerError, RelayController}, types::RelayId, }; /// T025b Test 1: `read_coils_with_timeout()` returns coil values on success /// /// This test verifies that reading coils succeeds when the Modbus server /// responds correctly. /// /// NOTE: Tests through `read_relay_state()` public interface #[tokio::test] #[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) .await .expect("Failed to connect to test server"); let relay_id = RelayId::new(1).expect("Valid relay ID"); // Act: Read relay state (internally calls read_coils_with_timeout) let result = controller.read_relay_state(relay_id).await; // Assert: Should succeed with a valid state assert!( result.is_ok(), "Expected successful coil read, got error: {:?}", result.err() ); } /// T025b Test 2: `read_coils_with_timeout()` returns Timeout error when operation exceeds timeout /// /// This test verifies that the timeout mechanism works correctly. /// /// NOTE: Requires either a slow/non-responsive Modbus server or mocking #[tokio::test] #[ignore = "Requires slow/non-responsive Modbus server or mocking"] async fn test_read_coils_returns_timeout_on_slow_response() { // Arrange: Connect with very short timeout let controller = ModbusRelayController::new("127.0.0.1", 5020, 1, 1) .await .expect("Failed to connect"); let relay_id = RelayId::new(1).expect("Valid relay ID"); // Act: Attempt to read (server should be configured to delay response) let result = controller.read_relay_state(relay_id).await; // Assert: Should return Timeout error assert!(result.is_err(), "Expected timeout error"); if let Err(ControllerError::Timeout(secs)) = result { assert_eq!(secs, 1, "Timeout duration should match configured value"); } else { panic!("Expected ControllerError::Timeout, got {result:?}"); } } /// T025b Test 3: `read_coils_with_timeout()` returns `ConnectionError` on `io::Error` /// /// This test verifies that IO errors are properly wrapped as `ConnectionError`. #[tokio::test] #[ignore = "Requires Modbus server that drops connections"] async fn test_read_coils_returns_connection_error_on_io_error() { // Arrange: Connect to server that will drop connection let controller = ModbusRelayController::new("127.0.0.1", 5020, 1, 5) .await .expect("Failed to connect"); // Server should be configured to drop connection after initial connect let relay_id = RelayId::new(1).expect("Valid relay ID"); // Act: Attempt to read after connection is dropped let result = controller.read_relay_state(relay_id).await; // Assert: Should return ConnectionError assert!(result.is_err(), "Expected connection error"); assert!( matches!(result, Err(ControllerError::ConnectionError(_))), "Expected ConnectionError, got {result:?}" ); } /// T025b Test 4: `read_coils_with_timeout()` returns `ModbusException` on protocol error /// /// This test verifies that Modbus protocol exceptions are properly handled. #[tokio::test] #[ignore = "Requires Modbus server that returns exception codes"] async fn test_read_coils_returns_modbus_exception_on_protocol_error() { // Arrange: Connect to server configured to return Modbus exception let controller = ModbusRelayController::new("127.0.0.1", 5020, 1, 5) .await .expect("Failed to connect"); // Server should be configured to return exception for this relay ID let relay_id = RelayId::new(1).expect("Valid relay ID"); // Act: Attempt to read (should trigger Modbus exception) let result = controller.read_relay_state(relay_id).await; // Assert: Should return ModbusException error assert!(result.is_err(), "Expected Modbus exception"); assert!( matches!(result, Err(ControllerError::ModbusException(_))), "Expected ModbusException, got {result:?}" ); } } #[cfg(test)] mod t025c_write_single_coil_timeout_tests { use super::*; use crate::domain::relay::{ controller::{ControllerError, RelayController}, types::{RelayId, RelayState}, }; /// 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 /// responds correctly. /// /// NOTE: Tests through `write_relay_state()` public interface #[tokio::test] #[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) .await .expect("Failed to connect to test server"); let relay_id = RelayId::new(1).expect("Valid relay ID"); let state = RelayState::On; // Act: Write relay state (internally calls write_single_coil_with_timeout) let result = controller.write_relay_state(relay_id, state).await; // Assert: Should succeed assert!( result.is_ok(), "Expected successful coil write, got error: {:?}", result.err() ); } /// T025c Test 2: `write_single_coil_with_timeout()` returns Timeout on slow device /// /// This test verifies that write operations properly timeout. #[tokio::test] #[ignore = "Requires slow/non-responsive Modbus server"] async fn test_write_single_coil_returns_timeout_on_slow_device() { // Arrange: Connect with very short timeout let controller = ModbusRelayController::new("127.0.0.1", 5020, 1, 1) .await .expect("Failed to connect"); let relay_id = RelayId::new(1).expect("Valid relay ID"); let state = RelayState::On; // Act: Attempt to write (server should be configured to delay response) let result = controller.write_relay_state(relay_id, state).await; // Assert: Should return Timeout error assert!(result.is_err(), "Expected timeout error"); if let Err(ControllerError::Timeout(secs)) = result { assert_eq!(secs, 1, "Timeout duration should match configured value"); } else { panic!("Expected ControllerError::Timeout, got {result:?}"); } } /// T025c Test 3: `write_single_coil_with_timeout()` returns appropriate error on failure /// /// This test verifies that various write failures are properly handled. #[tokio::test] #[ignore = "Requires Modbus server configured for error testing"] async fn test_write_single_coil_returns_error_on_failure() { // Arrange: Connect to test server let controller = ModbusRelayController::new("127.0.0.1", 5020, 1, 5) .await .expect("Failed to connect"); let relay_id = RelayId::new(1).expect("Valid relay ID"); let state = RelayState::On; // Act: Attempt to write (server should be configured to return error) let result = controller.write_relay_state(relay_id, state).await; // Assert: Should return an error (ConnectionError or ModbusException) assert!(result.is_err(), "Expected error from write operation"); } } #[cfg(test)] mod t025d_read_relay_state_tests { use super::*; use crate::domain::relay::{ controller::RelayController, types::{RelayId, RelayState}, }; /// 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`. #[tokio::test] #[ignore = "Requires Modbus server with relay 1 set to On"] async fn test_read_state_returns_on_when_coil_is_true() { // Arrange: Connect to test server with relay 1 in On state let controller = ModbusRelayController::new("127.0.0.1", 5020, 1, 5) .await .expect("Failed to connect to test server"); let relay_id = RelayId::new(1).expect("Valid relay ID"); // Act: Read relay state let result = controller.read_relay_state(relay_id).await; // Assert: Should return On state assert!(result.is_ok(), "Failed to read relay state"); assert_eq!(result.unwrap(), RelayState::On, "Expected relay to be On"); } /// T025d Test 2: `read_relay_state(RelayId(1))` returns Off when coil is false /// /// This test verifies that a false coil value is correctly converted to `RelayState::Off`. #[tokio::test] #[ignore = "Requires Modbus server with relay 1 set to Off"] async fn test_read_state_returns_off_when_coil_is_false() { // Arrange: Connect to test server with relay 1 in Off state let controller = ModbusRelayController::new("127.0.0.1", 5020, 1, 5) .await .expect("Failed to connect to test server"); let relay_id = RelayId::new(1).expect("Valid relay ID"); // Act: Read relay state let result = controller.read_relay_state(relay_id).await; // Assert: Should return Off state assert!(result.is_ok(), "Failed to read relay state"); assert_eq!(result.unwrap(), RelayState::Off, "Expected relay to be Off"); } /// T025d Test 3: `read_relay_state()` propagates `ControllerError` from helper /// /// This test verifies that errors from `read_coils_with_timeout` are properly propagated. #[tokio::test] #[ignore = "Requires Modbus server configured to return errors"] async fn test_read_state_propagates_controller_error() { // Arrange: Connect to test server let controller = ModbusRelayController::new("127.0.0.1", 5020, 1, 5) .await .expect("Failed to connect to test server"); // Server should be configured to return error for this relay let relay_id = RelayId::new(1).expect("Valid relay ID"); // Act: Attempt to read relay state let result = controller.read_relay_state(relay_id).await; // Assert: Should propagate error assert!( result.is_err(), "Expected error to be propagated from helper" ); } /// T025d Test 4: `read_relay_state()` correctly maps `RelayId` to `ModbusAddress` /// /// This test verifies that relay IDs are correctly converted to 0-based Modbus addresses. #[tokio::test] #[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) .await .expect("Failed to connect to test server"); // Test multiple relays to verify address mapping (RelayId 1-8 → Modbus 0-7) for relay_num in 1..=8 { let relay_id = RelayId::new(relay_num).expect("Valid relay ID"); // Act: Read relay state let result = controller.read_relay_state(relay_id).await; // Assert: Should succeed for all valid relay IDs assert!(result.is_ok(), "Failed to read relay {relay_num} state"); } } } #[cfg(test)] mod t025e_write_relay_state_tests { use super::*; use crate::domain::relay::{ controller::RelayController, types::{RelayId, RelayState}, }; /// 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. #[tokio::test] #[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) .await .expect("Failed to connect to test server"); let relay_id = RelayId::new(1).expect("Valid relay ID"); let state = RelayState::On; // Act: Write On state let write_result = controller.write_relay_state(relay_id, state).await; // Assert: Write should succeed assert!( write_result.is_ok(), "Failed to write relay state: {:?}", write_result.err() ); // Verify by reading back let read_result = controller.read_relay_state(relay_id).await; assert!(read_result.is_ok(), "Failed to read back relay state"); assert_eq!( read_result.unwrap(), RelayState::On, "Relay should be On after writing On state" ); } /// T025e Test 2: `write_relay_state(RelayId(1)`, `RelayState::Off`) writes false to coil /// /// This test verifies that `RelayState::Off` is correctly converted to a false coil value. #[tokio::test] #[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) .await .expect("Failed to connect to test server"); let relay_id = RelayId::new(1).expect("Valid relay ID"); let state = RelayState::Off; // Act: Write Off state let write_result = controller.write_relay_state(relay_id, state).await; // Assert: Write should succeed assert!( write_result.is_ok(), "Failed to write relay state: {:?}", write_result.err() ); // Verify by reading back let read_result = controller.read_relay_state(relay_id).await; assert!(read_result.is_ok(), "Failed to read back relay state"); assert_eq!( read_result.unwrap(), RelayState::Off, "Relay should be Off after writing Off state" ); } /// T025e Test 3: `write_relay_state()` correctly maps `RelayId` to `ModbusAddress` /// /// This test verifies that relay IDs are correctly converted when writing. #[tokio::test] #[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) .await .expect("Failed to connect to test server"); // Test writing to all relay IDs to verify address mapping for relay_num in 1..=8 { let relay_id = RelayId::new(relay_num).expect("Valid relay ID"); // Act: Write to relay let result = controller.write_relay_state(relay_id, RelayState::On).await; // Assert: Should succeed for all valid relay IDs assert!( result.is_ok(), "Failed to write to relay {}: {:?}", relay_num, result.err() ); } } /// T025e Test 4: `write_relay_state()` can toggle relays between On and Off /// /// This test verifies that relays can be toggled multiple times. #[tokio::test] #[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) .await .expect("Failed to connect to test server"); let relay_id = RelayId::new(1).expect("Valid relay ID"); // Act & Assert: Toggle relay multiple times for expected_state in [ RelayState::On, RelayState::Off, RelayState::On, RelayState::Off, ] { let write_result = controller.write_relay_state(relay_id, expected_state).await; assert!( write_result.is_ok(), "Failed to write state {expected_state:?}" ); let read_result = controller.read_relay_state(relay_id).await; assert!(read_result.is_ok(), "Failed to read state back"); assert_eq!( read_result.unwrap(), expected_state, "Relay state should match written value" ); } } } #[cfg(test)] mod write_all_states_validation_tests { use super::*; /// 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) .await .expect("Failed to connect to test server"); // Act: Attempt to write with empty vector let result = controller.write_all_states(vec![]).await; // Assert: Should return InvalidInput error assert!(result.is_err(), "Expected error for empty states vector"); assert!( matches!(result.unwrap_err(), ControllerError::InvalidInput(_)), "Expected InvalidInput error variant" ); } /// Test: `write_all_states()` returns `InvalidInput` when given 7 states (too few) #[tokio::test] #[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) .await .expect("Failed to connect to test server"); // Act: Attempt to write with 7 states (missing one) let states = vec![RelayState::On; 7]; let result = controller.write_all_states(states).await; // Assert: Should return InvalidInput error with descriptive message assert!(result.is_err(), "Expected error for 7 states"); match result.unwrap_err() { ControllerError::InvalidInput(msg) => { assert!( msg.contains("Expected 8"), "Error message should mention expected count" ); assert!( msg.contains('7'), "Error message should mention actual count" ); } other => panic!("Expected InvalidInput, got {other:?}"), } } /// Test: `write_all_states()` returns `InvalidInput` when given 9 states (too many) #[tokio::test] #[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) .await .expect("Failed to connect to test server"); // Act: Attempt to write with 9 states (one extra) let states = vec![RelayState::Off; 9]; let result = controller.write_all_states(states).await; // Assert: Should return InvalidInput error with descriptive message assert!(result.is_err(), "Expected error for 9 states"); match result.unwrap_err() { ControllerError::InvalidInput(msg) => { assert!( msg.contains("Expected 8"), "Error message should mention expected count" ); assert!( msg.contains('9'), "Error message should mention actual count" ); } other => panic!("Expected InvalidInput, got {other:?}"), } } /// Test: `write_all_states()` succeeds with exactly 8 states #[tokio::test] #[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) .await .expect("Failed to connect to test server"); // Act: Write with exactly 8 states let states = vec![RelayState::On; 8]; let result = controller.write_all_states(states).await; // Assert: Should succeed assert!( result.is_ok(), "Expected success for 8 states, got: {:?}", result.err() ); } }