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