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 { /// /// 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). /// private sealed class FlappyTransport : IModbusTransport { public volatile bool Reachable = true; public int ProbeCount; public Task ConnectAsync(CancellationToken ct) => Task.CompletedTask; public Task SendAsync(byte unitId, byte[] pdu, CancellationToken ct) { if (pdu[0] == 0x03) Interlocked.Increment(ref ProbeCount); if (!Reachable) return Task.FromException(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(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(); 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(); 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(); 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(); 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(); 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); } } }