using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests; /// /// Review 07 H16: an array of String/Timer/Counter/Control was accepted by the mapper, so every poll created a /// handle (one init read), threw from the decode, disposed the handle and /// latched Degraded. A deterministic refusal is now rejected at the mapper; a runtime /// that still occurs is per-tag BadNotSupported, keeps its handle, /// warns once and leaves driver health alone. /// [Trait("Category", "Unit")] public sealed class AbLegacyDeterministicRefusalTests { private const string Host = "ab://10.0.0.5/1,0"; [Theory] [InlineData("String", "ST9:0")] [InlineData("TimerElement", "T4:0")] [InlineData("CounterElement", "C5:0")] [InlineData("ControlElement", "R6:0")] public void Mapper_rejects_arrays_of_structured_element_types(string dataType, string address) { var json = $$"""{"address":"{{address}}","dataType":"{{dataType}}","isArray":true,"arrayLength":4}"""; AbLegacyTagDefinitionFactory.FromTagConfig(json, "p", out _).ShouldBeFalse(); AbLegacyTagDefinitionFactory.Inspect(json).ShouldContain(w => w.Contains("not supported as an array")); // Positive control: the same type as a SCALAR is still accepted. AbLegacyTagDefinitionFactory.FromTagConfig( $$"""{"address":"{{address}}","dataType":"{{dataType}}"}""", "p", out _).ShouldBeTrue(); } [Theory] [InlineData("Int", "N7:0")] [InlineData("Float", "F8:0")] [InlineData("Long", "L9:0")] [InlineData("Bit", "B3:0")] public void Mapper_keeps_arrays_of_flat_element_types(string dataType, string address) { var json = $$"""{"address":"{{address}}","dataType":"{{dataType}}","isArray":true,"arrayLength":4}"""; AbLegacyTagDefinitionFactory.FromTagConfig(json, "p", out var def).ShouldBeTrue(); def.ArrayLength.ShouldBe(4); AbLegacyTagDefinitionFactory.Inspect(json).ShouldBeEmpty(); } [Fact] public async Task Rejected_array_tag_is_skipped_with_its_reason_and_creates_no_runtime() { var factory = new FakeAbLegacyTagFactory { Customise = p => new FakeAbLegacyTag(p) { Value = 1 } }; var drv = new AbLegacyDriver(new AbLegacyDriverOptions { Devices = [new AbLegacyDeviceOptions(Host)], RawTags = [ new RawTagEntry("Strs", """{"address":"ST9:0","dataType":"String","isArray":true,"arrayLength":3}""", false, Host), new RawTagEntry("Good", """{"address":"N7:0","dataType":"Int"}""", false, Host), ], Probe = new AbLegacyProbeOptions { Enabled = false }, }, "drv-h16", factory); await drv.InitializeAsync("{}", CancellationToken.None); var r = await drv.ReadAsync(["Strs", "Good"], CancellationToken.None); r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadNodeIdUnknown); r[1].StatusCode.ShouldBe(AbLegacyStatusMapper.Good); // positive control factory.Tags.Keys.ShouldNotContain("ST9:0"); var skipped = drv.GetSkippedTags().ShouldNotBeNull(); skipped.Sample.ShouldContain(s => s.RawPath == "Strs" && s.Reason.Contains("not supported as an array")); drv.GetHealth().State.ShouldBe(DriverState.Healthy); await drv.ShutdownAsync(CancellationToken.None); } private sealed class RefusingDecodeTag(AbLegacyTagCreateParams p) : FakeAbLegacyTag(p) { public override object? DecodeArray(AbLegacyDataType type, int count) => throw new NotSupportedException("decode refused"); public override void EncodeValue(AbLegacyDataType type, int? bitIndex, object? value) => throw new NotSupportedException("encode refused"); } [Fact] public async Task Runtime_NotSupported_on_read_is_BadNotSupported_keeps_handle_no_health_warns_once() { var logger = new AbLegacyTestLogger(); var factory = new FakeAbLegacyTagFactory { Customise = p => p.TagName == "N7:0" ? new RefusingDecodeTag(p) : new FakeAbLegacyTag(p) { Value = 7 }, }; var drv = new AbLegacyDriver(new AbLegacyDriverOptions { Devices = [new AbLegacyDeviceOptions(Host)], RawTags = AbLegacyRawTags.Entries( new AbLegacyTagDefinition("Arr", Host, "N7:0", AbLegacyDataType.Int, ArrayLength: 4), new AbLegacyTagDefinition("Good", Host, "N9:0", AbLegacyDataType.Int)), Probe = new AbLegacyProbeOptions { Enabled = false }, }, "drv-h16", factory, logger); await drv.InitializeAsync("{}", CancellationToken.None); var first = await drv.ReadAsync(["Arr", "Good"], CancellationToken.None); var second = await drv.ReadAsync(["Arr", "Good"], CancellationToken.None); first[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadNotSupported); second[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadNotSupported); first[1].StatusCode.ShouldBe(AbLegacyStatusMapper.Good); // positive control: the refusal is per tag var tag = factory.Tags["N7:0"]; tag.InitializeCount.ShouldBe(1); // handle kept, not churned tag.Disposed.ShouldBeFalse(); drv.GetHealth().State.ShouldBe(DriverState.Healthy); logger.Warnings.Count(w => w.Contains("Arr") && w.Contains("decode refused")).ShouldBe(1); await drv.ShutdownAsync(CancellationToken.None); } [Fact] public async Task Runtime_NotSupported_on_write_is_BadNotSupported_with_no_health_write() { var factory = new FakeAbLegacyTagFactory { Customise = p => new RefusingDecodeTag(p) { Value = 1 } }; var drv = new AbLegacyDriver(new AbLegacyDriverOptions { Devices = [new AbLegacyDeviceOptions(Host)], RawTags = AbLegacyRawTags.Entries(new AbLegacyTagDefinition("X", Host, "N7:0", AbLegacyDataType.Int)), Probe = new AbLegacyProbeOptions { Enabled = false }, }, "drv-h16", factory); await drv.InitializeAsync("{}", CancellationToken.None); (await drv.ReadAsync(["X"], CancellationToken.None))[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good); var w = await drv.WriteAsync([new WriteRequest("X", 5)], CancellationToken.None); w[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadNotSupported); drv.GetHealth().State.ShouldBe(DriverState.Healthy); factory.Tags["N7:0"].Disposed.ShouldBeFalse(); await drv.ShutdownAsync(CancellationToken.None); } }