using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT; namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests; /// /// 05/STAB-8 — a dead TwinCAT device must not be hammered with a full connect attempt on every /// data call. The per-device gates the connect/create path; /// the probe loop bypasses it (its own interval is the recovery cadence) and a successful /// connect resets the window instantly so recovery is never delayed. No TC3 fixture exists — /// the fake-client suite is authoritative. /// [Trait("Category", "Unit")] public sealed class TwinCATConnectBackoffTests { private const string Host = "ads://5.23.91.23.1.1:851"; private static TwinCATDriverOptions Options(bool probeEnabled) => new() { Devices = [new TwinCATDeviceOptions(Host)], RawTags = TwinCATRawTags.Entries([new TwinCATTagDefinition("T", Host, "MAIN.T", TwinCATDataType.DInt)]), Probe = new TwinCATProbeOptions { Enabled = probeEnabled, Interval = TimeSpan.FromMilliseconds(50), Timeout = TimeSpan.FromMilliseconds(250), }, EnableControllerBrowse = false, }; /// Rapid data calls against a dead device attempt a single connect, then fail fast inside the backoff window. [Fact] public async Task DeadDevice_ConnectAttemptsFollowBackoffSchedule() { var factory = new FakeTwinCATClientFactory { Customise = () => new FakeTwinCATClient { ThrowOnConnect = true, Exception = new InvalidOperationException("device down"), }, }; var drv = new TwinCATDriver(Options(probeEnabled: false), "twincat-1", factory); await drv.InitializeAsync("{}", CancellationToken.None); for (var i = 0; i < 6; i++) await drv.ReadAsync(["T"], CancellationToken.None); // One real connect attempt; the remaining five fail fast inside the 1 s window — no new // client is created (the fake factory records one client per attempted connect). factory.Clients.Count.ShouldBe(1); } /// The probe bypasses the backoff window and a successful connect resets it so the next data call succeeds immediately. [Fact] public async Task ProbeBypassesBackoff_AndSuccessResets() { var fail = true; var factory = new FakeTwinCATClientFactory { Customise = () => new FakeTwinCATClient { ThrowOnConnect = fail, Exception = new InvalidOperationException("device down"), }, }; var drv = new TwinCATDriver(Options(probeEnabled: true), "twincat-1", factory); await drv.InitializeAsync("{}", CancellationToken.None); // First data call opens the backoff window on a dead device. var down = await drv.ReadAsync(["T"], CancellationToken.None); down[0].StatusCode.ShouldBe(TwinCATStatusMapper.BadCommunicationError); // Device recovers. The data path is still inside the window, but the probe bypasses it and // reconnects — resetting the window so the following data read succeeds with no residual delay. fail = false; var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(3); DataValueSnapshot? good = null; while (DateTime.UtcNow < deadline) { var r = await drv.ReadAsync(["T"], CancellationToken.None); if (r[0].StatusCode == TwinCATStatusMapper.Good) { good = r[0]; break; } await Task.Delay(25, CancellationToken.None); } good.ShouldNotBeNull("probe-driven reconnect must reset the backoff so data reads recover"); } }