From ab52d3a23a816221bfbb89d668e40b778ba21682 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20M=C3=BCller?= Date: Wed, 28 Feb 2024 21:59:34 +0100 Subject: [PATCH] Added UnitTests for TCP --- .gitlab-ci.yml | 23 +- .../Models/HoldingRegister.cs | 7 +- .../Models/InputRegister.cs | 4 +- AMWD.Protocols.Modbus.Tcp/Utils/AsyncQueue.cs | 2 +- .../Utils/ModbusTcpConnection.cs | 16 +- .../Utils/NetworkStreamWrapper.cs | 2 +- .../Utils/SocketWrapper.cs | 2 +- .../AMWD.Protocols.Modbus.Tests.csproj | 8 +- AMWD.Protocols.Modbus.Tests/GlobalUsings.cs | 4 +- .../Tcp/ModbusTcpClientTest.cs | 124 +++ .../Tcp/Utils/ModbusTcpConnectionTest.cs | 722 ++++++++++++++++++ 11 files changed, 887 insertions(+), 27 deletions(-) create mode 100644 AMWD.Protocols.Modbus.Tests/Tcp/ModbusTcpClientTest.cs create mode 100644 AMWD.Protocols.Modbus.Tests/Tcp/Utils/ModbusTcpConnectionTest.cs diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index bd74b86..acc7d98 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,4 +1,3 @@ -# The image has to use the same version as the .NET UnitTest project image: mcr.microsoft.com/dotnet/sdk:8.0 variables: @@ -44,14 +43,25 @@ test-debug: - lnx rules: - if: $CI_COMMIT_TAG == null - # line-coverage - #coverage: '/Total[^|]*\|\s*([0-9.%]+)/' - # branch-coverage coverage: '/Total[^|]*\|[^|]*\|\s*([0-9.%]+)/' script: - dotnet restore --no-cache --force - dotnet test -c Debug --nologo --no-restore +deploy-debug: + stage: deploy + dependencies: + - build-debug + - test-debug + tags: + - docker + - lnx + rules: + - if: $CI_COMMIT_TAG == null + script: + - dotnet nuget push -k $BAGET_APIKEY -s https://nuget.am-wd.de/v3/index.json --skip-duplicate artifacts/*.nupkg + + build-release: stage: build @@ -85,15 +95,12 @@ test-release: - lnx rules: - if: $CI_COMMIT_TAG != null - # line-coverage - #coverage: '/Total[^|]*\|\s*([0-9.%]+)/' - # branch-coverage coverage: '/Total[^|]*\|[^|]*\|\s*([0-9.%]+)/' script: - dotnet restore --no-cache --force - dotnet test -c Release --nologo --no-restore -deploy: +deploy-release: stage: deploy dependencies: - build-release diff --git a/AMWD.Protocols.Modbus.Common/Models/HoldingRegister.cs b/AMWD.Protocols.Modbus.Common/Models/HoldingRegister.cs index 286131b..a95e375 100644 --- a/AMWD.Protocols.Modbus.Common/Models/HoldingRegister.cs +++ b/AMWD.Protocols.Modbus.Common/Models/HoldingRegister.cs @@ -18,16 +18,13 @@ namespace AMWD.Protocols.Modbus.Common get { byte[] blob = [HighByte, LowByte]; - if (BitConverter.IsLittleEndian) - Array.Reverse(blob); - + blob.SwapNetworkOrder(); return BitConverter.ToUInt16(blob, 0); } set { byte[] blob = BitConverter.GetBytes(value); - if (BitConverter.IsLittleEndian) - Array.Reverse(blob); + blob.SwapNetworkOrder(); HighByte = blob[0]; LowByte = blob[1]; diff --git a/AMWD.Protocols.Modbus.Common/Models/InputRegister.cs b/AMWD.Protocols.Modbus.Common/Models/InputRegister.cs index 795cf1a..f967759 100644 --- a/AMWD.Protocols.Modbus.Common/Models/InputRegister.cs +++ b/AMWD.Protocols.Modbus.Common/Models/InputRegister.cs @@ -18,9 +18,7 @@ namespace AMWD.Protocols.Modbus.Common get { byte[] blob = [HighByte, LowByte]; - if (BitConverter.IsLittleEndian) - Array.Reverse(blob); - + blob.SwapNetworkOrder(); return BitConverter.ToUInt16(blob, 0); } } diff --git a/AMWD.Protocols.Modbus.Tcp/Utils/AsyncQueue.cs b/AMWD.Protocols.Modbus.Tcp/Utils/AsyncQueue.cs index 1a40e43..af686e1 100644 --- a/AMWD.Protocols.Modbus.Tcp/Utils/AsyncQueue.cs +++ b/AMWD.Protocols.Modbus.Tcp/Utils/AsyncQueue.cs @@ -6,7 +6,7 @@ namespace System.Collections.Generic // ============================================================================================================================= // // Source: https://git.am-wd.de/am.wd/common/-/blob/d4b390ad911ce302cc371bb2121fa9c31db1674a/AMWD.Common/Utilities/AsyncQueue.cs // // ============================================================================================================================= // - [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + [Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] internal class AsyncQueue { private readonly Queue _queue = new(); diff --git a/AMWD.Protocols.Modbus.Tcp/Utils/ModbusTcpConnection.cs b/AMWD.Protocols.Modbus.Tcp/Utils/ModbusTcpConnection.cs index 78df4f4..a951c96 100644 --- a/AMWD.Protocols.Modbus.Tcp/Utils/ModbusTcpConnection.cs +++ b/AMWD.Protocols.Modbus.Tcp/Utils/ModbusTcpConnection.cs @@ -115,7 +115,10 @@ namespace AMWD.Protocols.Modbus.Tcp #endif if (_disconnectCts != null) + { + await _reconnectTask; return; + } _disconnectCts = new CancellationTokenSource(); using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(_disconnectCts.Token, cancellationToken); @@ -166,6 +169,16 @@ namespace AMWD.Protocols.Modbus.Tcp if (!IsConnected) throw new ApplicationException($"Connection is not open"); + if (request?.Count < 1) + throw new ArgumentNullException(nameof(request)); + +#if NET8_0_OR_GREATER + ArgumentNullException.ThrowIfNull(validateResponseComplete); +#else + if (validateResponseComplete == null) + throw new ArgumentNullException(nameof(validateResponseComplete)); +#endif + var item = new RequestQueueItem { Request = [.. request], @@ -178,7 +191,7 @@ namespace AMWD.Protocols.Modbus.Tcp { _requestQueue.Remove(item); item.CancellationTokenSource.Dispose(); - item.TaskCompletionSource.TrySetCanceled(); + item.TaskCompletionSource.TrySetCanceled(cancellationToken); item.CancellationTokenRegistration.Dispose(); }); @@ -375,7 +388,6 @@ namespace AMWD.Protocols.Modbus.Tcp catch (Exception ex) { item.TaskCompletionSource.TrySetException(ex); - continue; } } } diff --git a/AMWD.Protocols.Modbus.Tcp/Utils/NetworkStreamWrapper.cs b/AMWD.Protocols.Modbus.Tcp/Utils/NetworkStreamWrapper.cs index e6cd4ce..8f70d5d 100644 --- a/AMWD.Protocols.Modbus.Tcp/Utils/NetworkStreamWrapper.cs +++ b/AMWD.Protocols.Modbus.Tcp/Utils/NetworkStreamWrapper.cs @@ -12,7 +12,7 @@ namespace AMWD.Protocols.Modbus.Tcp.Utils { private readonly NetworkStream _stream; - [Obsolete("Constructor only for mocking on UnitTests!")] + [Obsolete("Constructor only for mocking on UnitTests!", error: true)] public NetworkStreamWrapper() { } diff --git a/AMWD.Protocols.Modbus.Tcp/Utils/SocketWrapper.cs b/AMWD.Protocols.Modbus.Tcp/Utils/SocketWrapper.cs index 2647599..57c5782 100644 --- a/AMWD.Protocols.Modbus.Tcp/Utils/SocketWrapper.cs +++ b/AMWD.Protocols.Modbus.Tcp/Utils/SocketWrapper.cs @@ -7,7 +7,7 @@ namespace AMWD.Protocols.Modbus.Tcp.Utils [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] internal class SocketWrapper : IDisposable { - [Obsolete("Constructor only for mocking on UnitTests!")] + [Obsolete("Constructor only for mocking on UnitTests!", error: true)] public SocketWrapper() { } diff --git a/AMWD.Protocols.Modbus.Tests/AMWD.Protocols.Modbus.Tests.csproj b/AMWD.Protocols.Modbus.Tests/AMWD.Protocols.Modbus.Tests.csproj index e1fa925..3f873f2 100644 --- a/AMWD.Protocols.Modbus.Tests/AMWD.Protocols.Modbus.Tests.csproj +++ b/AMWD.Protocols.Modbus.Tests/AMWD.Protocols.Modbus.Tests.csproj @@ -13,14 +13,14 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive - + - - + + diff --git a/AMWD.Protocols.Modbus.Tests/GlobalUsings.cs b/AMWD.Protocols.Modbus.Tests/GlobalUsings.cs index 381ca26..c0bf97d 100644 --- a/AMWD.Protocols.Modbus.Tests/GlobalUsings.cs +++ b/AMWD.Protocols.Modbus.Tests/GlobalUsings.cs @@ -1,4 +1,4 @@ -global using AMWD.Protocols.Modbus.Common; -global using Microsoft.VisualStudio.TestTools.UnitTesting; global using System; global using System.Linq; +global using AMWD.Protocols.Modbus.Common; +global using Microsoft.VisualStudio.TestTools.UnitTesting; diff --git a/AMWD.Protocols.Modbus.Tests/Tcp/ModbusTcpClientTest.cs b/AMWD.Protocols.Modbus.Tests/Tcp/ModbusTcpClientTest.cs new file mode 100644 index 0000000..762040a --- /dev/null +++ b/AMWD.Protocols.Modbus.Tests/Tcp/ModbusTcpClientTest.cs @@ -0,0 +1,124 @@ +using AMWD.Protocols.Modbus.Common.Contracts; +using AMWD.Protocols.Modbus.Tcp; +using Moq; + +namespace AMWD.Protocols.Modbus.Tests.Tcp +{ + [TestClass] + public class ModbusTcpClientTest + { + private Mock _genericConnectionMock; + private Mock _tcpConnectionMock; + + [TestInitialize] + public void Initialize() + { + _genericConnectionMock = new Mock(); + _tcpConnectionMock = new Mock(); + _tcpConnectionMock.Setup(c => c.Hostname).Returns("127.0.0.1"); + _tcpConnectionMock.Setup(c => c.Port).Returns(502); + _tcpConnectionMock.Setup(c => c.ReadTimeout).Returns(TimeSpan.FromSeconds(10)); + _tcpConnectionMock.Setup(c => c.WriteTimeout).Returns(TimeSpan.FromSeconds(20)); + _tcpConnectionMock.Setup(c => c.ReconnectTimeout).Returns(TimeSpan.FromSeconds(30)); + _tcpConnectionMock.Setup(c => c.KeepAliveInterval).Returns(TimeSpan.FromSeconds(40)); + } + + [TestMethod] + public void ShouldReturnDefaultValuesForGenericConnection() + { + // Arrange + var client = new ModbusTcpClient(_genericConnectionMock.Object); + + // Act + string hostname = client.Hostname; + int port = client.Port; + TimeSpan readTimeout = client.ReadTimeout; + TimeSpan writeTimeout = client.WriteTimeout; + TimeSpan reconnectTimeout = client.ReconnectTimeout; + TimeSpan keepAliveInterval = client.KeepAliveInterval; + + // Assert + Assert.IsNull(hostname); + Assert.AreEqual(0, port); + Assert.AreEqual(TimeSpan.Zero, readTimeout); + Assert.AreEqual(TimeSpan.Zero, writeTimeout); + Assert.AreEqual(TimeSpan.Zero, reconnectTimeout); + Assert.AreEqual(TimeSpan.Zero, keepAliveInterval); + + _genericConnectionMock.VerifyNoOtherCalls(); + } + + [TestMethod] + public void ShouldNotSetValuesForGenericConnection() + { + // Arrange + var client = new ModbusTcpClient(_genericConnectionMock.Object); + + // Act + client.Hostname = "localhost"; + client.Port = 205; + client.ReadTimeout = TimeSpan.FromSeconds(123); + client.WriteTimeout = TimeSpan.FromSeconds(456); + client.ReconnectTimeout = TimeSpan.FromSeconds(789); + client.KeepAliveInterval = TimeSpan.FromSeconds(321); + + // Assert + _genericConnectionMock.VerifyNoOtherCalls(); + } + + [TestMethod] + public void ShouldReturnValuesForTcpConnection() + { + // Arrange + var client = new ModbusTcpClient(_tcpConnectionMock.Object); + + // Act + string hostname = client.Hostname; + int port = client.Port; + TimeSpan readTimeout = client.ReadTimeout; + TimeSpan writeTimeout = client.WriteTimeout; + TimeSpan reconnectTimeout = client.ReconnectTimeout; + TimeSpan keepAliveInterval = client.KeepAliveInterval; + + // Assert + Assert.AreEqual("127.0.0.1", hostname); + Assert.AreEqual(502, port); + Assert.AreEqual(10, readTimeout.TotalSeconds); + Assert.AreEqual(20, writeTimeout.TotalSeconds); + Assert.AreEqual(30, reconnectTimeout.TotalSeconds); + Assert.AreEqual(40, keepAliveInterval.TotalSeconds); + + _tcpConnectionMock.VerifyGet(c => c.Hostname, Times.Once); + _tcpConnectionMock.VerifyGet(c => c.Port, Times.Once); + _tcpConnectionMock.VerifyGet(c => c.ReadTimeout, Times.Once); + _tcpConnectionMock.VerifyGet(c => c.WriteTimeout, Times.Once); + _tcpConnectionMock.VerifyGet(c => c.ReconnectTimeout, Times.Once); + _tcpConnectionMock.VerifyGet(c => c.KeepAliveInterval, Times.Once); + _tcpConnectionMock.VerifyNoOtherCalls(); + } + + [TestMethod] + public void ShouldSetValuesForTcpConnection() + { + // Arrange + var client = new ModbusTcpClient(_tcpConnectionMock.Object); + + // Act + client.Hostname = "localhost"; + client.Port = 205; + client.ReadTimeout = TimeSpan.FromSeconds(123); + client.WriteTimeout = TimeSpan.FromSeconds(456); + client.ReconnectTimeout = TimeSpan.FromSeconds(789); + client.KeepAliveInterval = TimeSpan.FromSeconds(321); + + // Assert + _tcpConnectionMock.VerifySet(c => c.Hostname = "localhost", Times.Once); + _tcpConnectionMock.VerifySet(c => c.Port = 205, Times.Once); + _tcpConnectionMock.VerifySet(c => c.ReadTimeout = TimeSpan.FromSeconds(123), Times.Once); + _tcpConnectionMock.VerifySet(c => c.WriteTimeout = TimeSpan.FromSeconds(456), Times.Once); + _tcpConnectionMock.VerifySet(c => c.ReconnectTimeout = TimeSpan.FromSeconds(789), Times.Once); + _tcpConnectionMock.VerifySet(c => c.KeepAliveInterval = TimeSpan.FromSeconds(321), Times.Once); + _tcpConnectionMock.VerifyNoOtherCalls(); + } + } +} diff --git a/AMWD.Protocols.Modbus.Tests/Tcp/Utils/ModbusTcpConnectionTest.cs b/AMWD.Protocols.Modbus.Tests/Tcp/Utils/ModbusTcpConnectionTest.cs new file mode 100644 index 0000000..22cf557 --- /dev/null +++ b/AMWD.Protocols.Modbus.Tests/Tcp/Utils/ModbusTcpConnectionTest.cs @@ -0,0 +1,722 @@ +using System.Collections.Generic; +using System.IO; +using System.Net; +using System.Net.Sockets; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; +using AMWD.Protocols.Modbus.Common.Contracts; +using AMWD.Protocols.Modbus.Tcp; +using AMWD.Protocols.Modbus.Tcp.Utils; +using Moq; + +namespace AMWD.Protocols.Modbus.Tests.Tcp.Utils +{ + [TestClass] + public class ModbusTcpConnectionTest + { + private string _hostname = "127.0.0.1"; + + private Mock _tcpClientMock; + private Mock _networkStreamMock; + private Mock _socketMock; + + private bool _clientIsAlwaysConnected; + private Queue _clientIsConnectedQueue; + + private int _clientReceiveTimeout = 1000; + private int _clientSendTimeout = 1000; + private Task _clientConnectTask = Task.CompletedTask; + + private List _networkRequestCallbacks; + + private Queue _networkResponseQueue; + + [TestInitialize] + public void Initialize() + { + _clientIsAlwaysConnected = true; + _clientIsConnectedQueue = new Queue(); + + _networkRequestCallbacks = []; + _networkResponseQueue = new Queue(); + } + + [TestMethod] + public void ShouldGetAndSetPropertiesOfBaseClient() + { + // Arrange + _clientIsAlwaysConnected = false; + _clientIsConnectedQueue.Enqueue(true); + var connection = GetTcpConnection(); + connection.GetType().GetField("_isConnected", BindingFlags.NonPublic | BindingFlags.Instance).SetValue(connection, true); + + // Act + connection.ReadTimeout = TimeSpan.FromSeconds(123); + connection.WriteTimeout = TimeSpan.FromSeconds(456); + + // Assert - part 1 + Assert.AreEqual(1, connection.ReadTimeout.TotalSeconds); + Assert.AreEqual(1, connection.WriteTimeout.TotalSeconds); + Assert.IsTrue(connection.IsConnected); + + // Assert - part 2 + _tcpClientMock.VerifySet(c => c.ReceiveTimeout = 123000, Times.Once); + _tcpClientMock.VerifySet(c => c.SendTimeout = 456000, Times.Once); + + _tcpClientMock.VerifyGet(c => c.ReceiveTimeout, Times.Once); + _tcpClientMock.VerifyGet(c => c.SendTimeout, Times.Once); + _tcpClientMock.VerifyGet(c => c.Connected, Times.Once); + + _socketMock.VerifyNoOtherCalls(); + _tcpClientMock.VerifyNoOtherCalls(); + _networkStreamMock.VerifyNoOtherCalls(); + } + + [DataTestMethod] + [DataRow(null)] + [DataRow("")] + [DataRow(" ")] + [ExpectedException(typeof(ArgumentNullException))] + public void ShouldThrowArumentNullExceptionForInvalidHostname(string hostname) + { + // Arrange + var connection = GetTcpConnection(); + + // Act + connection.Hostname = hostname; + + // Assert - ArgumentNullException + } + + [DataTestMethod] + [DataRow(0)] + [DataRow(65536)] + [ExpectedException(typeof(ArgumentOutOfRangeException))] + public void ShouldThrowArumentOutOfRangeExceptionForInvalidPort(int port) + { + // Arrange + var connection = GetTcpConnection(); + + // Act + connection.Port = port; + + // Assert - ArgumentOutOfRangeException + } + + [TestMethod] + public async Task ShouldConnectAsync() + { + // Arrange + var connection = GetConnection(); + + // Act + await connection.ConnectAsync(); + + // Assert + Assert.IsTrue(connection.IsConnected); + + _tcpClientMock.Verify(c => c.Close(), Times.Once); + _tcpClientMock.Verify(c => c.ConnectAsync(IPAddress.Loopback, 502, It.IsAny()), Times.Once); + _tcpClientMock.VerifyGet(c => c.ReceiveTimeout, Times.Once); + _tcpClientMock.VerifyGet(c => c.Connected, Times.Exactly(2)); + _tcpClientMock.VerifyGet(c => c.Client, Times.Exactly(3)); + + _socketMock.Verify(s => s.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.KeepAlive, false), Times.Once); + _socketMock.Verify(s => s.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveTime, 0), Times.Once); + _socketMock.Verify(s => s.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveInterval, 0), Times.Once); + + _socketMock.VerifyNoOtherCalls(); + _tcpClientMock.VerifyNoOtherCalls(); + _networkStreamMock.VerifyNoOtherCalls(); + } + + [TestMethod] + public async Task ShouldOnlyConnectAsyncOnce() + { + // Arrange + var connection = GetConnection(); + + await connection.ConnectAsync(); + ClearInvocations(); + + // Act + await connection.ConnectAsync(); + + // Assert + Assert.IsTrue(connection.IsConnected); + + _tcpClientMock.VerifyGet(c => c.Connected, Times.Once); + + _socketMock.VerifyNoOtherCalls(); + + _socketMock.VerifyNoOtherCalls(); + _tcpClientMock.VerifyNoOtherCalls(); + _networkStreamMock.VerifyNoOtherCalls(); + } + + [TestMethod] + [ExpectedException(typeof(ApplicationException))] + public async Task ShouldThrowApplicationExceptionHostnameNotResolvable() + { + // Arrange + _hostname = "device.internal"; + var connection = GetConnection(); + + // Act + await connection.ConnectAsync(); + + // Assert - ApplicationException + } + + [TestMethod] + public async Task ShouldRetryConnectAsync() + { + // Arrange + _clientIsAlwaysConnected = false; + _clientIsConnectedQueue.Enqueue(false); + _clientIsConnectedQueue.Enqueue(true); + _clientIsConnectedQueue.Enqueue(true); + var connection = GetConnection(); + + // Act + await connection.ConnectAsync(); + + // Assert + Assert.IsTrue(connection.IsConnected); + + _tcpClientMock.Verify(c => c.Close(), Times.Exactly(2)); + _tcpClientMock.Verify(c => c.ConnectAsync(IPAddress.Loopback, 502, It.IsAny()), Times.Exactly(2)); + _tcpClientMock.VerifyGet(c => c.ReceiveTimeout, Times.Exactly(2)); + _tcpClientMock.VerifyGet(c => c.Connected, Times.Exactly(3)); + _tcpClientMock.VerifyGet(c => c.Client, Times.Exactly(3)); + + _socketMock.Verify(s => s.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.KeepAlive, false), Times.Once); + _socketMock.Verify(s => s.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveTime, 0), Times.Once); + _socketMock.Verify(s => s.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveInterval, 0), Times.Once); + + _socketMock.VerifyNoOtherCalls(); + _tcpClientMock.VerifyNoOtherCalls(); + _networkStreamMock.VerifyNoOtherCalls(); + } + + [TestMethod] + [ExpectedException(typeof(SocketException))] + public async Task ShouldThrowSocketExceptionOnConnectAsyncForNoReconnect() + { + // Arrange + _clientIsAlwaysConnected = false; + _clientIsConnectedQueue.Enqueue(false); + var connection = GetTcpConnection(); + connection.ReconnectTimeout = TimeSpan.Zero; + + // Act + await connection.ConnectAsync(); + + // Assert - SocketException + } + + [TestMethod] + public async Task ShouldDisconnectAsync() + { + // Arrange + var connection = GetConnection(); + + await connection.ConnectAsync(); + ClearInvocations(); + + // Act + await connection.DisconnectAsync(); + + // Assert + Assert.IsFalse(connection.IsConnected); + + _tcpClientMock.Verify(c => c.Close(), Times.Once); + _tcpClientMock.VerifyNoOtherCalls(); + + _socketMock.VerifyNoOtherCalls(); + _tcpClientMock.VerifyNoOtherCalls(); + _networkStreamMock.VerifyNoOtherCalls(); + } + + [TestMethod] + public async Task ShouldOnlyDisconnectAsyncOnce() + { + // Arrange + var connection = GetConnection(); + + await connection.ConnectAsync(); + await connection.DisconnectAsync(); + ClearInvocations(); + + // Act + await connection.DisconnectAsync(); + + // Assert + Assert.IsFalse(connection.IsConnected); + + _socketMock.VerifyNoOtherCalls(); + _tcpClientMock.VerifyNoOtherCalls(); + _networkStreamMock.VerifyNoOtherCalls(); + } + + [TestMethod] + public async Task ShouldCallDisconnectOnDispose() + { + // Arrange + var connection = GetConnection(); + + await connection.ConnectAsync(); + ClearInvocations(); + + // Act + connection.Dispose(); + + // Assert + _tcpClientMock.Verify(c => c.Close(), Times.Once); + _tcpClientMock.Verify(c => c.Dispose(), Times.Once); + _tcpClientMock.VerifyNoOtherCalls(); + + _socketMock.VerifyNoOtherCalls(); + _tcpClientMock.VerifyNoOtherCalls(); + _networkStreamMock.VerifyNoOtherCalls(); + } + + [TestMethod] + public void ShouldAllowMultipleDispose() + { + // Arrange + var connection = GetConnection(); + + // Act + connection.Dispose(); + connection.Dispose(); + + // Assert + _tcpClientMock.Verify(c => c.Close(), Times.Once); + _tcpClientMock.Verify(c => c.Dispose(), Times.Once); + _tcpClientMock.VerifyNoOtherCalls(); + + _socketMock.VerifyNoOtherCalls(); + _tcpClientMock.VerifyNoOtherCalls(); + _networkStreamMock.VerifyNoOtherCalls(); + } + + [TestMethod] + [ExpectedException(typeof(ApplicationException))] + public async Task ShouldThrowApplicationExceptionOnInvokeAsyncWhileNotConnected() + { + // Arrange + var connection = GetConnection(); + + // Act + await connection.InvokeAsync(null, null); + + // Assert - ApplicationException + } + + [DataTestMethod] + [DataRow(null)] + [DataRow(new byte[0])] + [ExpectedException(typeof(ArgumentNullException))] + public async Task ShouldThrowArgumentNullExceptionOnInvokeAsyncForRequest(byte[] request) + { + // Arrange + var connection = GetConnection(); + await connection.ConnectAsync(); + + // Act + await connection.InvokeAsync(request, null); + + // Assert - ArgumentNullException + } + + [TestMethod] + [ExpectedException(typeof(ArgumentNullException))] + public async Task ShouldThrowArgumentNullExceptionOnInvokeAsyncForMissingValidation() + { + // Arrange + byte[] request = new byte[1]; + + var connection = GetConnection(); + await connection.ConnectAsync(); + + // Act + await connection.InvokeAsync(request, null); + + // Assert - ArgumentNullException + } + + [TestMethod] + public async Task ShouldInvokeAsync() + { + // Arrange + _networkResponseQueue.Enqueue([9, 8, 7]); + byte[] request = [1, 2, 3]; + var validation = new Func, bool>(_ => true); + + var connection = GetConnection(); + await connection.ConnectAsync(); + ClearInvocations(); + + // Act + var response = await connection.InvokeAsync(request, validation); + + // Assert + Assert.AreEqual(1, _networkRequestCallbacks.Count); + + CollectionAssert.AreEqual(new byte[] { 9, 8, 7 }, response.ToArray()); + CollectionAssert.AreEqual(request, _networkRequestCallbacks[0]); + + _tcpClientMock.Verify(c => c.Connected, Times.Once); + _tcpClientMock.Verify(c => c.GetStream(), Times.Once); + + _networkStreamMock.Verify(ns => ns.FlushAsync(It.IsAny()), Times.Once); + _networkStreamMock.Verify(ns => ns.WriteAsync(It.IsAny>(), It.IsAny()), Times.Once); + _networkStreamMock.Verify(ns => ns.ReadAsync(It.IsAny>(), It.IsAny()), Times.Once); + + _socketMock.VerifyNoOtherCalls(); + _tcpClientMock.VerifyNoOtherCalls(); + _networkStreamMock.VerifyNoOtherCalls(); + } + + [TestMethod] + [ExpectedException(typeof(EndOfStreamException))] + public async Task ShouldThrowEndOfStreamOnInvokeAsync() + { + // Arrange + byte[] request = [1, 2, 3]; + var validation = new Func, bool>(_ => true); + + var connection = GetConnection(); + await connection.ConnectAsync(); + ClearInvocations(); + + // Act + _ = await connection.InvokeAsync(request, validation); + + // Assert - EndOfStreamException + } + + [TestMethod] + [ExpectedException(typeof(TaskCanceledException))] + public async Task ShouldCancelOnInvokeAsyncOnDisconnect() + { + // Arrange + byte[] request = [1, 2, 3]; + var validation = new Func, bool>(_ => true); + + var connection = GetConnection(); + _networkStreamMock + .Setup(ns => ns.WriteAsync(It.IsAny>(), It.IsAny())) + .Returns(new ValueTask(Task.Delay(100))); + + await connection.ConnectAsync(); + ClearInvocations(); + + // Act + var task = connection.InvokeAsync(request, validation); + await connection.DisconnectAsync(); + await task; + + // Assert - TaskCanceledException + } + + [TestMethod] + [ExpectedException(typeof(TaskCanceledException))] + public async Task ShouldCancelOnInvokeAsyncOnAbort() + { + // Arrange + byte[] request = [1, 2, 3]; + var validation = new Func, bool>(_ => true); + var cts = new CancellationTokenSource(); + + var connection = GetConnection(); + _networkStreamMock + .Setup(ns => ns.WriteAsync(It.IsAny>(), It.IsAny())) + .Returns(new ValueTask(Task.Delay(100))); + + await connection.ConnectAsync(); + ClearInvocations(); + + // Act + var task = connection.InvokeAsync(request, validation, cts.Token); + cts.Cancel(); + await task; + + // Assert - TaskCanceledException + } + + [DataTestMethod] + [DataRow(typeof(IOException))] + [DataRow(typeof(SocketException))] + [DataRow(typeof(TimeoutException))] + [DataRow(typeof(InvalidOperationException))] + public async Task ShouldReconnectOnInvokeAsyncForExceptionType(Type exceptionType) + { + // Arrange + _networkResponseQueue.Enqueue([9, 8, 7]); + byte[] request = [1, 2, 3]; + var validation = new Func, bool>(_ => true); + + var connection = GetConnection(); + await connection.ConnectAsync(); + ClearInvocations(); + + _networkStreamMock + .Setup(ns => ns.WriteAsync(It.IsAny>(), It.IsAny())) + .Callback, CancellationToken>((req, _) => _networkRequestCallbacks.Add(req.ToArray())) + .ThrowsAsync((Exception)Activator.CreateInstance(exceptionType)); + + // Act + try + { + await connection.InvokeAsync(request, validation); + } + catch (Exception ex) + { + // Assert - part 1 + Assert.IsInstanceOfType(ex, exceptionType); + } + + // Assert - part 2 + Assert.AreEqual(1, _networkRequestCallbacks.Count); + CollectionAssert.AreEqual(request, _networkRequestCallbacks[0]); + + _tcpClientMock.Verify(c => c.Close(), Times.Once); + _tcpClientMock.Verify(c => c.ConnectAsync(IPAddress.Loopback, 502, It.IsAny()), Times.Once); + _tcpClientMock.VerifyGet(c => c.ReceiveTimeout, Times.Once); + _tcpClientMock.VerifyGet(c => c.Connected, Times.Exactly(2)); + _tcpClientMock.VerifyGet(c => c.Client, Times.Exactly(3)); + _tcpClientMock.Verify(c => c.GetStream(), Times.Once); + + _networkStreamMock.Verify(ns => ns.FlushAsync(It.IsAny()), Times.Once); + _networkStreamMock.Verify(ns => ns.WriteAsync(It.IsAny>(), It.IsAny()), Times.Once); + + _socketMock.Verify(s => s.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.KeepAlive, false), Times.Once); + _socketMock.Verify(s => s.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveTime, 0), Times.Once); + _socketMock.Verify(s => s.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveInterval, 0), Times.Once); + + _socketMock.VerifyNoOtherCalls(); + _tcpClientMock.VerifyNoOtherCalls(); + _networkStreamMock.VerifyNoOtherCalls(); + } + + [TestMethod] + public async Task ShouldReturnWithUnknownExceptionOnInvokeAsync() + { + // Arrange + byte[] request = [1, 2, 3]; + var validation = new Func, bool>(_ => true); + + var connection = GetConnection(); + await connection.ConnectAsync(); + ClearInvocations(); + + _networkStreamMock + .Setup(ns => ns.WriteAsync(It.IsAny>(), It.IsAny())) + .Callback, CancellationToken>((req, _) => _networkRequestCallbacks.Add(req.ToArray())) + .ThrowsAsync(new NotImplementedException()); + + // Act + try + { + await connection.InvokeAsync(request, validation); + } + catch (Exception ex) + { + // Assert - part 1 + Assert.IsInstanceOfType(ex, typeof(NotImplementedException)); + } + + // Assert - part 2 + Assert.AreEqual(1, _networkRequestCallbacks.Count); + CollectionAssert.AreEqual(request, _networkRequestCallbacks[0]); + + _tcpClientMock.Verify(c => c.Connected, Times.Once); + _tcpClientMock.Verify(c => c.GetStream(), Times.Once); + + _networkStreamMock.Verify(ns => ns.FlushAsync(It.IsAny()), Times.Once); + _networkStreamMock.Verify(ns => ns.WriteAsync(It.IsAny>(), It.IsAny()), Times.Once); + + _socketMock.VerifyNoOtherCalls(); + _tcpClientMock.VerifyNoOtherCalls(); + _networkStreamMock.VerifyNoOtherCalls(); + } + + [TestMethod] + public async Task ShouldRemoveRequestFromQueueOnInvokeAsync() + { + // Arrange + _networkResponseQueue.Enqueue([9, 8, 7]); + byte[] request = [1, 2, 3]; + var validation = new Func, bool>(_ => true); + + var connection = GetConnection(); + await connection.ConnectAsync(); + ClearInvocations(); + + _networkStreamMock + .Setup(ns => ns.WriteAsync(It.IsAny>(), It.IsAny())) + .Callback, CancellationToken>((req, _) => _networkRequestCallbacks.Add(req.ToArray())) + .Returns(new ValueTask(Task.Delay(100))); + + var cts = new CancellationTokenSource(); + + // Act + var taskToComplete = connection.InvokeAsync(request, validation); + + var taskToCancel = connection.InvokeAsync(request, validation, cts.Token); + cts.Cancel(); + + var response = await taskToComplete; + + // Assert + try + { + await taskToCancel; + Assert.Fail(); + } + catch (TaskCanceledException) + { /* expected exception */ } + + Assert.AreEqual(1, _networkRequestCallbacks.Count); + + CollectionAssert.AreEqual(new byte[] { 9, 8, 7 }, response.ToArray()); + CollectionAssert.AreEqual(request, _networkRequestCallbacks[0]); + + _tcpClientMock.Verify(c => c.Connected, Times.Exactly(2)); + _tcpClientMock.Verify(c => c.GetStream(), Times.Once); + + _networkStreamMock.Verify(ns => ns.FlushAsync(It.IsAny()), Times.Once); + _networkStreamMock.Verify(ns => ns.WriteAsync(It.IsAny>(), It.IsAny()), Times.Once); + _networkStreamMock.Verify(ns => ns.ReadAsync(It.IsAny>(), It.IsAny()), Times.Once); + + _socketMock.VerifyNoOtherCalls(); + _tcpClientMock.VerifyNoOtherCalls(); + _networkStreamMock.VerifyNoOtherCalls(); + } + + [TestMethod] + public async Task ShouldCancelQueuedRequestOnDisconnect() + { + // Arrange + _networkResponseQueue.Enqueue([9, 8, 7]); + byte[] request = [1, 2, 3]; + var validation = new Func, bool>(_ => true); + + var connection = GetConnection(); + await connection.ConnectAsync(); + ClearInvocations(); + + _networkStreamMock + .Setup(ns => ns.WriteAsync(It.IsAny>(), It.IsAny())) + .Callback, CancellationToken>((req, _) => _networkRequestCallbacks.Add(req.ToArray())) + .Returns(new ValueTask(Task.Delay(100))); + + var cts = new CancellationTokenSource(); + + // Act + var taskToCancel = connection.InvokeAsync(request, validation); + var taskToDequeue = connection.InvokeAsync(request, validation); + await connection.DisconnectAsync(); + + // Assert + try + { + await taskToCancel; + Assert.Fail(); + } + catch (TaskCanceledException ex) + { + /* expected exception */ + Assert.AreNotEqual(CancellationToken.None, ex.CancellationToken); + } + + try + { + await taskToDequeue; + Assert.Fail(); + } + catch (TaskCanceledException ex) + { + /* expected exception */ + Assert.AreEqual(CancellationToken.None, ex.CancellationToken); + } + + Assert.AreEqual(1, _networkRequestCallbacks.Count); + CollectionAssert.AreEqual(request, _networkRequestCallbacks[0]); + + _tcpClientMock.Verify(c => c.Connected, Times.Exactly(2)); + _tcpClientMock.Verify(c => c.GetStream(), Times.Once); + _tcpClientMock.Verify(c => c.Close(), Times.Once); + + _networkStreamMock.Verify(ns => ns.FlushAsync(It.IsAny()), Times.Once); + _networkStreamMock.Verify(ns => ns.WriteAsync(It.IsAny>(), It.IsAny()), Times.Once); + + _socketMock.VerifyNoOtherCalls(); + _tcpClientMock.VerifyNoOtherCalls(); + _networkStreamMock.VerifyNoOtherCalls(); + } + + private IModbusConnection GetConnection() + => GetTcpConnection(); + + private ModbusTcpConnection GetTcpConnection() + { + _networkStreamMock = new Mock(); + _networkStreamMock + .Setup(ns => ns.WriteAsync(It.IsAny>(), It.IsAny())) + .Callback, CancellationToken>((req, _) => _networkRequestCallbacks.Add(req.ToArray())) + .Returns(ValueTask.CompletedTask); + _networkStreamMock + .Setup(ns => ns.ReadAsync(It.IsAny>(), It.IsAny())) + .Returns, CancellationToken>((buffer, _) => + { + if (_networkResponseQueue.TryDequeue(out byte[] bytes)) + { + bytes.CopyTo(buffer); + return ValueTask.FromResult(bytes.Length); + } + + return ValueTask.FromResult(0); + }); + + _socketMock = new Mock(); + + _tcpClientMock = new Mock(); + _tcpClientMock.Setup(c => c.Client).Returns(() => _socketMock.Object); + _tcpClientMock.Setup(c => c.Connected).Returns(() => _clientIsAlwaysConnected || _clientIsConnectedQueue.Dequeue()); + _tcpClientMock.Setup(c => c.ReceiveTimeout).Returns(() => _clientReceiveTimeout); + _tcpClientMock.Setup(c => c.SendTimeout).Returns(() => _clientSendTimeout); + + _tcpClientMock + .Setup(c => c.ConnectAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(() => _clientConnectTask); + + _tcpClientMock + .Setup(c => c.GetStream()) + .Returns(() => _networkStreamMock.Object); + + var connection = new ModbusTcpConnection + { + Hostname = _hostname, + Port = 502 + }; + + // Replace real TCP client with mock + var clientField = connection.GetType().GetField("_client", BindingFlags.NonPublic | BindingFlags.Instance); + (clientField.GetValue(connection) as TcpClientWrapper)?.Dispose(); + clientField.SetValue(connection, _tcpClientMock.Object); + + return connection; + } + + private void ClearInvocations() + { + _networkStreamMock.Invocations.Clear(); + _socketMock.Invocations.Clear(); + _tcpClientMock.Invocations.Clear(); + } + } +}