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; /// /// Phase 4c (Task 6) — 1-D array support for AbCip. Covers: discovery flips /// / /// for an array atomic tag + an array UDT member; the read path returns a typed CLR array /// boxed as ; and the equipment-tag resolver threads /// arrayLength from the TagConfig into the transient definition's element count so /// an isArray equipment tag reads the whole array. /// [Trait("Category", "Unit")] public sealed class AbCipArrayTests { private static (AbCipDriver drv, FakeAbCipTagFactory factory) NewDriver(params AbCipTagDefinition[] tags) { var factory = new FakeAbCipTagFactory(); var opts = new AbCipDriverOptions { Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")], Tags = tags, }; var drv = new AbCipDriver(opts, "drv-array", factory); return (drv, factory); } // ---- Discovery: IsArray / ArrayDim flips ---- /// An atomic pre-declared tag with ElementCount > 1 discovers as a 1-D array. [Fact] public async Task PreDeclared_array_tag_discovers_as_IsArray_with_ArrayDim() { var builder = new RecordingBuilder(); var (drv, _) = NewDriver( new AbCipTagDefinition("Recipe", "ab://10.0.0.5/1,0", "Recipe", AbCipDataType.DInt, ElementCount: 10), new AbCipTagDefinition("Single", "ab://10.0.0.5/1,0", "Single", AbCipDataType.DInt)); await drv.InitializeAsync("{}", CancellationToken.None); await drv.DiscoverAsync(builder, CancellationToken.None); var arr = builder.Variables.Single(v => v.BrowseName == "Recipe").Info; arr.IsArray.ShouldBeTrue(); arr.ArrayDim.ShouldBe(10u); var scalar = builder.Variables.Single(v => v.BrowseName == "Single").Info; scalar.IsArray.ShouldBeFalse(); scalar.ArrayDim.ShouldBeNull(); } /// A UDT member with ElementCount > 1 discovers as a 1-D array variable. [Fact] public async Task Udt_array_member_discovers_as_IsArray_with_ArrayDim() { var builder = new RecordingBuilder(); var (drv, _) = NewDriver( new AbCipTagDefinition("Motor", "ab://10.0.0.5/1,0", "Motor", AbCipDataType.Structure, Members: [ new AbCipStructureMember("Setpoints", AbCipDataType.Real, ElementCount: 4), new AbCipStructureMember("Speed", AbCipDataType.DInt), ])); await drv.InitializeAsync("{}", CancellationToken.None); await drv.DiscoverAsync(builder, CancellationToken.None); var member = builder.Variables.Single(v => v.BrowseName == "Setpoints").Info; member.IsArray.ShouldBeTrue(); member.ArrayDim.ShouldBe(4u); var scalarMember = builder.Variables.Single(v => v.BrowseName == "Speed").Info; scalarMember.IsArray.ShouldBeFalse(); scalarMember.ArrayDim.ShouldBeNull(); } // ---- Read: typed CLR array ---- /// An array DInt tag reads as a boxed int[] of the configured element count. [Fact] public async Task Array_DInt_read_returns_typed_int_array() { var (drv, factory) = NewDriver( new AbCipTagDefinition("Recipe", "ab://10.0.0.5/1,0", "Recipe", AbCipDataType.DInt, ElementCount: 4)); await drv.InitializeAsync("{}", CancellationToken.None); factory.Customise = p => new ArrayFakeAbCipTag(p, new int[] { 11, 22, 33, 44 }); var snapshots = await drv.ReadAsync(["Recipe"], CancellationToken.None); snapshots.Single().StatusCode.ShouldBe(AbCipStatusMapper.Good); var value = snapshots.Single().Value.ShouldBeOfType(); value.ShouldBe([11, 22, 33, 44]); // libplctag element count must be threaded to the runtime params. factory.Tags["Recipe"].CreationParams.ElementCount.ShouldBe(4); } /// An array Real tag reads as a boxed float[]. [Fact] public async Task Array_Real_read_returns_typed_float_array() { var (drv, factory) = NewDriver( new AbCipTagDefinition("Floats", "ab://10.0.0.5/1,0", "Floats", AbCipDataType.Real, ElementCount: 3)); await drv.InitializeAsync("{}", CancellationToken.None); factory.Customise = p => new ArrayFakeAbCipTag(p, new float[] { 1.5f, 2.5f, 3.5f }); var snapshots = await drv.ReadAsync(["Floats"], CancellationToken.None); var value = snapshots.Single().Value.ShouldBeOfType(); value.ShouldBe([1.5f, 2.5f, 3.5f]); } /// An array Bool tag reads as a boxed bool[]. [Fact] public async Task Array_Bool_read_returns_typed_bool_array() { var (drv, factory) = NewDriver( new AbCipTagDefinition("Flags", "ab://10.0.0.5/1,0", "Flags", AbCipDataType.Bool, ElementCount: 3)); await drv.InitializeAsync("{}", CancellationToken.None); factory.Customise = p => new ArrayFakeAbCipTag(p, new bool[] { true, false, true }); var snapshots = await drv.ReadAsync(["Flags"], CancellationToken.None); var value = snapshots.Single().Value.ShouldBeOfType(); value.ShouldBe([true, false, true]); } /// An array String tag reads as a boxed string[]. [Fact] public async Task Array_String_read_returns_typed_string_array() { var (drv, factory) = NewDriver( new AbCipTagDefinition("Names", "ab://10.0.0.5/1,0", "Names", AbCipDataType.String, ElementCount: 2)); await drv.InitializeAsync("{}", CancellationToken.None); factory.Customise = p => new ArrayFakeAbCipTag(p, new string[] { "a", "b" }); var snapshots = await drv.ReadAsync(["Names"], CancellationToken.None); var value = snapshots.Single().Value.ShouldBeOfType(); value.ShouldBe(["a", "b"]); } /// A scalar tag (ElementCount 1) is unaffected — still a boxed scalar, not an array. [Fact] public async Task Scalar_read_path_unchanged_for_element_count_one() { var (drv, factory) = NewDriver( new AbCipTagDefinition("Speed", "ab://10.0.0.5/1,0", "Speed", AbCipDataType.DInt)); await drv.InitializeAsync("{}", CancellationToken.None); factory.Customise = p => new FakeAbCipTag(p) { Value = 4200 }; var snapshots = await drv.ReadAsync(["Speed"], CancellationToken.None); snapshots.Single().Value.ShouldBe(4200); snapshots.Single().Value.ShouldNotBeOfType(); } /// /// Driver.AbCip-016 — a DECLARED UDT member that is a 1-D array (Setpoints : REAL[4]) /// must READ as a typed CLR array, matching the array node it discovers as. Before the fix /// the member fan-out in InitializeAsync dropped the member's ElementCount / /// IsArray, so the fanned-out runtime definition defaulted to scalar and the read /// returned a single element (or null) instead of the array — a declared-type-vs-runtime-value /// mismatch. /// [Fact] public async Task Declared_udt_array_member_reads_as_typed_array() { var (drv, factory) = NewDriver( new AbCipTagDefinition("Motor", "ab://10.0.0.5/1,0", "Motor", AbCipDataType.Structure, Members: [ new AbCipStructureMember("Setpoints", AbCipDataType.Real, ElementCount: 4), new AbCipStructureMember("Speed", AbCipDataType.DInt), ])); await drv.InitializeAsync("{}", CancellationToken.None); factory.Customise = p => new ArrayFakeAbCipTag(p, new float[] { 1.5f, 2.5f, 3.5f, 4.5f }); var snapshots = await drv.ReadAsync(["Motor.Setpoints"], CancellationToken.None); snapshots.Single().StatusCode.ShouldBe(AbCipStatusMapper.Good); var value = snapshots.Single().Value.ShouldBeOfType(); value.ShouldBe([1.5f, 2.5f, 3.5f, 4.5f]); // The fanned-out member runtime must thread the member's element count to libplctag. factory.Tags["Motor.Setpoints"].CreationParams.ElementCount.ShouldBe(4); factory.Tags["Motor.Setpoints"].CreationParams.IsArray.ShouldBeTrue(); } // ---- Resolver: arrayLength threading ---- /// The equipment-tag resolver threads arrayLength into the def's ElementCount. [Fact] public async Task Equipment_ref_with_arrayLength_reads_as_a_typed_array() { var json = """{"deviceHostAddress":"ab://10.0.0.5/1,0","tagPath":"Recipe","dataType":"DInt","isArray":true,"arrayLength":4}"""; var factory = new FakeAbCipTagFactory(); var opts = new AbCipDriverOptions { Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")], Tags = [], }; var drv = new AbCipDriver(opts, "abcip-eq-array", factory); await drv.InitializeAsync("{}", CancellationToken.None); factory.Customise = p => new ArrayFakeAbCipTag(p, new int[] { 7, 8, 9, 10 }); var snapshots = await drv.ReadAsync([json], CancellationToken.None); snapshots.Single().StatusCode.ShouldBe(AbCipStatusMapper.Good); var value = snapshots.Single().Value.ShouldBeOfType(); value.ShouldBe([7, 8, 9, 10]); factory.Tags["Recipe"].CreationParams.ElementCount.ShouldBe(4); } /// The parser threads arrayLength into the transient definition's ElementCount and sets IsArray. [Fact] public void Parser_threads_arrayLength_into_ElementCount() { var json = """{"tagPath":"Recipe","dataType":"DInt","isArray":true,"arrayLength":8}"""; AbCipEquipmentTagParser.TryParse(json, out var def).ShouldBeTrue(); def!.ElementCount.ShouldBe(8); def.IsArray.ShouldBeTrue(); } /// A non-array equipment ref defaults ElementCount to 1 (scalar) and IsArray false. [Fact] public void Parser_defaults_ElementCount_to_one_when_not_an_array() { var json = """{"tagPath":"Recipe","dataType":"DInt"}"""; AbCipEquipmentTagParser.TryParse(json, out var def).ShouldBeTrue(); def!.ElementCount.ShouldBe(1); def.IsArray.ShouldBeFalse(); } /// /// Review finding I-2 — isArray:true with NO arrayLength is a DEGENERATE input /// that must parse as SCALAR (IsArray false, ElementCount 1), matching every other driver /// (Modbus, S7, TwinCAT, AbLegacy). The AdminUI validator blocks authoring this combination, /// but the parser contract must still be consistent cross-driver. /// [Fact] public void Parser_isArray_true_without_arrayLength_parses_as_scalar() { var json = """{"tagPath":"Recipe","dataType":"DInt","isArray":true}"""; AbCipEquipmentTagParser.TryParse(json, out var def).ShouldBeTrue(); def!.IsArray.ShouldBeFalse(); def.ElementCount.ShouldBe(1); } /// /// Review finding I-2 — isArray:true with an invalid (zero) arrayLength also /// parses as SCALAR, consistent with the cross-driver rule. /// [Fact] public void Parser_isArray_true_with_zero_arrayLength_parses_as_scalar() { var json = """{"tagPath":"Recipe","dataType":"DInt","isArray":true,"arrayLength":0}"""; AbCipEquipmentTagParser.TryParse(json, out var def).ShouldBeTrue(); def!.IsArray.ShouldBeFalse(); def.ElementCount.ShouldBe(1); } /// /// Review finding I-1 — a 1-element array (isArray:true, arrayLength:1) is a valid /// 1-element array, NOT a scalar: the parser sets /// true and 1. /// [Fact] public void Parser_treats_isArray_with_arrayLength_one_as_a_one_element_array() { var json = """{"tagPath":"Recipe","dataType":"DInt","isArray":true,"arrayLength":1}"""; AbCipEquipmentTagParser.TryParse(json, out var def).ShouldBeTrue(); def!.IsArray.ShouldBeTrue(); def.ElementCount.ShouldBe(1); } /// /// Review finding I-1 — isArray:true, arrayLength:1 must DISCOVER as a [1] array node /// (IsArray + ArrayDim 1), matching the foundation's materialisation, not as a scalar. /// [Fact] public async Task Equipment_ref_isArray_arrayLength_one_discovers_as_one_element_array() { var json = """{"deviceHostAddress":"ab://10.0.0.5/1,0","tagPath":"Recipe","dataType":"DInt","isArray":true,"arrayLength":1}"""; AbCipEquipmentTagParser.TryParse(json, out var def).ShouldBeTrue(); var builder = new RecordingBuilder(); var (drv, _) = NewDriver(def!); await drv.InitializeAsync("{}", CancellationToken.None); await drv.DiscoverAsync(builder, CancellationToken.None); var arr = builder.Variables.Single().Info; arr.IsArray.ShouldBeTrue(); arr.ArrayDim.ShouldBe(1u); } /// /// Review finding I-1 — the I-1 case: an isArray:true, arrayLength:1 equipment tag /// reads a 1-ELEMENT typed array, NOT a scalar. On current code (gate ElementCount > 1) /// this reads a scalar; the explicit IsArray flag fixes it. /// [Fact] public async Task Equipment_ref_isArray_arrayLength_one_reads_as_one_element_array() { var json = """{"deviceHostAddress":"ab://10.0.0.5/1,0","tagPath":"Recipe","dataType":"DInt","isArray":true,"arrayLength":1}"""; var factory = new FakeAbCipTagFactory(); var opts = new AbCipDriverOptions { Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")], Tags = [], }; var drv = new AbCipDriver(opts, "abcip-eq-array1", factory); await drv.InitializeAsync("{}", CancellationToken.None); factory.Customise = p => new ArrayFakeAbCipTag(p, new int[] { 99 }); var snapshots = await drv.ReadAsync([json], CancellationToken.None); snapshots.Single().StatusCode.ShouldBe(AbCipStatusMapper.Good); var value = snapshots.Single().Value.ShouldBeOfType(); value.ShouldBe([99]); // A 1-element array still threads elem_count 1 to libplctag. factory.Tags["Recipe"].CreationParams.ElementCount.ShouldBe(1); } /// /// Regression — a genuinely scalar equipment ref (isArray:false) reads a boxed /// scalar via , never an array. /// [Fact] public async Task Equipment_ref_isArray_false_reads_as_scalar() { var json = """{"deviceHostAddress":"ab://10.0.0.5/1,0","tagPath":"Speed","dataType":"DInt","isArray":false}"""; var factory = new FakeAbCipTagFactory(); var opts = new AbCipDriverOptions { Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")], Tags = [], }; var drv = new AbCipDriver(opts, "abcip-eq-scalar", factory); await drv.InitializeAsync("{}", CancellationToken.None); factory.Customise = p => new FakeAbCipTag(p) { Value = 4200 }; var snapshots = await drv.ReadAsync([json], CancellationToken.None); snapshots.Single().Value.ShouldBe(4200); snapshots.Single().Value.ShouldNotBeOfType(); } // ---- helpers ---- /// Minimal recorder for the discovery assertions. private sealed class RecordingBuilder : IAddressSpaceBuilder { public List<(string BrowseName, string DisplayName)> Folders { get; } = new(); public List<(string BrowseName, DriverAttributeInfo Info)> Variables { get; } = new(); public IAddressSpaceBuilder Folder(string browseName, string displayName) { Folders.Add((browseName, displayName)); return this; } public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo info) { Variables.Add((browseName, info)); return new Handle(info.FullName); } public void AddProperty(string _, DriverDataType __, object? ___) { } private sealed class Handle(string fullRef) : IVariableHandle { public string FullReference => fullRef; public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NullSink(); } private sealed class NullSink : IAlarmConditionSink { public void OnTransition(AlarmEventArgs args) { } } } }