using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport; namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests; /// /// Review 07 r0 I2 + M1/M2/M4/M10: any evidence that the device answered — a Good value, a device refusal, a Good /// or refused write — restores Healthy and advances LastSuccessfulRead; a no-reply failure on either path /// degrades; writes respect the device's backoff window; teardown health is epoch-guarded. /// [Trait("Category", "Unit")] public sealed class AbLegacyHealthEvidenceTests { private const string Host = "ab://10.0.0.5/1,0"; private sealed class SwitchableFactory : IAbLegacyTagFactory { public Func Make { get; set; } = p => new FakeAbLegacyTag(p) { Value = 1 }; public System.Collections.Concurrent.ConcurrentDictionary Tags { get; } = new(); public IAbLegacyTagRuntime Create(AbLegacyTagCreateParams p) => Tags[p.TagName] = Make(p); } private static FakeAbLegacyTag Dead(AbLegacyTagCreateParams p) => new(p) { ThrowOnRead = true, ThrowOnWrite = true, Exception = new System.Net.Sockets.SocketException(10060) }; private static AbLegacyDriver NewDriver(IAbLegacyTagFactory f, params AbLegacyTagDefinition[] tags) => new(new AbLegacyDriverOptions { Devices = [new AbLegacyDeviceOptions(Host)], RawTags = AbLegacyRawTags.Entries(tags), Probe = new AbLegacyProbeOptions { Enabled = false }, CoalesceReads = false, }, "drv-health", f); private static async Task DegradedDriverAsync(SwitchableFactory f, params AbLegacyTagDefinition[] tags) { var drv = NewDriver(f, tags); await drv.InitializeAsync("{}", CancellationToken.None); f.Make = Dead; (await drv.ReadAsync([tags[0].Name], CancellationToken.None))[0].StatusCode .ShouldBe(AbLegacyStatusMapper.BadCommunicationError); drv.GetHealth().State.ShouldBe(DriverState.Degraded); // precondition return drv; } [Fact] public async Task A_refusal_only_read_after_a_fault_restores_Healthy_and_advances_LastSuccessfulRead() { var f = new SwitchableFactory(); var drv = await DegradedDriverAsync(f, new AbLegacyTagDefinition("X", Host, "N7:0", AbLegacyDataType.Int)); var before = drv.GetHealth().LastSuccessfulRead; f.Make = p => new FakeAbLegacyTag(p) { Status = (int)AbStatus.NotFound }; await Task.Delay(5, TestContext.Current.CancellationToken); var r = await drv.ReadAsync(["X"], CancellationToken.None); r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadNodeIdUnknown); var h = drv.GetHealth(); h.State.ShouldBe(DriverState.Healthy); h.LastSuccessfulRead!.Value.ShouldBeGreaterThan(before!.Value); await drv.ShutdownAsync(CancellationToken.None); } [Theory] [InlineData(0)] // a Good write [InlineData((int)AbStatus.NotAllowed)] // a write the device refused public async Task A_good_or_refused_write_after_a_fault_restores_Healthy(int writeStatus) { var f = new SwitchableFactory(); var drv = await DegradedDriverAsync(f, new AbLegacyTagDefinition("X", Host, "N7:0", AbLegacyDataType.Int), new AbLegacyTagDefinition("W", Host, "N7:5", AbLegacyDataType.Int)); var before = drv.GetHealth().LastSuccessfulRead; f.Make = p => new FakeAbLegacyTag(p) { WriteStatusOverride = writeStatus }; await Task.Delay(5, TestContext.Current.CancellationToken); var w = await drv.WriteAsync([new WriteRequest("W", 3)], CancellationToken.None); w[0].StatusCode.ShouldBe(writeStatus == 0 ? AbLegacyStatusMapper.Good : AbLegacyStatusMapper.BadNotWritable); f.Tags["N7:5"].WriteCount.ShouldBe(1); // positive control: the write reached the device var h = drv.GetHealth(); h.State.ShouldBe(DriverState.Healthy); h.LastSuccessfulRead!.Value.ShouldBeGreaterThan(before!.Value); await drv.ShutdownAsync(CancellationToken.None); } [Fact] public async Task A_no_reply_write_status_degrades_like_the_exception_path_does() { var f = new SwitchableFactory { Make = p => new FakeAbLegacyTag(p) { WriteStatusOverride = (int)AbStatus.Timeout } }; var drv = NewDriver(f, new AbLegacyTagDefinition("W", Host, "N7:5", AbLegacyDataType.Int)); await drv.InitializeAsync("{}", CancellationToken.None); var w = await drv.WriteAsync([new WriteRequest("W", 3)], CancellationToken.None); w[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadTimeout); drv.GetHealth().State.ShouldBe(DriverState.Degraded); await drv.ShutdownAsync(CancellationToken.None); } [Fact] public async Task One_write_call_is_worst_wins_but_still_advances_LastSuccessfulRead() { var f = new SwitchableFactory { Make = p => p.TagName == "N7:6" ? Dead(p) : new FakeAbLegacyTag(p) { Value = 1 }, }; var drv = NewDriver(f, new AbLegacyTagDefinition("Good", Host, "N7:5", AbLegacyDataType.Int), new AbLegacyTagDefinition("Bad", Host, "N7:6", AbLegacyDataType.Int)); await drv.InitializeAsync("{}", CancellationToken.None); var before = drv.GetHealth().LastSuccessfulRead; await Task.Delay(5, TestContext.Current.CancellationToken); var w = await drv.WriteAsync([new WriteRequest("Good", 1), new WriteRequest("Bad", 2)], CancellationToken.None); w[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good); w[1].StatusCode.ShouldBe(AbLegacyStatusMapper.BadCommunicationError); var h = drv.GetHealth(); h.State.ShouldBe(DriverState.Degraded); h.LastSuccessfulRead!.Value.ShouldBeGreaterThan(before!.Value); await drv.ShutdownAsync(CancellationToken.None); } [Fact] public async Task A_write_inside_the_backoff_window_fails_fast_even_with_a_cached_handle() { var now = new DateTime(2026, 10, 22, 12, 0, 0, DateTimeKind.Utc); var f = new SwitchableFactory(); var drv = NewDriver(f, new AbLegacyTagDefinition("W", Host, "N7:5", AbLegacyDataType.Int), new AbLegacyTagDefinition("D1", Host, "N7:6", AbLegacyDataType.Int), new AbLegacyTagDefinition("D2", Host, "N7:7", AbLegacyDataType.Int)); drv.Clock = () => now; await drv.InitializeAsync("{}", CancellationToken.None); (await drv.ReadAsync(["W"], CancellationToken.None))[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good); // cache W var cached = f.Tags["N7:5"]; f.Make = Dead; await drv.ReadAsync(["D1", "D2"], CancellationToken.None); // two consecutive no-reply faults: device down drv.GetDeviceState(Host)!.IsThrottled(now).ShouldBeTrue(); var w = await drv.WriteAsync([new WriteRequest("W", 9)], CancellationToken.None); w[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadCommunicationError); cached.WriteCount.ShouldBe(0); // Positive control: once the window has passed, the same cached handle is written. now = now.AddSeconds(2); (await drv.WriteAsync([new WriteRequest("W", 9)], CancellationToken.None))[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good); cached.WriteCount.ShouldBe(1); await drv.ShutdownAsync(CancellationToken.None); } [Fact] public async Task An_indirect_bit_write_releases_the_previous_parent_word_handle() { var f = new SwitchableFactory { Make = p => new FakeAbLegacyTag(p) { Value = p.TagName == "N7:0" ? (short)5 : (short)0 } }; var drv = NewDriver(f, new AbLegacyTagDefinition("IndBit", Host, "N7:[N7:0]/3", AbLegacyDataType.Bit)); await drv.InitializeAsync("{}", CancellationToken.None); (await drv.WriteAsync([new WriteRequest("IndBit", true)], CancellationToken.None))[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good); var state = drv.GetDeviceState(Host)!; state.ParentRuntimes.ContainsKey("N7:5").ShouldBeTrue(); // positive control: the parent word was RMW'd f.Tags["N7:0"].Value = (short)6; (await drv.WriteAsync([new WriteRequest("IndBit", true)], CancellationToken.None))[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good); state.ParentRuntimes.ContainsKey("N7:5").ShouldBeFalse(); state.ParentRuntimes.ContainsKey("N7:6").ShouldBeTrue(); f.Tags["N7:5"].Disposed.ShouldBeTrue(); await drv.ShutdownAsync(CancellationToken.None); } /// A probe read that parks ignoring cancellation, so ShutdownAsync is still joining. private sealed class ParkingTag(AbLegacyTagCreateParams p, Task release, TaskCompletionSource entered) : FakeAbLegacyTag(p) { public override async Task ReadAsync(CancellationToken ct) { entered.TrySetResult(); await release.ConfigureAwait(false); } } [Fact] public async Task A_slow_teardown_does_not_overwrite_a_newer_initialise_health() { var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var parked = false; var f = new SwitchableFactory { Make = p => { if (parked) return new FakeAbLegacyTag(p) { Value = 1 }; parked = true; return new ParkingTag(p, release.Task, entered); }, }; var drv = new AbLegacyDriver(new AbLegacyDriverOptions { Devices = [new AbLegacyDeviceOptions(Host)], Probe = new AbLegacyProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(20) }, }, "drv-m1", f); await drv.InitializeAsync("{}", CancellationToken.None); await entered.Task; var shutdown = drv.ShutdownAsync(CancellationToken.None); // parks joining the wedged probe loop await Task.Delay(50, TestContext.Current.CancellationToken); shutdown.IsCompleted.ShouldBeFalse(); await drv.InitializeAsync("{}", CancellationToken.None); // a newer initialise wins drv.GetHealth().State.ShouldBe(DriverState.Healthy); release.SetResult(); await shutdown; drv.GetHealth().State.ShouldBe(DriverState.Healthy); // Positive control: an uncontested shutdown still writes Unknown. await drv.ShutdownAsync(CancellationToken.None); drv.GetHealth().State.ShouldBe(DriverState.Unknown); } private sealed class DisposeAwareTag(AbLegacyTagCreateParams p) : FakeAbLegacyTag(p) { public override Task ReadAsync(CancellationToken ct) => Disposed ? throw new ObjectDisposedException("tag") : base.ReadAsync(ct); } /// /// Review 07 r0 M3: a reader that fetched a handle and then waited on its gate while another path evicted and /// disposed it used to read the disposed instance — a spurious comms fault and Degraded. It now re-resolves /// the handle once the gate is held. /// [Fact] public async Task A_handle_evicted_while_a_reader_waited_for_its_gate_is_re_resolved() { var f = new SwitchableFactory { Make = p => new DisposeAwareTag(p) { Value = 4 } }; var drv = NewDriver(f, new AbLegacyTagDefinition("X", Host, "N7:0", AbLegacyDataType.Int)); await drv.InitializeAsync("{}", CancellationToken.None); (await drv.ReadAsync(["X"], CancellationToken.None))[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good); var state = drv.GetDeviceState(Host)!; var first = f.Tags["N7:0"]; var gate = state.GetRuntimeLock("X"); await gate.WaitAsync(TestContext.Current.CancellationToken); // Signalled at exactly "handle fetched, about to wait on the gate" — no sleep, so the eviction below always // lands between the reader's fetch and its use (review 07 r1 test nit: the sleep could pass vacuously). var atGate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); state.BeforeOperationGate = key => { if (key == "X") atGate.TrySetResult(); }; var read = drv.ReadAsync(["X"], CancellationToken.None); await atGate.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); state.Runtimes.TryRemove("X", out var evicted).ShouldBeTrue(); evicted!.Dispose(); gate.Release(); var r = await read; r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good); f.Tags["N7:0"].ShouldNotBeSameAs(first); // a fresh handle served it drv.GetHealth().State.ShouldBe(DriverState.Healthy); await drv.ShutdownAsync(CancellationToken.None); } /// /// Review 07 r1 N3: a reader parked on a resolved INDIRECT key's gate while the index sweep evicted that key and /// forgot its gate used to RECONNECT it under the lock — resurrecting a stale resolution under a gate nobody /// else would get. It now answers that one call neutrally and leaves the key gone. /// [Fact] public async Task A_swept_indirect_resolution_is_never_resurrected_under_a_forgotten_gate() { var creates = new System.Collections.Concurrent.ConcurrentQueue(); var f = new SwitchableFactory { Make = p => { creates.Enqueue(p.TagName); return new FakeAbLegacyTag(p) { Value = p.TagName == "N7:0" ? (short)5 : (short)42 }; }, }; var drv = NewDriver(f, new AbLegacyTagDefinition("Ind", Host, "N7:[N7:0]", AbLegacyDataType.Int)); await drv.InitializeAsync("{}", CancellationToken.None); (await drv.ReadAsync(["Ind"], CancellationToken.None))[0].Value.ShouldBe(42); // positive control var state = drv.GetDeviceState(Host)!; const string key = "Ind=>N7:5"; state.Runtimes.ContainsKey(key).ShouldBeTrue(); var gate = state.GetRuntimeLock(key); await gate.WaitAsync(TestContext.Current.CancellationToken); var atGate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); state.BeforeOperationGate = k => { if (k == key) atGate.TrySetResult(); }; var read = drv.ReadAsync(["Ind"], CancellationToken.None); await atGate.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); // What the sweep does when the index moves: evict the resolution, then forget its gate. state.Runtimes.TryRemove(key, out var swept).ShouldBeTrue(); swept!.Dispose(); state.ForgetGates(key); gate.Release(); var r = await read; r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadCommunicationError); state.Runtimes.ContainsKey(key).ShouldBeFalse(); // not resurrected creates.Count(n => n == "N7:5").ShouldBe(1); // no reconnect of the stale resolution drv.GetHealth().State.ShouldBe(DriverState.Healthy); // neutral: no health evidence either way await drv.ShutdownAsync(CancellationToken.None); } /// Review 07 r1 N4: an index read answered Unsupported is neutral, not a transport fault. [Fact] public async Task An_index_read_answered_Unsupported_is_neutral() { var f = new SwitchableFactory { Make = p => p.TagName == "N7:0" ? new FakeAbLegacyTag(p) { Status = (int)AbStatus.Unsupported } : new FakeAbLegacyTag(p) { Value = 1 }, }; var drv = NewDriver(f, new AbLegacyTagDefinition("Ind", Host, "N7:[N7:0]", AbLegacyDataType.Int), new AbLegacyTagDefinition("Plain", Host, "N9:0", AbLegacyDataType.Int)); await drv.InitializeAsync("{}", CancellationToken.None); var r = await drv.ReadAsync(["Ind", "Plain"], CancellationToken.None); r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadNotSupported); r[1].StatusCode.ShouldBe(AbLegacyStatusMapper.Good); // positive control drv.GetHealth().State.ShouldBe(DriverState.Healthy); drv.GetDeviceState(Host)!.ParentRuntimes.ContainsKey("N7:0").ShouldBeTrue(); // handle kept, not evicted await drv.ShutdownAsync(CancellationToken.None); } /// Review 07 r1 N2: a persisted mismatched array READS BadTypeMismatch instead of garbage as Good. [Fact] public async Task A_mismatched_array_reads_BadTypeMismatch_not_garbage() { var f = new SwitchableFactory { Make = p => new FakeAbLegacyTag(p) { ArrayValue = new[] { 1f, 2f } } }; var drv = NewDriver(f, new AbLegacyTagDefinition("Bad", Host, "F8:0", AbLegacyDataType.Int, ArrayLength: 2), new AbLegacyTagDefinition("Good", Host, "F9:0", AbLegacyDataType.Float, ArrayLength: 2)); await drv.InitializeAsync("{}", CancellationToken.None); var r = await drv.ReadAsync(["Bad", "Good"], CancellationToken.None); r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadTypeMismatch); f.Tags.ContainsKey("F8:0").ShouldBeFalse(); // nothing read at the wrong width r[1].StatusCode.ShouldBe(AbLegacyStatusMapper.Good); // positive control r[1].Value.ShouldBe(new[] { 1f, 2f }); drv.GetHealth().State.ShouldBe(DriverState.Healthy); await drv.ShutdownAsync(CancellationToken.None); } }