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); + } + } +}