From 21c60aa5d01b3000b2e3042554bddde38229ae02 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 08:11:19 +0000 Subject: [PATCH] Update tests to Rony.Net 1.5.0 and add PLC simulator tests Use the Rony.Net.Xunit base class so every test gets its own mock server on a free port, a custom framing that splits Cimon frames by their length field, and a PLC simulator that keeps word and bit memory. Add tests for connection reuse and closing, concurrent requests, PLC error codes and network failures (chunked, slow, truncated, corrupted, missing and dropped responses, connection resets and refused connections). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SJRGqfjK42eQkM5T9CiCGn --- CHANGELOG.md | 9 + README.md | 24 +- .../CimonPlc.UnitTests.csproj | 3 +- .../CimonPlc/EthernetConnectionTests.cs | 150 +++++++++ .../CimonPlc/EthernetConnectorTests.cs | 291 ++++++++++++------ .../CimonPlc/EthernetFailureTests.cs | 243 +++++++++++++++ .../CimonPlc/EthernetProtocolTests.cs | 22 -- .../Simulators/CimonFraming.cs | 43 +++ .../Simulators/CimonPlcSimulator.cs | 124 ++++++++ .../Simulators/PlcMockServerTest.cs | 39 +++ 10 files changed, 830 insertions(+), 118 deletions(-) create mode 100644 tests/CimonPlc.UnitTests/CimonPlc/EthernetConnectionTests.cs create mode 100644 tests/CimonPlc.UnitTests/CimonPlc/EthernetFailureTests.cs create mode 100644 tests/CimonPlc.UnitTests/Simulators/CimonFraming.cs create mode 100644 tests/CimonPlc.UnitTests/Simulators/CimonPlcSimulator.cs create mode 100644 tests/CimonPlc.UnitTests/Simulators/PlcMockServerTest.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index f56c426..4f15511 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## Unreleased + +### Tests +- Updated the test mock server to [Rony.Net](https://github.com/archofthings/Rony.Net) 1.5.0 and `Rony.Net.Xunit`. Every test gets its own server on a free port, + so tests no longer share fixed ports, and the server log is written to the test output. +- Added a simulated PLC (`CimonPlcSimulator`) that keeps word and bit memory, and a `CimonFraming` that splits requests by the frame length field. +- New tests check the exact frames sent to the PLC, values written and read back, connection reuse and closing, concurrent requests, + PLC error codes, and network failures (chunked, slow, truncated, corrupted, missing and dropped responses, connection resets and refused connections). + ## 2.0.0 ### Fixed diff --git a/README.md b/README.md index 2d86a01..c257860 100644 --- a/README.md +++ b/README.md @@ -211,8 +211,28 @@ dotnet build dotnet test ``` -`EthernetConnector` tests run against a local mock TCP server built with [Rony.Net](https://github.com/archofthings/Rony.Net). -`SerialConnector` tests use a fake serial socket, so no device is needed. +No PLC is needed. `EthernetConnector` tests talk over real TCP sockets to a PLC simulated with the +[Rony.Net](https://github.com/archofthings/Rony.Net) mock server, and `SerialConnector` tests use a fake serial socket. + +The Ethernet tests are also a sample of testing a binary protocol client with Rony.Net: + +- [`PlcMockServerTest`](https://github.com/archofthings/Cimon.Net/blob/main/tests/CimonPlc.UnitTests/Simulators/PlcMockServerTest.cs) uses `Rony.Net.Xunit` to give every test its own server on a free port, with its log in the test output. +- [`CimonFraming`](https://github.com/archofthings/Cimon.Net/blob/main/tests/CimonPlc.UnitTests/Simulators/CimonFraming.cs) is a custom `IMessageFraming` that splits the TCP stream into Cimon frames. +- [`CimonPlcSimulator`](https://github.com/archofthings/Cimon.Net/blob/main/tests/CimonPlc.UnitTests/Simulators/CimonPlcSimulator.cs) answers like a PLC and keeps its memory, so written values can be read back. +- [`EthernetFailureTests`](https://github.com/archofthings/Cimon.Net/blob/main/tests/CimonPlc.UnitTests/CimonPlc/EthernetFailureTests.cs) simulates slow, chunked, truncated, corrupted and dropped responses. + +```csharp +Plc.Attach(Server); // answer like a PLC +using var connector = CreateConnector(); // EthernetConnector for 127.0.0.1:Server.Port + +await connector.WriteWordAsync(MemoryType.D, "100", 1, 2, 3); +var (code, words) = await connector.ReadWordAsync(MemoryType.D, "100", 3); // [1, 2, 3] + +Server.Should().HaveAcceptedConnections(Times.Exactly(2)) // autoConnect: one connection per request + .And.HaveReceivedInOrder( + r => r.Body[10] == (byte)WriteCommands.WordBlockWrite, + r => r.Body[10] == (byte)ReadCommand.WordBlockRead); +``` ## Contributing diff --git a/tests/CimonPlc.UnitTests/CimonPlc.UnitTests.csproj b/tests/CimonPlc.UnitTests/CimonPlc.UnitTests.csproj index a8c99e0..b0ee65c 100644 --- a/tests/CimonPlc.UnitTests/CimonPlc.UnitTests.csproj +++ b/tests/CimonPlc.UnitTests/CimonPlc.UnitTests.csproj @@ -8,7 +8,8 @@ - + + runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/tests/CimonPlc.UnitTests/CimonPlc/EthernetConnectionTests.cs b/tests/CimonPlc.UnitTests/CimonPlc/EthernetConnectionTests.cs new file mode 100644 index 0000000..3d3b7a9 --- /dev/null +++ b/tests/CimonPlc.UnitTests/CimonPlc/EthernetConnectionTests.cs @@ -0,0 +1,150 @@ +using System.Linq; +using System.Net.Sockets; +using System.Threading.Tasks; +using CimonPlc.Enums; +using CimonPlc.UnitTests.Simulators; +using Rony.Net; +using Xunit; +using Xunit.Abstractions; + +namespace CimonPlc.UnitTests +{ + /// + /// How opens, reuses and closes TCP connections, + /// checked on the mock server side with Rony.Net connection records. + /// + public class EthernetConnectionTests : PlcMockServerTest + { + public EthernetConnectionTests(ITestOutputHelper output) : base(output) + { + Plc.Attach(Server); + } + + [Fact] + public async Task Explicit_Connection_Should_Be_Reused_For_Every_Request() + { + //Arrange + using var connector = CreateConnector(); + await connector.Connect(); + + //Act + await connector.WriteWordAsync(MemoryType.D, "0", 1); + await connector.ReadWordAsync(MemoryType.D, "0", 1); + await connector.WriteBitAsync(MemoryType.M, "0", 1); + + //Assert + Assert.True(connector.IsConnected); + Server.Should().HaveAcceptedConnections(Times.Once()); + Server.Connections[0].Should() + .HaveReceived(r => true, Times.Exactly(3)) + .And.HaveReceivedInOrder( + r => r.Body[10] == (byte)WriteCommands.WordBlockWrite, + r => r.Body[10] == (byte)ReadCommand.WordBlockRead, + r => r.Body[10] == (byte)WriteCommands.BitBlockWrite); + } + + [Fact] + public async Task Disconnect_Should_Close_The_Connection() + { + //Arrange + using var connector = CreateConnector(); + await connector.Connect(); + await Server.WaitForConnectionAsync(); + + //Act + var result = connector.Disconnect(); + + //Assert + Assert.Equal(ConnectionStatus.DisConnected, result); + await Server.Connections[0].WaitForCloseAsync(); + Server.Should().HaveNoOpenConnections(); + } + + [Fact] + public async Task Dispose_Should_Close_The_Connection() + { + //Arrange + var connector = CreateConnector(); + await connector.Connect(); + await Server.WaitForConnectionAsync(); + + //Act + connector.Dispose(); + + //Assert + await Server.WaitForAllConnectionsClosedAsync(); + Server.Should().HaveNoOpenConnections(); + } + + [Fact] + public async Task AutoConnect_Should_Open_And_Close_A_Connection_For_Every_Request() + { + //Arrange + using var connector = CreateConnector(); + + //Act + var first = await connector.WriteWordAsync(MemoryType.D, "0", 1); + var second = await connector.WriteWordAsync(MemoryType.D, "0", 2); + + //Assert + Assert.Equal(ResponseCode.Success, first); + Assert.Equal(ResponseCode.Success, second); + Assert.False(connector.IsConnected); + await Server.WaitForAllConnectionsClosedAsync(); + Server.Should().HaveAcceptedConnections(Times.Exactly(2)).And.HaveNoOpenConnections(); + Assert.All(Server.Connections, c => c.Should().HaveReceived(r => true, Times.Once())); + } + + [Fact] + public async Task Operations_Should_Not_Connect_Without_AutoConnect() + { + //Arrange + using var connector = CreateConnector(autoConnect: false); + + //Act + var result = await connector.WriteWordAsync(MemoryType.D, "0", 1); + + //Assert + Assert.Equal(ResponseCode.SystemError, result); + Server.Should().HaveAcceptedConnections(Times.Never()); + } + + [Fact] + public async Task Concurrent_Requests_Should_Be_Sent_One_At_A_Time() + { + //Arrange + // Word addresses must end with 0, so request i reads address i * 16 + for (var i = 0; i < 20; i++) + Plc.SetWord(MemoryType.D, i * 16, i); + using var connector = CreateConnector(); + await connector.Connect(); + + //Act + var results = await Task.WhenAll(Enumerable.Range(0, 20).Select(i => connector.ReadWordAsync(MemoryType.D, $"{i:X}0", 1))); + + //Assert : every answer belongs to its own request + Assert.All(results, x => Assert.Equal(ResponseCode.Success, x.responseCode)); + Assert.Equal(Enumerable.Range(0, 20), results.Select(x => x.data.Single())); + Server.Should().HaveAcceptedConnections(Times.Once()); + Assert.Equal(20, Server.ReceivedRequests.Select(r => r.Body[9]).Distinct().Count()); + } + + [Fact] + public async Task Connect_Should_Throw_While_Plc_Refuses_Connections() + { + //Arrange + using var connector = CreateConnector(); + Server.RefuseConnections(); + + //Assert + await Assert.ThrowsAnyAsync(() => connector.Connect()); + + //Act : the PLC is back + Server.AcceptConnections(); + var result = await connector.Connect(); + + //Assert + Assert.Equal(ConnectionStatus.Connected, result); + } + } +} diff --git a/tests/CimonPlc.UnitTests/CimonPlc/EthernetConnectorTests.cs b/tests/CimonPlc.UnitTests/CimonPlc/EthernetConnectorTests.cs index a3d7608..718552f 100644 --- a/tests/CimonPlc.UnitTests/CimonPlc/EthernetConnectorTests.cs +++ b/tests/CimonPlc.UnitTests/CimonPlc/EthernetConnectorTests.cs @@ -1,19 +1,26 @@ using System; +using System.Linq; +using System.Text; using System.Threading.Tasks; -using Xunit; -using CimonPlc.PlcConnectors; using CimonPlc.Enums; +using CimonPlc.PlcConnectors; using CimonPlc.Sockets; +using CimonPlc.UnitTests.Simulators; using Rony.Net; -using Rony.Listeners; -using System.Collections.Generic; -using System.Text; -using System.Linq; +using Xunit; +using Xunit.Abstractions; namespace CimonPlc.UnitTests { - public class EthernetConnectorTests + /// + /// Reads and writes of against a PLC simulated with a Rony.Net mock server. + /// + public class EthernetConnectorTests : PlcMockServerTest { + public EthernetConnectorTests(ITestOutputHelper output) : base(output) + { + } + [Theory] [InlineData(100, 100, 100)] [InlineData(1000, 1000, 500)] @@ -22,16 +29,16 @@ public class EthernetConnectorTests [InlineData(1000, 1000, 5000)] public async Task Connect_Should_Return_Connected_On_Valid_Data(int readTimeout, int writeTimeout, int pingTimeout) { - //Arrange - var connector = new EthernetConnector(new TcpSocket("127.0.0.1", 10619)); - using var mockServer = new MockServer(new TcpServer(10619)); - mockServer.Start(); + //Arrange (ping enabled, the loopback address answers it) + using var connector = new EthernetConnector(new TcpSocket("127.0.0.1", Server.Port)); //Act var result = await connector.Connect(readTimeout, writeTimeout, pingTimeout); - mockServer.Stop(); + //Assert Assert.Equal(ConnectionStatus.Connected, result); + await Server.WaitForConnectionAsync(); + Server.Should().HaveAcceptedConnections(Times.Once()); } [Theory] @@ -42,8 +49,8 @@ public async Task Connect_Should_Return_Connected_On_Valid_Data(int readTimeout, [InlineData(1000, 1000, 0)] public async Task Connect_Should_Return_Error_On_Incorrect_Data(int readTimeout, int writeTimeout, int pingTimeout) { - //Arrange - var connector = new EthernetConnector(new TcpSocket("127.0.0.1", 10620)); + //Arrange + using var connector = new EthernetConnector(new TcpSocket("127.0.0.1")); //Assert await Assert.ThrowsAnyAsync(() => connector.Connect(readTimeout, writeTimeout, pingTimeout)); @@ -55,34 +62,23 @@ public async Task Connect_Should_Return_Error_On_Incorrect_Data(int readTimeout, [InlineData(MemoryType.D, "000F0", 6)] [InlineData(MemoryType.M, "0F010", 100)] [InlineData(MemoryType.L, "000F10", 100)] - public async Task ReadWordAsync_Should_Return_Value_On_Correct_Data(MemoryType memoryType, string address, int length) + public async Task ReadWordAsync_Should_Return_Plc_Memory(MemoryType memoryType, string address, int length) { - //Arrange - var connector = new EthernetConnector(new TcpSocket("127.0.0.1", 10620)); - using var mockServer = new MockServer(new TcpServer(10620)); - mockServer.Start(); + //Arrange + var start = Convert.ToInt32(address, 16); + var expected = Enumerable.Range(0, length).Select(x => (x * 257 + 1) & 0xFFFF).ToArray(); + for (var i = 0; i < length; i++) + Plc.SetWord(memoryType, start + i, expected[i]); + Plc.Attach(Server); + using var connector = CreateConnector(); //Act - mockServer.Mock.Send("").Receive(x => - { - var response = new List(); - response.AddRange(Encoding.ASCII.GetBytes("KDT_PLC_S")); - response.Add((byte)(x[9] + 128)); - response.Add(x[10]); - response.Add(0); - response.AddRange((9 + length * 2).ToDualByte()); - response.AddRange(new byte[] { 0, 0, 0, 0, 0, 0, 0, 0, 0 }); - for (var i = 0; i < length; i++) - response.AddRange(new byte[] { 1, 1 }); - response.AddRange(response.Sum(x => x).ToDualByte()); - return response.ToArray(); - }); var (responseCode, data) = await connector.ReadWordAsync(memoryType, address, length); - mockServer.Stop(); //Assert Assert.Equal(ResponseCode.Success, responseCode); - Assert.Equal(length, data.Length); + Assert.Equal(expected, data); + Server.Should().HaveReceived(EthernetConnector.BuildFrame(0, (byte)ReadCommand.WordBlockRead, memoryType, address, length), Times.Once()); } [Theory] @@ -93,11 +89,12 @@ public async Task ReadWordAsync_Should_Return_Value_On_Correct_Data(MemoryType m [InlineData(MemoryType.Y, "000F1", 0)] public async Task ReadWordAsync_Should_Return_Error_On_Incorrect_Data(MemoryType memoryType, string address, int length) { - //Arrange - var connector = new EthernetConnector(new TcpSocket("127.0.0.1", 10620)); + //Arrange + using var connector = CreateConnector(); //Assert await Assert.ThrowsAnyAsync(() => connector.ReadWordAsync(memoryType, address, length)); + Assert.Empty(Server.ReceivedRequests); } [Theory] @@ -106,34 +103,23 @@ public async Task ReadWordAsync_Should_Return_Error_On_Incorrect_Data(MemoryType [InlineData(MemoryType.D, "000F5", 6)] [InlineData(MemoryType.M, "0F01", 100)] [InlineData(MemoryType.L, "000F1", 100)] - public async Task ReadBitAsync_Should_Return_Value_On_Correct_Data(MemoryType memoryType, string address, int length) + public async Task ReadBitAsync_Should_Return_Plc_Memory(MemoryType memoryType, string address, int length) { //Arrange - var connector = new EthernetConnector(new TcpSocket("127.0.0.1", 10621)); - using var mockServer = new MockServer(new TcpServer(10621)); - mockServer.Start(); + var start = Convert.ToInt32(address, 16); + var expected = Enumerable.Range(0, length).Select(x => (byte)(x % 3 == 0 ? 1 : 0)).ToArray(); + for (var i = 0; i < length; i++) + Plc.SetBit(memoryType, start + i, expected[i]); + Plc.Attach(Server); + using var connector = CreateConnector(); //Act - mockServer.Mock.Send("").Receive(x => - { - var response = new List(); - response.AddRange(Encoding.ASCII.GetBytes("KDT_PLC_S")); - response.Add((byte)(x[9] + 128)); - response.Add(x[10]); - response.Add(0); - response.AddRange((9 + length).ToDualByte()); - response.AddRange(new byte[] { 0, 0, 0, 0, 0, 0, 0, 0, 0 }); - for (var i = 0; i < length; i++) - response.Add(1); - response.AddRange(response.Sum(x => x).ToDualByte()); - return response.ToArray(); - }); var (responseCode, data) = await connector.ReadBitAsync(memoryType, address, length); - mockServer.Stop(); //Assert Assert.Equal(ResponseCode.Success, responseCode); - Assert.Equal(length, data.Length); + Assert.Equal(expected, data); + Server.Should().HaveReceived(EthernetConnector.BuildFrame(0, (byte)ReadCommand.BitBlockRead, memoryType, address, length), Times.Once()); } [Theory] @@ -144,10 +130,11 @@ public async Task ReadBitAsync_Should_Return_Value_On_Correct_Data(MemoryType me public async Task ReadBitAsync_Should_Return_Error_On_Incorrect_Data(MemoryType memoryType, string address, int length) { //Arrange - var connector = new EthernetConnector(new TcpSocket("127.0.0.1", 10620)); + using var connector = CreateConnector(); //Assert await Assert.ThrowsAnyAsync(() => connector.ReadBitAsync(memoryType, address, length)); + Assert.Empty(Server.ReceivedRequests); } [Theory] @@ -156,30 +143,20 @@ public async Task ReadBitAsync_Should_Return_Error_On_Incorrect_Data(MemoryType [InlineData(MemoryType.D, "000F0", 16050)] [InlineData(MemoryType.M, "0F010", 100, 100, 10000, 1200, 1400)] [InlineData(MemoryType.L, "000F0", 1010, 65000, 3403, 2302)] - public async Task WriteWordAsync_Should_Return_Value_On_Correct_Data(MemoryType memoryType, string address, params int[] data) + public async Task WriteWordAsync_Should_Store_Data_In_Plc_Memory(MemoryType memoryType, string address, params int[] data) { //Arrange - var connector = new EthernetConnector(new TcpSocket("127.0.0.1", 10622)); - using var mockServer = new MockServer(new TcpServer(10622)); - mockServer.Start(); + Plc.Attach(Server); + using var connector = CreateConnector(); //Act - mockServer.Mock.Send("").Receive(x => - { - var response = new List(); - response.AddRange(Encoding.ASCII.GetBytes("KDT_PLC_S")); - response.Add((byte)(x[9] + 128)); - response.Add(0x41); - response.Add(0); - response.AddRange(new byte[] { 0, 2, 0, 0 }); - response.AddRange(response.Sum(x => x).ToDualByte()); - return response.ToArray(); - }); var result = await connector.WriteWordAsync(memoryType, address, data); - mockServer.Stop(); //Assert Assert.Equal(ResponseCode.Success, result); + var start = Convert.ToInt32(address, 16); + Assert.Equal(data, Enumerable.Range(start, data.Length).Select(x => Plc.GetWord(memoryType, x))); + Server.Should().HaveReceived(r => r.Body[10] == (byte)WriteCommands.WordBlockWrite, Times.Once()); } [Theory] @@ -191,43 +168,33 @@ public async Task WriteWordAsync_Should_Return_Value_On_Correct_Data(MemoryType public async Task WriteWordAsync_Should_Return_Error_On_Incorrect_Data(MemoryType memoryType, string address, params int[] data) { //Arrange - var connector = new EthernetConnector(new TcpSocket("127.0.0.1", 10620)); + using var connector = CreateConnector(); //Assert await Assert.ThrowsAnyAsync(() => connector.WriteWordAsync(memoryType, address, data)); + Assert.Empty(Server.ReceivedRequests); } - [Theory] [InlineData(MemoryType.X, "000F0", (byte)1, (byte)1, (byte)1)] [InlineData(MemoryType.Y, "0", (byte)1, (byte)1, (byte)1, (byte)1)] [InlineData(MemoryType.D, "000F1", (byte)1, (byte)1)] [InlineData(MemoryType.M, "0F011", (byte)1, (byte)1, (byte)0, (byte)1, (byte)1)] [InlineData(MemoryType.L, "000F0", (byte)1, (byte)1, (byte)1, (byte)0)] - public async Task WriteBitAsync_Should_Return_Value_On_Correct_Data(MemoryType memoryType, string address, params byte[] data) + public async Task WriteBitAsync_Should_Store_Data_In_Plc_Memory(MemoryType memoryType, string address, params byte[] data) { //Arrange - var connector = new EthernetConnector(new TcpSocket("127.0.0.1", 10623)); - using var mockServer = new MockServer(new TcpServer(10623)); - mockServer.Start(); + Plc.Attach(Server); + using var connector = CreateConnector(); //Act - mockServer.Mock.Send("").Receive(x => - { - var response = new List(); - response.AddRange(Encoding.ASCII.GetBytes("KDT_PLC_S")); - response.Add((byte)(x[9] + 128)); - response.Add(0x41); - response.Add(0); - response.AddRange(new byte[] { 0, 2, 0, 0 }); - response.AddRange(response.Sum(x => x).ToDualByte()); - return response.ToArray(); - }); var result = await connector.WriteBitAsync(memoryType, address, data); - mockServer.Stop(); //Assert Assert.Equal(ResponseCode.Success, result); + var start = Convert.ToInt32(address, 16); + Assert.Equal(data, Enumerable.Range(start, data.Length).Select(x => Plc.GetBit(memoryType, x))); + Server.Should().HaveReceived(r => r.Body[10] == (byte)WriteCommands.BitBlockWrite, Times.Once()); } [Theory] @@ -238,10 +205,148 @@ public async Task WriteBitAsync_Should_Return_Value_On_Correct_Data(MemoryType m public async Task WriteBitAsync_Should_Return_Error_On_Incorrect_Data(MemoryType memoryType, string address, params byte[] data) { //Arrange - var connector = new EthernetConnector(new TcpSocket("127.0.0.1", 10623)); + using var connector = CreateConnector(); //Assert await Assert.ThrowsAnyAsync(() => connector.WriteBitAsync(memoryType, address, data)); + Assert.Empty(Server.ReceivedRequests); + } + + [Fact] + public async Task Written_Words_Should_Be_Read_Back() + { + //Arrange + Plc.Attach(Server); + using var connector = CreateConnector(); + await connector.Connect(); + + //Act + var writeResult = await connector.WriteWordAsync(MemoryType.D, "100", 1, 2, 0xFFFF, 0); + var (readResult, data) = await connector.ReadWordAsync(MemoryType.D, "100", 4); + + //Assert + Assert.Equal(ResponseCode.Success, writeResult); + Assert.Equal(ResponseCode.Success, readResult); + Assert.Equal(new[] { 1, 2, 0xFFFF, 0 }, data); + Server.Should().HaveReceivedInOrder( + r => r.Body[10] == (byte)WriteCommands.WordBlockWrite, + r => r.Body[10] == (byte)ReadCommand.WordBlockRead); + } + + [Fact] + public async Task Written_Bits_Should_Be_Read_Back() + { + //Arrange + Plc.Attach(Server); + using var connector = CreateConnector(); + await connector.Connect(); + + //Act + var writeResult = await connector.WriteBitAsync(MemoryType.M, "10", 1, 0, 1, 1); + var (readResult, data) = await connector.ReadBitAsync(MemoryType.M, "10", 4); + + //Assert + Assert.Equal(ResponseCode.Success, writeResult); + Assert.Equal(ResponseCode.Success, readResult); + Assert.Equal(new byte[] { 1, 0, 1, 1 }, data); + } + + [Fact] + public async Task Requests_Should_Carry_Normalized_Address() + { + //Arrange + Plc.Attach(Server); + using var connector = CreateConnector(); + + //Act + await connector.ReadWordAsync(MemoryType.D, "a0", 1); + + //Assert : 6 upper case hex chars, after the memory type and a 0 byte + var request = Assert.Single(Server.ReceivedRequests); + Assert.Equal((byte)MemoryType.D, request.Body[14]); + Assert.Equal("0000A0", Encoding.ASCII.GetString(request.Body, 16, 6)); + } + + [Fact] + public async Task Frame_Numbers_Should_Increase_With_Every_Request() + { + //Arrange + Plc.Attach(Server); + using var connector = CreateConnector(); + await connector.Connect(); + + //Act + for (var i = 0; i < 5; i++) + Assert.Equal(ResponseCode.Success, await connector.WriteWordAsync(MemoryType.D, "0", i)); + + //Assert + Assert.Equal(new byte[] { 0, 1, 2, 3, 4 }, Server.ReceivedRequests.Select(r => r.Body[9])); + } + + [Theory] + [InlineData(ResponseCode.InvalidDevicePrefix)] + [InlineData(ResponseCode.InvalidDeviceAddress)] + [InlineData(ResponseCode.ChecksumError)] + [InlineData(ResponseCode.CpuError)] + public async Task Operations_Should_Return_Error_Code_Sent_By_Plc(ResponseCode code) + { + //Arrange + Server.Mock.Send("").Receive(request => CimonPlcSimulator.Ack(request, code)); + using var connector = CreateConnector(); + + //Act + var (readResult, data) = await connector.ReadWordAsync(MemoryType.D, "0", 1); + var writeResult = await connector.WriteBitAsync(MemoryType.M, "0", 1); + + //Assert + Assert.Equal(code, readResult); + Assert.Null(data); + Assert.Equal(code, writeResult); + } + + [Fact] + public async Task ReadWordAsync_Should_Return_Error_When_Plc_Acknowledges_Without_Data() + { + //Arrange : 0x41 with Success is a write acknowledge, not an answer to a read + Server.Mock.Send("").Receive(request => CimonPlcSimulator.Ack(request)); + using var connector = CreateConnector(); + + //Act + var (responseCode, data) = await connector.ReadWordAsync(MemoryType.D, "0", 1); + + //Assert + Assert.Equal(ResponseCode.WritingError, responseCode); + Assert.Null(data); + } + + [Fact] + public async Task Operations_Should_Return_Error_On_Wrong_Frame_Number() + { + //Arrange : the PLC answers another request + Server.Mock.Send("").Receive(request => CimonPlcSimulator.SlaveFrame((byte)(request[9] + 1), EthernetFrame.AckCommand, new byte[] { 0, 0 })); + using var connector = CreateConnector(); + + //Act + var result = await connector.WriteWordAsync(MemoryType.D, "0", 1); + + //Assert + Assert.Equal(ResponseCode.WritingError, result); + } + + [Fact] + public async Task ReadWordAsync_Should_Return_Error_On_Answer_To_Another_Command() + { + //Arrange : a bit read answer for a word read request + Server.Mock.SendMatchingBytes(CimonPlcSimulator.IsCommand(ReadCommand.WordBlockRead)) + .Receive(request => CimonPlcSimulator.SlaveFrame(request[9], (byte)ReadCommand.BitBlockRead, new byte[11])); + using var connector = CreateConnector(); + + //Act + var (responseCode, data) = await connector.ReadWordAsync(MemoryType.D, "0", 1); + + //Assert + Assert.Equal(ResponseCode.WritingError, responseCode); + Assert.Null(data); } } } diff --git a/tests/CimonPlc.UnitTests/CimonPlc/EthernetFailureTests.cs b/tests/CimonPlc.UnitTests/CimonPlc/EthernetFailureTests.cs new file mode 100644 index 0000000..f767b7a --- /dev/null +++ b/tests/CimonPlc.UnitTests/CimonPlc/EthernetFailureTests.cs @@ -0,0 +1,243 @@ +using System; +using System.Diagnostics; +using System.Threading.Tasks; +using CimonPlc.Enums; +using CimonPlc.UnitTests.Simulators; +using Rony.Net; +using Xunit; +using Xunit.Abstractions; + +namespace CimonPlc.UnitTests +{ + /// + /// Network and PLC failures simulated with Rony.Net: slow, partial, broken and missing responses. + /// + public class EthernetFailureTests : PlcMockServerTest + { + private const int ReadTimeout = 300; + + public EthernetFailureTests(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public async Task Response_Sent_In_Pieces_Should_Be_Reassembled() + { + //Arrange + Plc.SetWord(MemoryType.D, 0, 1234); + Server.Mock.SendMatchingBytes(CimonPlcSimulator.IsCommand(ReadCommand.WordBlockRead)) + .Receive(Plc.ReadWords) + .InChunks(3, TimeSpan.FromMilliseconds(5)); + using var connector = CreateConnector(); + + //Act + var (responseCode, data) = await connector.ReadWordAsync(MemoryType.D, "0", 1); + + //Assert + Assert.Equal(ResponseCode.Success, responseCode); + Assert.Equal(new[] { 1234 }, data); + } + + [Fact] + public async Task Slow_Response_Within_Read_Timeout_Should_Succeed() + { + //Arrange + Server.Mock.Send("").Receive(Plc.WriteWords).After(TimeSpan.FromMilliseconds(100)); + using var connector = CreateConnector(); + await connector.Connect(readTimeout: 2000); + + //Act + var result = await connector.WriteWordAsync(MemoryType.D, "0", 1); + + //Assert + Assert.Equal(ResponseCode.Success, result); + } + + [Fact] + public async Task Response_After_Read_Timeout_Should_Return_SystemError() + { + //Arrange + Server.Mock.Send("").Receive(Plc.WriteWords).After(TimeSpan.FromSeconds(2)); + using var connector = CreateConnector(); + await connector.Connect(readTimeout: ReadTimeout); + + //Act + var result = await connector.WriteWordAsync(MemoryType.D, "0", 1); + + //Assert + Assert.Equal(ResponseCode.SystemError, result); + } + + [Fact] + public async Task No_Response_Should_Return_SystemError() + { + //Arrange + Server.Mock.Send("").NoReply(); + using var connector = CreateConnector(); + await connector.Connect(readTimeout: ReadTimeout); + + //Act + var (responseCode, data) = await connector.ReadWordAsync(MemoryType.D, "0", 1); + + //Assert + Assert.Equal(ResponseCode.SystemError, responseCode); + Assert.Null(data); + Server.Should().HaveReceived(r => true, Times.Once()); + } + + [Fact] + public async Task Truncated_Response_Should_Return_WritingError() + { + //Arrange : only the header arrives, then the PLC closes the connection + Server.Mock.Send("").Receive(Plc.ReadWords).Truncated(EthernetFrame.HeaderLength).AndDisconnect(); + using var connector = CreateConnector(); + + //Act + var (responseCode, data) = await connector.ReadWordAsync(MemoryType.D, "0", 4); + + //Assert + Assert.Equal(ResponseCode.WritingError, responseCode); + Assert.Null(data); + } + + [Fact] + public async Task Truncated_Response_On_Open_Connection_Should_Return_WritingError_After_Read_Timeout() + { + //Arrange + Server.Mock.Send("").Receive(Plc.ReadWords).Truncated(20); + using var connector = CreateConnector(); + await connector.Connect(readTimeout: ReadTimeout); + + //Act + var (responseCode, data) = await connector.ReadWordAsync(MemoryType.D, "0", 4); + + //Assert + Assert.Equal(ResponseCode.WritingError, responseCode); + Assert.Null(data); + } + + [Fact] + public async Task Corrupted_Checksum_Should_Return_WritingError() + { + //Arrange + Server.Mock.Send("").Receive(Plc.WriteWords).Corrupted(bytes => + { + bytes[bytes.Length - 1] ^= 0xFF; + return bytes; + }); + using var connector = CreateConnector(); + + //Act + var result = await connector.WriteWordAsync(MemoryType.D, "0", 1); + + //Assert + Assert.Equal(ResponseCode.WritingError, result); + } + + [Fact] + public async Task Corrupted_Header_Should_Return_WritingError() + { + //Arrange : "KDT_PLC_S" becomes "XDT_PLC_S" + Server.Mock.Send("").Receive(Plc.ReadBits).Corrupted(bytes => + { + bytes[0] = (byte)'X'; + return bytes; + }); + using var connector = CreateConnector(); + + //Act + var (responseCode, data) = await connector.ReadBitAsync(MemoryType.M, "0", 8); + + //Assert + Assert.Equal(ResponseCode.WritingError, responseCode); + Assert.Null(data); + } + + [Fact] + public async Task Dropped_Connection_Should_Return_SystemError() + { + //Arrange + Server.Mock.Send("").Disconnect(); + using var connector = CreateConnector(); + + //Act + var result = await connector.WriteBitAsync(MemoryType.M, "0", 1); + + //Assert + Assert.Equal(ResponseCode.SystemError, result); + } + + [Fact] + public async Task Reset_Connection_Should_Return_SystemError() + { + //Arrange + Server.Mock.Send("").ResetConnection(); + using var connector = CreateConnector(); + + //Act + var result = await connector.WriteBitAsync(MemoryType.M, "0", 1); + + //Assert + Assert.Equal(ResponseCode.SystemError, result); + } + + [Fact] + public async Task Connection_Closed_While_Waiting_Should_Return_Before_Read_Timeout() + { + //Arrange + Server.Mock.Send("").NoReply(); + using var connector = CreateConnector(); + await connector.Connect(readTimeout: 10000); + var stopwatch = Stopwatch.StartNew(); + + //Act : the PLC goes away while the request is pending + var pending = connector.ReadWordAsync(MemoryType.D, "0", 1); + await Server.Mock.WaitForRequestAsync(); + await Server.Connections[0].CloseAsync(); + var (responseCode, _) = await pending; + + //Assert + Assert.Equal(ResponseCode.SystemError, responseCode); + Assert.True(stopwatch.ElapsedMilliseconds < 5000, $"Took {stopwatch.ElapsedMilliseconds} ms"); + } + + [Fact] + public async Task AutoConnect_Should_Recover_After_Dropped_Connection() + { + //Arrange : the first request loses the connection, the next ones are answered + Server.Mock.Send("").Disconnect().Then(Plc.WriteWords); + using var connector = CreateConnector(); + + //Act + var first = await connector.WriteWordAsync(MemoryType.D, "0", 1); + var second = await connector.WriteWordAsync(MemoryType.D, "0", 2); + + //Assert + Assert.Equal(ResponseCode.SystemError, first); + Assert.Equal(ResponseCode.Success, second); + Assert.Equal(2, Plc.GetWord(MemoryType.D, 0)); + Server.Should().HaveAcceptedConnections(Times.Exactly(2)); + } + + [Fact] + public async Task Busy_Plc_Should_Succeed_On_Retry() + { + //Arrange : a response sequence, the PLC reports an error once and then works + Server.Mock.Send("") + .Receive(request => CimonPlcSimulator.Ack(request, ResponseCode.CpuError)) + .Then(Plc.WriteWords); + using var connector = CreateConnector(); + await connector.Connect(); + + //Act + var first = await connector.WriteWordAsync(MemoryType.D, "0", 7); + var second = await connector.WriteWordAsync(MemoryType.D, "0", 7); + + //Assert + Assert.Equal(ResponseCode.CpuError, first); + Assert.Equal(ResponseCode.Success, second); + Assert.Equal(7, Plc.GetWord(MemoryType.D, 0)); + Server.Should().HaveAcceptedConnections(Times.Once()); + } + } +} diff --git a/tests/CimonPlc.UnitTests/CimonPlc/EthernetProtocolTests.cs b/tests/CimonPlc.UnitTests/CimonPlc/EthernetProtocolTests.cs index 94830c4..cac14a1 100644 --- a/tests/CimonPlc.UnitTests/CimonPlc/EthernetProtocolTests.cs +++ b/tests/CimonPlc.UnitTests/CimonPlc/EthernetProtocolTests.cs @@ -4,10 +4,7 @@ using System.Threading.Tasks; using CimonPlc.Enums; using CimonPlc.PlcConnectors; -using CimonPlc.Sockets; using CimonPlc.UnitTests.FakeClasses; -using Rony.Listeners; -using Rony.Net; using Xunit; namespace CimonPlc.UnitTests @@ -171,24 +168,5 @@ public async Task AutoConnect_Should_Close_Automatically_Opened_Connection() //Assert Assert.False(connector.IsConnected); } - - [Fact] - public async Task TcpSocket_Should_Reconnect_After_AutoDisconnect() - { - //Arrange - using var connector = new EthernetConnector(new TcpSocket("127.0.0.1", 10624)); - using var mockServer = new MockServer(new TcpServer(10624)); - mockServer.Start(); - mockServer.Mock.Send("").Receive(x => WriteAck(x)); - - //Act - var first = await connector.WriteWordAsync(MemoryType.D, "0", 1); - var second = await connector.WriteWordAsync(MemoryType.D, "0", 2); - mockServer.Stop(); - - //Assert - Assert.Equal(ResponseCode.Success, first); - Assert.Equal(ResponseCode.Success, second); - } } } diff --git a/tests/CimonPlc.UnitTests/Simulators/CimonFraming.cs b/tests/CimonPlc.UnitTests/Simulators/CimonFraming.cs new file mode 100644 index 0000000..aff87f0 --- /dev/null +++ b/tests/CimonPlc.UnitTests/Simulators/CimonFraming.cs @@ -0,0 +1,43 @@ +using System; +using System.Collections.Generic; +using Rony.Interfaces; + +namespace CimonPlc.UnitTests.Simulators +{ + /// + /// Rony.Net message framing for Cimon Ethernet frames. TCP does not keep message boundaries, + /// so the mock server uses the length field of the frame header to find where every request + /// ends: header (14 bytes) + data (length field) + checksum (2 bytes). + /// Requests that arrive in pieces, or several in one burst, are still matched one frame at a time. + /// + public sealed class CimonFraming : IMessageFraming + { + public static readonly CimonFraming Instance = new CimonFraming(); + + private CimonFraming() + { + } + + public IReadOnlyList Decode(ReadOnlySpan data, bool endOfBurst, out int consumed) + { + var messages = new List(); + consumed = 0; + + while (data.Length - consumed >= EthernetFrame.HeaderLength) + { + var frame = data.Slice(consumed); + var length = EthernetFrame.HeaderLength + Tools.ToInt(frame[12], frame[13]) + EthernetFrame.CheckSumLength; + if (frame.Length < length) + break; + + messages.Add(frame.Slice(0, length).ToArray()); + consumed += length; + } + + return messages; + } + + // Responses built by the simulator are complete frames already. + public byte[] Encode(byte[] message) => message; + } +} diff --git a/tests/CimonPlc.UnitTests/Simulators/CimonPlcSimulator.cs b/tests/CimonPlc.UnitTests/Simulators/CimonPlcSimulator.cs new file mode 100644 index 0000000..8600a24 --- /dev/null +++ b/tests/CimonPlc.UnitTests/Simulators/CimonPlcSimulator.cs @@ -0,0 +1,124 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Text; +using CimonPlc.Enums; +using Rony.Net; + +namespace CimonPlc.UnitTests.Simulators +{ + /// + /// A Cimon PLC simulated with a Rony.Net mock server. It keeps word and bit memory, so values + /// written by a connector can be read back, and it builds the slave frames used to script + /// custom responses (errors, wrong frame numbers, ...). + /// + public class CimonPlcSimulator + { + // Offsets of the master frame: header, then memory type (1) + 0 (1) + address (6) + count (2) + payload + private const int MemoryTypeOffset = EthernetFrame.HeaderLength; + private const int AddressOffset = EthernetFrame.HeaderLength + 2; + private const int CountOffset = EthernetFrame.HeaderLength + 8; + private const int PayloadOffset = EthernetFrame.HeaderLength + 10; + + private readonly ConcurrentDictionary<(MemoryType, int), int> _words = new ConcurrentDictionary<(MemoryType, int), int>(); + private readonly ConcurrentDictionary<(MemoryType, int), byte> _bits = new ConcurrentDictionary<(MemoryType, int), byte>(); + + /// + /// Adds one rule per command to the server, answering like a PLC that keeps its memory. + /// + public CimonPlcSimulator Attach(MockServer server) + { + server.Mock.SendMatchingBytes(IsCommand(ReadCommand.WordBlockRead)).Receive(ReadWords); + server.Mock.SendMatchingBytes(IsCommand(ReadCommand.BitBlockRead)).Receive(ReadBits); + server.Mock.SendMatchingBytes(IsCommand(WriteCommands.WordBlockWrite)).Receive(WriteWords); + server.Mock.SendMatchingBytes(IsCommand(WriteCommands.BitBlockWrite)).Receive(WriteBits); + return this; + } + + public int GetWord(MemoryType memoryType, int address) => _words.TryGetValue((memoryType, address), out var value) ? value : 0; + + public void SetWord(MemoryType memoryType, int address, int value) => _words[(memoryType, address)] = value; + + public byte GetBit(MemoryType memoryType, int address) => _bits.TryGetValue((memoryType, address), out var value) ? value : (byte)0; + + public void SetBit(MemoryType memoryType, int address, byte value) => _bits[(memoryType, address)] = value; + + public byte[] ReadWords(byte[] request) + { + var (memoryType, address, count) = ParseRequest(request); + var data = Enumerable.Range(address, count).SelectMany(x => GetWord(memoryType, x).ToDualByte()); + return DataResponse(request, data); + } + + public byte[] ReadBits(byte[] request) + { + var (memoryType, address, count) = ParseRequest(request); + var data = Enumerable.Range(address, count).Select(x => GetBit(memoryType, x)); + return DataResponse(request, data); + } + + public byte[] WriteWords(byte[] request) + { + var (memoryType, address, count) = ParseRequest(request); + for (var i = 0; i < count; i++) + SetWord(memoryType, address + i, Tools.ToInt(request[PayloadOffset + i * 2], request[PayloadOffset + i * 2 + 1])); + + return Ack(request); + } + + public byte[] WriteBits(byte[] request) + { + var (memoryType, address, count) = ParseRequest(request); + for (var i = 0; i < count; i++) + SetBit(memoryType, address + i, request[PayloadOffset + i]); + + return Ack(request); + } + + public static Func IsCommand(ReadCommand command) => frame => frame[10] == (byte)command; + + public static Func IsCommand(WriteCommands command) => frame => frame[10] == (byte)command; + + /// + /// The answer to a write, or the error answer to any request: command 0x41 and a response code. + /// + public static byte[] Ack(byte[] request, ResponseCode code = ResponseCode.Success) + { + return SlaveFrame(request[9], EthernetFrame.AckCommand, ((int)code).ToDualByte()); + } + + /// + /// The answer to a read: the request command, the memory address block and the read data. + /// + public static byte[] DataResponse(byte[] request, IEnumerable data) + { + // Memory address block (9 bytes): memory type + 0 + address (6) + 0 + var block = request.Skip(MemoryTypeOffset).Take(8).Concat(new byte[] { 0 }); + return SlaveFrame(request[9], request[10], block.Concat(data)); + } + + public static byte[] SlaveFrame(byte frameNo, byte command, IEnumerable data) + { + var body = data.ToArray(); + var frame = new List(); + frame.AddRange(Encoding.ASCII.GetBytes(EthernetFrame.SlaveId)); + frame.Add((byte)(frameNo + 128)); + frame.Add(command); + frame.Add(0); + frame.AddRange(body.Length.ToDualByte()); + frame.AddRange(body); + frame.AddRange(Tools.CheckSum(frame)); + return frame.ToArray(); + } + + public static (MemoryType memoryType, int address, int count) ParseRequest(byte[] request) + { + var memoryType = (MemoryType)request[MemoryTypeOffset]; + var address = int.Parse(Encoding.ASCII.GetString(request, AddressOffset, 6), NumberStyles.HexNumber, CultureInfo.InvariantCulture); + var count = Tools.ToInt(request[CountOffset], request[CountOffset + 1]); + return (memoryType, address, count); + } + } +} diff --git a/tests/CimonPlc.UnitTests/Simulators/PlcMockServerTest.cs b/tests/CimonPlc.UnitTests/Simulators/PlcMockServerTest.cs new file mode 100644 index 0000000..2fe1b6f --- /dev/null +++ b/tests/CimonPlc.UnitTests/Simulators/PlcMockServerTest.cs @@ -0,0 +1,39 @@ +using CimonPlc.PlcConnectors; +using CimonPlc.Sockets; +using Rony.Interfaces; +using Rony.Listeners; +using Rony.Net.Xunit; +using Xunit.Abstractions; + +namespace CimonPlc.UnitTests.Simulators +{ + /// + /// Base class for tests that talk to a simulated PLC over a real TCP socket. + /// (Rony.Net.Xunit) gives every test its own mock server on a free port, + /// writes the server log to the test output and disposes the server after the test. + /// + public abstract class PlcMockServerTest : MockServerTest + { + protected PlcMockServerTest(ITestOutputHelper output) : base(output) + { + // Strict mode: a request without a configured response fails the test. + VerifyAllRequestsMatchedAfterTest = true; + } + + /// + /// PLC memory and frame helpers. Attach it to to answer like a PLC. + /// + protected CimonPlcSimulator Plc { get; } = new CimonPlcSimulator(); + + // A free port, and requests split by the length field of Cimon frames. + protected override IListener CreateListener() => new TcpServer(0) { Framing = CimonFraming.Instance }; + + /// + /// A connector for the mock server. Ping is disabled, the server is on the loopback address. + /// + protected EthernetConnector CreateConnector(bool autoConnect = true) + { + return new EthernetConnector(new TcpSocket("127.0.0.1", Server.Port, usePing: false), autoConnect); + } + } +}