209 lines
7.9 KiB
C#
209 lines
7.9 KiB
C#
using System.Collections.Concurrent;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
using ZB.MOM.WW.OtOpcUa.Driver.Modbus;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
|
|
|
|
[Trait("Category", "Unit")]
|
|
public sealed class ModbusProbeTests
|
|
{
|
|
/// <summary>
|
|
/// Transport fake the probe tests flip between "responding" and "unreachable" to
|
|
/// exercise the state machine. Calls to SendAsync with FC=0x03 count as probe traffic
|
|
/// (the driver's probe loop issues exactly that shape).
|
|
/// </summary>
|
|
private sealed class FlappyTransport : IModbusTransport
|
|
{
|
|
public volatile bool Reachable = true;
|
|
public int ProbeCount;
|
|
|
|
public Task ConnectAsync(CancellationToken ct) => Task.CompletedTask;
|
|
|
|
public Task<byte[]> SendAsync(byte unitId, byte[] pdu, CancellationToken ct)
|
|
{
|
|
if (pdu[0] == 0x03) Interlocked.Increment(ref ProbeCount);
|
|
if (!Reachable)
|
|
return Task.FromException<byte[]>(new IOException("transport unreachable"));
|
|
|
|
// Happy path — return a valid FC03 response for 1 register at addr.
|
|
if (pdu[0] == 0x03)
|
|
{
|
|
var qty = (ushort)((pdu[3] << 8) | pdu[4]);
|
|
var resp = new byte[2 + qty * 2];
|
|
resp[0] = 0x03;
|
|
resp[1] = (byte)(qty * 2);
|
|
return Task.FromResult(resp);
|
|
}
|
|
return Task.FromException<byte[]>(new NotSupportedException());
|
|
}
|
|
|
|
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
|
}
|
|
|
|
private static (ModbusDriver drv, FlappyTransport fake) NewDriver(ModbusProbeOptions probe)
|
|
{
|
|
var fake = new FlappyTransport();
|
|
var opts = new ModbusDriverOptions { Host = "fake", Port = 502, Probe = probe };
|
|
return (new ModbusDriver(opts, "modbus-1", _ => fake), fake);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Initial_state_is_Unknown_before_first_probe_tick()
|
|
{
|
|
var (drv, _) = NewDriver(new ModbusProbeOptions { Enabled = false });
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
|
|
var statuses = drv.GetHostStatuses();
|
|
statuses.Count.ShouldBe(1);
|
|
statuses[0].State.ShouldBe(HostState.Unknown);
|
|
statuses[0].HostName.ShouldBe("fake:502");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task First_successful_probe_transitions_to_Running()
|
|
{
|
|
var (drv, fake) = NewDriver(new ModbusProbeOptions
|
|
{
|
|
Enabled = true,
|
|
Interval = TimeSpan.FromMilliseconds(150),
|
|
Timeout = TimeSpan.FromSeconds(1),
|
|
});
|
|
var transitions = new ConcurrentQueue<HostStatusChangedEventArgs>();
|
|
drv.OnHostStatusChanged += (_, e) => transitions.Enqueue(e);
|
|
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
|
|
// Wait for the first probe to complete.
|
|
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(2);
|
|
while (fake.ProbeCount == 0 && DateTime.UtcNow < deadline) await Task.Delay(25);
|
|
|
|
// Then wait for the event to actually arrive.
|
|
deadline = DateTime.UtcNow + TimeSpan.FromSeconds(1);
|
|
while (transitions.Count == 0 && DateTime.UtcNow < deadline) await Task.Delay(25);
|
|
|
|
transitions.Count.ShouldBeGreaterThanOrEqualTo(1);
|
|
transitions.TryDequeue(out var t).ShouldBeTrue();
|
|
t!.OldState.ShouldBe(HostState.Unknown);
|
|
t.NewState.ShouldBe(HostState.Running);
|
|
drv.GetHostStatuses()[0].State.ShouldBe(HostState.Running);
|
|
|
|
await drv.ShutdownAsync(CancellationToken.None);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Transport_failure_transitions_to_Stopped()
|
|
{
|
|
var (drv, fake) = NewDriver(new ModbusProbeOptions
|
|
{
|
|
Enabled = true,
|
|
Interval = TimeSpan.FromMilliseconds(150),
|
|
Timeout = TimeSpan.FromSeconds(1),
|
|
});
|
|
var transitions = new ConcurrentQueue<HostStatusChangedEventArgs>();
|
|
drv.OnHostStatusChanged += (_, e) => transitions.Enqueue(e);
|
|
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
await WaitForStateAsync(drv, HostState.Running, TimeSpan.FromSeconds(2));
|
|
|
|
fake.Reachable = false;
|
|
await WaitForStateAsync(drv, HostState.Stopped, TimeSpan.FromSeconds(2));
|
|
|
|
transitions.Select(t => t.NewState).ShouldContain(HostState.Stopped);
|
|
await drv.ShutdownAsync(CancellationToken.None);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Recovery_transitions_Stopped_back_to_Running()
|
|
{
|
|
var (drv, fake) = NewDriver(new ModbusProbeOptions
|
|
{
|
|
Enabled = true,
|
|
Interval = TimeSpan.FromMilliseconds(150),
|
|
Timeout = TimeSpan.FromSeconds(1),
|
|
});
|
|
var transitions = new ConcurrentQueue<HostStatusChangedEventArgs>();
|
|
drv.OnHostStatusChanged += (_, e) => transitions.Enqueue(e);
|
|
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
await WaitForStateAsync(drv, HostState.Running, TimeSpan.FromSeconds(2));
|
|
|
|
fake.Reachable = false;
|
|
await WaitForStateAsync(drv, HostState.Stopped, TimeSpan.FromSeconds(2));
|
|
|
|
fake.Reachable = true;
|
|
await WaitForStateAsync(drv, HostState.Running, TimeSpan.FromSeconds(2));
|
|
|
|
// We expect at minimum: Unknown→Running, Running→Stopped, Stopped→Running.
|
|
transitions.Count.ShouldBeGreaterThanOrEqualTo(3);
|
|
await drv.ShutdownAsync(CancellationToken.None);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Repeated_successful_probes_do_not_generate_duplicate_Running_events()
|
|
{
|
|
var (drv, _) = NewDriver(new ModbusProbeOptions
|
|
{
|
|
Enabled = true,
|
|
Interval = TimeSpan.FromMilliseconds(100),
|
|
Timeout = TimeSpan.FromSeconds(1),
|
|
});
|
|
var transitions = new ConcurrentQueue<HostStatusChangedEventArgs>();
|
|
drv.OnHostStatusChanged += (_, e) => transitions.Enqueue(e);
|
|
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
await WaitForStateAsync(drv, HostState.Running, TimeSpan.FromSeconds(2));
|
|
await Task.Delay(500); // several more probe ticks, all successful — state shouldn't thrash
|
|
|
|
transitions.Count.ShouldBe(1); // only the initial Unknown→Running
|
|
await drv.ShutdownAsync(CancellationToken.None);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Disabled_probe_stays_Unknown_and_fires_no_events()
|
|
{
|
|
var (drv, _) = NewDriver(new ModbusProbeOptions { Enabled = false });
|
|
var transitions = new ConcurrentQueue<HostStatusChangedEventArgs>();
|
|
drv.OnHostStatusChanged += (_, e) => transitions.Enqueue(e);
|
|
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
await Task.Delay(300);
|
|
|
|
transitions.Count.ShouldBe(0);
|
|
drv.GetHostStatuses()[0].State.ShouldBe(HostState.Unknown);
|
|
await drv.ShutdownAsync(CancellationToken.None);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Shutdown_stops_the_probe_loop()
|
|
{
|
|
var (drv, fake) = NewDriver(new ModbusProbeOptions
|
|
{
|
|
Enabled = true,
|
|
Interval = TimeSpan.FromMilliseconds(100),
|
|
Timeout = TimeSpan.FromSeconds(1),
|
|
});
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
await WaitForStateAsync(drv, HostState.Running, TimeSpan.FromSeconds(2));
|
|
|
|
var before = fake.ProbeCount;
|
|
await drv.ShutdownAsync(CancellationToken.None);
|
|
await Task.Delay(400);
|
|
|
|
// A handful of in-flight ticks may complete after shutdown in a narrow race; the
|
|
// contract is that the loop stops scheduling new ones. Tolerate ≤1 extra.
|
|
(fake.ProbeCount - before).ShouldBeLessThanOrEqualTo(1);
|
|
}
|
|
|
|
private static async Task WaitForStateAsync(ModbusDriver drv, HostState expected, TimeSpan timeout)
|
|
{
|
|
var deadline = DateTime.UtcNow + timeout;
|
|
while (DateTime.UtcNow < deadline)
|
|
{
|
|
if (drv.GetHostStatuses()[0].State == expected) return;
|
|
await Task.Delay(25);
|
|
}
|
|
}
|
|
}
|