using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Driver.AbCip; namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests; /// /// 05/STAB-8 — a dead AbCip device must not re-create + re-initialize (Forward-Open) a libplctag /// runtime per tag per tick. The per-device gates the /// runtime-create path; cache hits are unaffected; the probe loop (its own runtime) drives /// failure/success so a recovered device resets the window. /// [Trait("Category", "Unit")] public sealed class AbCipConnectBackoffTests { private static AbCipDriverOptions Options(bool probeEnabled) => new() { Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")], Tags = [new AbCipTagDefinition("Speed", "ab://10.0.0.5/1,0", "Motor1.Speed", AbCipDataType.DInt)], Probe = new AbCipProbeOptions { Enabled = probeEnabled, ProbeTagPath = "ProbeTag", Interval = TimeSpan.FromMilliseconds(50), Timeout = TimeSpan.FromMilliseconds(250), }, }; /// Rapid reads against a dead device attempt a single Forward-Open, then fail fast inside the backoff window. [Fact] public async Task DeadDevice_ConnectAttemptsFollowBackoffSchedule() { var creates = 0; var factory = new FakeAbCipTagFactory { Customise = p => { Interlocked.Increment(ref creates); return new FakeAbCipTag(p) { ThrowOnInitialize = true, Exception = new InvalidOperationException("down") }; }, }; var drv = new AbCipDriver(Options(probeEnabled: false), "abcip-1", factory); await drv.InitializeAsync("{}", CancellationToken.None); for (var i = 0; i < 6; i++) await drv.ReadAsync(["Speed"], CancellationToken.None); creates.ShouldBe(1); // one Forward-Open attempt; the rest fail fast inside the window } /// A cached healthy runtime is read without ever consulting the backoff or re-creating. [Fact] public async Task CacheHit_NeverConsultsBackoff() { var creates = 0; var factory = new FakeAbCipTagFactory { Customise = p => { Interlocked.Increment(ref creates); return new FakeAbCipTag(p) { Value = 5 }; }, }; var drv = new AbCipDriver(Options(probeEnabled: false), "abcip-1", factory); await drv.InitializeAsync("{}", CancellationToken.None); for (var i = 0; i < 5; i++) { var r = await drv.ReadAsync(["Speed"], CancellationToken.None); r[0].StatusCode.ShouldBe(AbCipStatusMapper.Good); } creates.ShouldBe(1); // created once + cached; cache hits never re-create } /// The probe bypasses the backoff window and a successful probe resets it so the next read succeeds. [Fact] public async Task ProbeBypassesBackoff_AndSuccessResets() { var fail = true; var factory = new FakeAbCipTagFactory { Customise = p => new FakeAbCipTag(p) { ThrowOnInitialize = fail, Exception = new InvalidOperationException("down"), Value = 7, }, }; var drv = new AbCipDriver(Options(probeEnabled: true), "abcip-1", factory); await drv.InitializeAsync("{}", CancellationToken.None); var down = await drv.ReadAsync(["Speed"], CancellationToken.None); down[0].StatusCode.ShouldBe(AbCipStatusMapper.BadCommunicationError); fail = false; // device recovers; the probe bypasses the window and resets it var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(3); DataValueSnapshot? good = null; while (DateTime.UtcNow < deadline) { var r = await drv.ReadAsync(["Speed"], CancellationToken.None); if (r[0].StatusCode == AbCipStatusMapper.Good) { good = r[0]; break; } await Task.Delay(25, CancellationToken.None); } good.ShouldNotBeNull("probe-driven recovery must reset the backoff so reads recover"); } }