using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport; using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Fakes; namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests; /// /// Review 07 H1 / L4: a status that proves the controller ANSWERED (NotFound, OutOfBounds, NotAllowed, /// TooLarge, TooSmall, TypeMismatch, Unsupported) is a deterministic per-tag refusal, not a comms fault. It /// keeps the tag runtime (no evict → no lease / Forward-Open churn), writes no Degraded, opens no device-wide /// backoff window for siblings, warns once, and — since the device answered — advances LastSuccessfulRead. /// [Trait("Category", "Unit")] public sealed class AbCipDeviceRefusalTests { private const string Device = "ab://10.0.0.5/1,0"; /// A fake whose reads/writes throw a chosen transport status (init succeeds unless told otherwise). private sealed class RefusingTag(AbCipTagCreateParams p) : FakeAbCipTag(p) { public AbStatus? ReadThrows { get; set; } public AbStatus? WriteThrows { get; set; } public override async Task ReadAsync(CancellationToken cancellationToken) { await base.ReadAsync(cancellationToken); if (ReadThrows is { } s) throw new AbTransportException(s, s.ToString()); } public override async Task WriteAsync(CancellationToken cancellationToken) { await base.WriteAsync(cancellationToken); if (WriteThrows is { } s) throw new AbTransportException(s, s.ToString()); } } private static (AbCipDriver Driver, FakeAbCipTagFactory Factory) Build( Func make) { var factory = new FakeAbCipTagFactory { Customise = make }; var drv = new AbCipDriver(new AbCipDriverOptions { Devices = [new AbCipDeviceOptions(Device)], RawTags = AbCipRawTags.From( new AbCipTagDefinition("Bad", Device, "Arr[999]", AbCipDataType.DInt), new AbCipTagDefinition("Good", Device, "Real1", AbCipDataType.DInt)), Probe = new AbCipProbeOptions { Enabled = false }, MaxConcurrentReads = 1, }, "abcip-refusal", factory); return (drv, factory); } /// The classification itself: every answered refusal is reachable; transport failures are not. /// The transport status. /// Whether it is a device refusal. [Theory] [InlineData(AbStatus.NotFound, true)] [InlineData(AbStatus.OutOfBounds, true)] [InlineData(AbStatus.NotAllowed, true)] [InlineData(AbStatus.TooLarge, true)] [InlineData(AbStatus.TooSmall, true)] [InlineData(AbStatus.TypeMismatch, true)] [InlineData(AbStatus.Unsupported, true)] [InlineData(AbStatus.Ok, false)] [InlineData(AbStatus.Timeout, false)] [InlineData(AbStatus.BadConnection, false)] [InlineData(AbStatus.BadReply, false)] [InlineData(AbStatus.Partial, false)] [InlineData(AbStatus.Abort, false)] public void Answered_statuses_are_device_refusals(AbStatus status, bool refusal) { AbCipRefusals.IsDeviceRefusal(status).ShouldBe(refusal); AbCipDriverProbe.IsReachableStatus(status).ShouldBe(refusal || status == AbStatus.Ok); } /// A read the controller refuses keeps its runtime, stays Healthy, and advances LastSuccessfulRead. /// The refusal the controller answers with. /// The OPC UA status the tag reads. [Theory] [InlineData(AbStatus.NotFound, AbCipStatusMapper.BadNodeIdUnknown)] [InlineData(AbStatus.OutOfBounds, AbCipStatusMapper.BadOutOfRange)] [InlineData(AbStatus.TooLarge, AbCipStatusMapper.BadOutOfRange)] [InlineData(AbStatus.TooSmall, AbCipStatusMapper.BadOutOfRange)] [InlineData(AbStatus.NotAllowed, AbCipStatusMapper.BadNotReadable)] public async Task A_refused_read_keeps_the_runtime_and_writes_no_Degraded(AbStatus status, uint expected) { var (drv, factory) = Build(p => new RefusingTag(p) { ReadThrows = p.TagName == "Arr[999]" ? status : null, Value = 1 }); await drv.InitializeAsync("{}", CancellationToken.None); var before = drv.GetHealth().LastSuccessfulRead!.Value; await Task.Delay(20); for (var i = 0; i < 3; i++) { var r = await drv.ReadAsync(["Bad"], CancellationToken.None); r[0].StatusCode.ShouldBe(expected); } var bad = factory.Tags["Arr[999]"]; bad.InitializeCount.ShouldBe(1); // never evicted → never re-created bad.Disposed.ShouldBeFalse(); bad.ReadCount.ShouldBe(3); var health = drv.GetHealth(); health.State.ShouldBe(DriverState.Healthy); health.LastSuccessfulRead!.Value.ShouldBeGreaterThan(before); // the device answered } /// Positive control: a genuine transport failure on the same tag still evicts and degrades. [Fact] public async Task A_transport_failure_still_evicts_and_degrades() { var (drv, factory) = Build(p => new RefusingTag(p) { ReadThrows = p.TagName == "Arr[999]" ? AbStatus.Timeout : null }); await drv.InitializeAsync("{}", CancellationToken.None); var r = await drv.ReadAsync(["Bad"], CancellationToken.None); r[0].StatusCode.ShouldBe(AbCipStatusMapper.BadTimeout); factory.Tags["Arr[999]"].Disposed.ShouldBeTrue(); drv.GetHealth().State.ShouldBe(DriverState.Degraded); } /// A refusal returned as a status word (no throw) is classified the same way. [Fact] public async Task A_refusal_status_word_keeps_the_runtime() { var (drv, factory) = Build(p => new FakeAbCipTag(p) { Status = p.TagName == "Arr[999]" ? (int)AbStatus.OutOfBounds : 0, Value = 1 }); await drv.InitializeAsync("{}", CancellationToken.None); await drv.ReadAsync(["Bad"], CancellationToken.None); var r = await drv.ReadAsync(["Bad", "Good"], CancellationToken.None); r[0].StatusCode.ShouldBe(AbCipStatusMapper.BadOutOfRange); r[1].StatusCode.ShouldBe(AbCipStatusMapper.Good); factory.Tags["Arr[999]"].InitializeCount.ShouldBe(1); factory.Tags["Arr[999]"].Disposed.ShouldBeFalse(); drv.GetHealth().State.ShouldBe(DriverState.Healthy); } /// /// A cold create the controller refuses with OutOfBounds proves the session works: it must not open the /// device-wide backoff window, so a sibling cold tag still connects and reads Good. /// [Fact] public async Task A_refused_create_does_not_throttle_sibling_cold_creates() { var (drv, _) = Build(p => p.TagName == "Arr[999]" ? new FakeAbCipTag(p) { ThrowOnInitialize = true, Exception = new AbTransportException(AbStatus.OutOfBounds, "OutOfBounds") } : new FakeAbCipTag(p) { Value = 42 }); await drv.InitializeAsync("{}", CancellationToken.None); var r = await drv.ReadAsync(["Bad", "Good"], CancellationToken.None); r[0].StatusCode.ShouldBe(AbCipStatusMapper.BadOutOfRange); r[1].StatusCode.ShouldBe(AbCipStatusMapper.Good); r[1].Value.ShouldBe(42); drv.GetHealth().State.ShouldBe(DriverState.Healthy); } /// A write the controller refuses keeps the runtime and writes no health. [Fact] public async Task A_refused_write_keeps_the_runtime_and_writes_no_Degraded() { var factory = new FakeAbCipTagFactory { Customise = p => new RefusingTag(p) { WriteThrows = AbStatus.NotAllowed } }; var drv = new AbCipDriver(new AbCipDriverOptions { Devices = [new AbCipDeviceOptions(Device)], RawTags = AbCipRawTags.From(new AbCipTagDefinition("Ro", Device, "ReadOnlyTag", AbCipDataType.DInt)), Probe = new AbCipProbeOptions { Enabled = false }, }, "abcip-refusal", factory); await drv.InitializeAsync("{}", CancellationToken.None); var w1 = await drv.WriteAsync([new WriteRequest("Ro", 5)], CancellationToken.None); var w2 = await drv.WriteAsync([new WriteRequest("Ro", 6)], CancellationToken.None); w1[0].StatusCode.ShouldBe(AbCipStatusMapper.BadNotWritable); w2[0].StatusCode.ShouldBe(AbCipStatusMapper.BadNotWritable); factory.Tags["ReadOnlyTag"].InitializeCount.ShouldBe(1); factory.Tags["ReadOnlyTag"].Disposed.ShouldBeFalse(); drv.GetHealth().State.ShouldBe(DriverState.Healthy); } /// /// L4 end to end over the REAL native runtime + a private pool: a renamed tag (CIP 0x04) that is the device's /// ONLY lease holder used to be evicted every poll — the last release, so Forward Close, then a throttled Forward /// Open on the next poll. One runtime and one Forward Open across N polls and no Forward Close proves no churn. /// [Fact] public async Task A_native_NotFound_tag_causes_no_runtime_or_connection_churn() { await using var target = new FakeCipTarget(); target.ConnectedHandler = req => Task.FromResult( req[0] == 0x52 ? [0xD2, 0x00, 0x04, 0x00] : target.ServiceReply(req)); var host = $"ab://127.0.0.1:{target.Port}/1,0"; var creates = 0; var native = new NativeAbCipTagFactory(new DeviceSessionPool()); var factory = new CountingFactory(p => { Interlocked.Increment(ref creates); return native.Create(p); }); var drv = new AbCipDriver(new AbCipDriverOptions { Devices = [new AbCipDeviceOptions(host, AbCipPlcFamily.ControlLogix)], RawTags = AbCipRawTags.From(new AbCipTagDefinition("Renamed", host, "Renamed", AbCipDataType.DInt)), Probe = new AbCipProbeOptions { Enabled = false }, Timeout = TimeSpan.FromSeconds(5), }, "abcip-l4-native", factory); await drv.InitializeAsync("{}", CancellationToken.None); try { for (var n = 0; n < 5; n++) (await drv.ReadAsync(["Renamed"], CancellationToken.None))[0].StatusCode .ShouldBe(AbCipStatusMapper.BadNodeIdUnknown); Volatile.Read(ref creates).ShouldBe(1); target.ForwardOpens.ShouldBe(1); target.ForwardCloses.ShouldBe(0); target.ConnectedRequests.ShouldBeGreaterThanOrEqualTo(5); // positive control: every poll hit the wire drv.GetHealth().State.ShouldBe(DriverState.Healthy); } finally { await drv.DisposeAsync(); } } private sealed class CountingFactory(Func create) : IAbCipTagFactory { public IAbCipTagRuntime Create(AbCipTagCreateParams createParams) => create(createParams); } }