using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests; /// /// Review 07 (Gitea #737) against a running ab_server on both transports: a controller refusal is a per-tag /// config error, not a comms fault (H10 probe, H1/L4 data path), and a whole-array write round-trips (U10). /// Skipped when the fixture is unreachable (); the array write additionally /// needs AB_SERVER_ALLOW_WRITES=1 (). /// [Trait("Category", "Integration")] [Trait("Requires", "AbServer")] public sealed class AbCipRefusalLiveTests { private static string Device(AbServerFixture fixture) => $"ab://{fixture.Host}:{fixture.Port}/1,0"; private static async Task WaitUntilAsync(Func condition, TimeSpan timeout, string what) { var deadline = DateTime.UtcNow + timeout; while (!condition()) { if (DateTime.UtcNow > deadline) throw new TimeoutException(what); await Task.Delay(50); } } /// /// H10: a typo'd probe tag on a reachable controller leaves the host Running, and a COLD data tag created /// after many probe ticks still connects (the backoff window was never opened by the probe's NotFound). /// /// The transport stack the driver runs on. [AbServerTheory] [MemberData(nameof(TransportMatrix.Transports), MemberType = typeof(TransportMatrix))] public async Task A_typod_probe_tag_does_not_black_out_a_reachable_device(AbCipTransport transport) { var device = Device(new AbServerFixture()); var drv = new AbCipDriver(new AbCipDriverOptions { Devices = [new AbCipDeviceOptions(device, AbCipPlcFamily.ControlLogix)], RawTags = AbCipRawTags.From(new AbCipTagDefinition("Counter", device, "TestDINT", AbCipDataType.DInt)), Probe = new AbCipProbeOptions { Enabled = true, ProbeTagPath = "NoSuchProbeTag_xyz", Interval = TimeSpan.FromMilliseconds(200), Timeout = TimeSpan.FromSeconds(2), }, Timeout = TimeSpan.FromSeconds(5), Transport = transport, }, $"drv-h10-{transport}"); await drv.InitializeAsync("{}", CancellationToken.None); try { await WaitUntilAsync(() => drv.GetHostStatuses().Single().State == HostState.Running, TimeSpan.FromSeconds(10), "the host never went Running on a refused probe tag"); await Task.Delay(1_000); // several probe ticks — each one used to record a Forward-Open failure var r = await drv.ReadAsync(["Counter"], CancellationToken.None); // a cold create r[0].StatusCode.ShouldBe(AbCipStatusMapper.Good); drv.GetHostStatuses().Single().State.ShouldBe(HostState.Running); } finally { await drv.DisposeAsync(); } } /// /// H1/L4: a tag the controller does not have reads BadNodeIdUnknown every poll while a sibling reads Good, and /// the driver stays Healthy with LastSuccessfulRead advancing (it used to latch Degraded and, with no other /// tag, trip the comms-loss watchdog against a reachable controller). /// /// The transport stack the driver runs on. [AbServerTheory] [MemberData(nameof(TransportMatrix.Transports), MemberType = typeof(TransportMatrix))] public async Task A_missing_tag_is_per_tag_Bad_and_never_degrades_the_driver(AbCipTransport transport) { var device = Device(new AbServerFixture()); var drv = new AbCipDriver(new AbCipDriverOptions { Devices = [new AbCipDeviceOptions(device, AbCipPlcFamily.ControlLogix)], RawTags = AbCipRawTags.From( new AbCipTagDefinition("Typo", device, "NoSuchTag_xyz", AbCipDataType.DInt), new AbCipTagDefinition("Counter", device, "TestDINT", AbCipDataType.DInt)), Probe = new AbCipProbeOptions { Enabled = false }, Timeout = TimeSpan.FromSeconds(5), Transport = transport, }, $"drv-h1-{transport}"); await drv.InitializeAsync("{}", CancellationToken.None); try { DateTime? last = null; for (var i = 0; i < 3; i++) { var r = await drv.ReadAsync(["Typo", "Counter"], CancellationToken.None); r[0].StatusCode.ShouldBe(AbCipStatusMapper.BadNodeIdUnknown); r[1].StatusCode.ShouldBe(AbCipStatusMapper.Good); var health = drv.GetHealth(); health.State.ShouldBe(DriverState.Healthy); if (last is { } previous) health.LastSuccessfulRead!.Value.ShouldBeGreaterThanOrEqualTo(previous); last = health.LastSuccessfulRead; } // Only the refused tag: still Healthy, still answering (the watchdog conjunction can never trip). var before = drv.GetHealth().LastSuccessfulRead!.Value; await Task.Delay(20); (await drv.ReadAsync(["Typo"], CancellationToken.None))[0].StatusCode.ShouldBe(AbCipStatusMapper.BadNodeIdUnknown); drv.GetHealth().State.ShouldBe(DriverState.Healthy); drv.GetHealth().LastSuccessfulRead!.Value.ShouldBeGreaterThan(before); } finally { await drv.DisposeAsync(); } } /// /// U10: a whole int[] written to the 16-element TestArray (DINT[16]) reads back element for element /// through a fresh driver; a wrong-length write is refused BadOutOfRange and changes nothing. /// /// The transport stack the driver runs on. [AbServerTheory] [MemberData(nameof(TransportMatrix.Transports), MemberType = typeof(TransportMatrix))] public async Task A_whole_array_write_round_trips(AbCipTransport transport) { IntegrationWriteGate.SkipUnlessWritesAllowed(); var device = Device(new AbServerFixture()); var seed = Random.Shared.Next(1_000, 1_000_000); var values = Enumerable.Range(0, 16).Select(i => seed + i * 7).ToArray(); AbCipDriver Make(string id) => new(new AbCipDriverOptions { Devices = [new AbCipDeviceOptions(device, AbCipPlcFamily.ControlLogix)], RawTags = AbCipRawTags.From( new AbCipTagDefinition("Arr", device, "TestArray", AbCipDataType.DInt, ElementCount: 16, IsArray: true)), Probe = new AbCipProbeOptions { Enabled = false }, Timeout = TimeSpan.FromSeconds(5), Transport = transport, }, $"drv-u10-{id}-{transport}"); var writer = Make("writer"); await writer.InitializeAsync("{}", CancellationToken.None); try { var w = await writer.WriteAsync([new WriteRequest("Arr", values)], CancellationToken.None); w[0].StatusCode.ShouldBe(AbCipStatusMapper.Good); var bad = await writer.WriteAsync([new WriteRequest("Arr", values.Take(15).ToArray())], CancellationToken.None); bad[0].StatusCode.ShouldBe(AbCipStatusMapper.BadOutOfRange); writer.GetHealth().State.ShouldBe(DriverState.Healthy); } finally { await writer.DisposeAsync(); } var reader = Make("reader"); await reader.InitializeAsync("{}", CancellationToken.None); try { var r = await reader.ReadAsync(["Arr"], CancellationToken.None); r[0].StatusCode.ShouldBe(AbCipStatusMapper.Good); r[0].Value.ShouldBe(values); } finally { await reader.DisposeAsync(); } } }