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);
}
///
/// U1 (FC23 opt-in, DisableFC23 kill-switch): a multi-register write on the opted-in FC23 write + read-back path
/// round-trips against the real server — whether it implements FC23 or answers Illegal Function and
/// is moved to FC16 — and so does the default FC16 path. Uses the standard profile's scratch HR[200..209].
///
[Theory]
[InlineData(true, 200)]
[InlineData(false, 202)]
public async Task Multi_register_write_round_trips_with_and_without_FC23(bool useFc23, int address)
{
SkipUnlessStandardLive();
var tag = new ModbusTagDefinition("F", ModbusRegion.HoldingRegisters, (ushort)address, ModbusDataType.Float32);
var opts = new ModbusDriverOptions
{
Host = sim.Host, Port = sim.Port, UnitId = 1, Timeout = TimeSpan.FromSeconds(2), UseFC23ForMultiRegisterWrites = useFc23,
RawTags = ModbusRawTags.Entries([tag]), Probe = new ModbusProbeOptions { Enabled = false },
};
await using var driver = new ModbusDriver(opts, "modbus-u1");
await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);
var value = useFc23 ? 6.5f : 12.25f;
for (var i = 0; i < 2; i++) // second write exercises the latched path when the server lacks FC23
{
var w = await driver.WriteAsync([new WriteRequest("F", value + i)], TestContext.Current.CancellationToken);
w[0].StatusCode.ShouldBe(StatusGood);
var r = await driver.ReadAsync(["F"], TestContext.Current.CancellationToken);
r[0].Value.ShouldBe(value + i);
}
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);
}
}
}