using System.Diagnostics; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests; /// /// Comms-loss → recovery against the real pymodbus standard fixture, with a loopback /// in front of it so the device can disappear and come back without touching the /// shared container. Covers the review 07 High findings P3 (transport self-heal) and H2 (deadband /// recovery publish). /// [Collection(ModbusSimulatorCollection.Name)] [Trait("Category", "Integration")] [Trait("Device", "Standard")] public sealed class CommsRecoveryTests(ModbusSimulatorFixture sim) { private const uint StatusGood = 0u; private void SkipUnlessStandardLive() { if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason); var profile = Environment.GetEnvironmentVariable("MODBUS_SIM_PROFILE"); if (profile is not null && !string.Equals(profile, "standard", StringComparison.OrdinalIgnoreCase)) Assert.Skip($"MODBUS_SIM_PROFILE={profile} — these tests need the standard profile (HR[0..31] = address)."); } private ModbusDriverOptions Options(TcpRelay relay, params ModbusTagDefinition[] tags) => new() { Host = "127.0.0.1", Port = relay.Port, UnitId = 1, Timeout = TimeSpan.FromSeconds(2), RawTags = ModbusRawTags.Entries(tags), Probe = new ModbusProbeOptions { Enabled = false }, }; private static async Task WaitFor(Func> probe, Func done, TimeSpan budget) { var sw = Stopwatch.StartNew(); T last; do { last = await probe(); if (done(last)) return last; await Task.Delay(200, TestContext.Current.CancellationToken); } while (sw.Elapsed < budget); return last; } /// /// P3: after the device drops (two failed calls in a row, socket torn down, reconnect refused) the /// transport must reconnect ON ITS OWN once the device is back — before this fix every later call /// failed fast with "Transport not connected" until the 90 s comms-loss watchdog re-initialised the /// driver. /// [Fact] public async Task Driver_reads_again_after_the_device_drops_and_returns_without_reinit() { SkipUnlessStandardLive(); await using var relay = new TcpRelay(sim.Host, sim.Port); var tag = new ModbusTagDefinition("HR5", ModbusRegion.HoldingRegisters, 5, ModbusDataType.UInt16, Writable: false); await using var driver = new ModbusDriver(Options(relay, tag), "modbus-p3"); await driver.InitializeAsync("{}", TestContext.Current.CancellationToken); var good = await driver.ReadAsync(["HR5"], TestContext.Current.CancellationToken); good[0].StatusCode.ShouldBe(StatusGood); good[0].Value.ShouldBe((ushort)5); relay.Cut(); for (var i = 0; i < 2; i++) { var bad = await driver.ReadAsync(["HR5"], TestContext.Current.CancellationToken); bad[0].StatusCode.ShouldNotBe(StatusGood); } driver.GetHealth().State.ShouldBe(DriverState.Degraded); relay.Restore(); var recovered = await WaitFor( () => driver.ReadAsync(["HR5"], TestContext.Current.CancellationToken), r => r[0].StatusCode == StatusGood, TimeSpan.FromSeconds(20)); recovered[0].StatusCode.ShouldBe(StatusGood, "the transport must reconnect by itself once the device is back"); recovered[0].Value.ShouldBe((ushort)5); driver.GetHealth().State.ShouldBe(DriverState.Healthy); } /// /// H2: a deadbanded tag whose device returns holding the SAME value must publish the Good recovery. /// HR[5] is a constant 5 in the standard profile, so the recovery value is identical by construction. /// Positive control: the Bad publish during the outage is observed first. /// [Fact] public async Task Deadbanded_subscription_publishes_the_Good_recovery_with_an_identical_value() { SkipUnlessStandardLive(); await using var relay = new TcpRelay(sim.Host, sim.Port); var tag = new ModbusTagDefinition("HR5", ModbusRegion.HoldingRegisters, 5, ModbusDataType.UInt16, Writable: false, Deadband: 10.0); await using var driver = new ModbusDriver(Options(relay, tag), "modbus-h2"); await driver.InitializeAsync("{}", TestContext.Current.CancellationToken); var published = new System.Collections.Concurrent.ConcurrentQueue(); driver.OnDataChange += (_, e) => published.Enqueue(e.Snapshot); var handle = await driver.SubscribeAsync(["HR5"], TimeSpan.FromMilliseconds(200), TestContext.Current.CancellationToken); try { (await WaitFor(() => Task.FromResult(published.ToArray()), p => p.Any(s => s.StatusCode == StatusGood), TimeSpan.FromSeconds(10))) .ShouldContain(s => s.StatusCode == StatusGood, "initial Good publish"); relay.Cut(); (await WaitFor(() => Task.FromResult(published.ToArray()), p => p.Any(s => s.StatusCode != StatusGood), TimeSpan.FromSeconds(20))) .ShouldContain(s => s.StatusCode != StatusGood, "the outage must publish Bad (positive control)"); var badCount = published.Count; relay.Restore(); var after = await WaitFor(() => Task.FromResult(published.Skip(badCount).ToArray()), p => p.Any(s => s.StatusCode == StatusGood), TimeSpan.FromSeconds(30)); var recovery = after.FirstOrDefault(s => s.StatusCode == StatusGood); recovery.ShouldNotBeNull("the Good recovery with the same value must not be swallowed by the deadband"); recovery.Value.ShouldBe((ushort)5); } finally { await driver.UnsubscribeAsync(handle, TestContext.Current.CancellationToken); } } }