Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
24 changes: 22 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 2 additions & 1 deletion tests/CimonPlc.UnitTests/CimonPlc.UnitTests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@

<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.10.1" />
<PackageReference Include="Rony.Net" Version="0.1.2" />
<PackageReference Include="Rony.Net" Version="1.5.0" />
<PackageReference Include="Rony.Net.Xunit" Version="1.5.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
Expand Down
150 changes: 150 additions & 0 deletions tests/CimonPlc.UnitTests/CimonPlc/EthernetConnectionTests.cs
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// How <see cref="PlcConnectors.EthernetConnector"/> opens, reuses and closes TCP connections,
/// checked on the mock server side with Rony.Net connection records.
/// </summary>
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<SocketException>(() => connector.Connect());

//Act : the PLC is back
Server.AcceptConnections();
var result = await connector.Connect();

//Assert
Assert.Equal(ConnectionStatus.Connected, result);
}
}
}
Loading
Loading