using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy; namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests; /// /// Phase 4c #137 array support for the AbLegacy (PCCC/DF1) driver. A PCCC data file /// (e.g. N7) is inherently an array of up to 256 words; an equipment tag carrying an /// arrayLength reads count consecutive file elements from the base address /// (N7:0 reading count words) via libplctag's native element-count and surfaces /// them as a typed CLR array. Proven here against ; there is no /// live AbLegacy fixture on this machine. /// [Trait("Category", "Unit")] public sealed class AbLegacyArrayTests { private static (AbLegacyDriver drv, FakeAbLegacyTagFactory factory) NewDriver(params AbLegacyTagDefinition[] tags) { var factory = new FakeAbLegacyTagFactory(); var drv = new AbLegacyDriver(new AbLegacyDriverOptions { Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0")], Tags = tags, Probe = new AbLegacyProbeOptions { Enabled = false }, }, "ablegacy-array", factory); return (drv, factory); } // ---- Read: typed CLR arrays ---- /// An N-file (16-bit integer) array tag reads a short[]. [Fact] public async Task Read_N_file_array_returns_short_array() { var (drv, factory) = NewDriver( new AbLegacyTagDefinition("Levels", "ab://10.0.0.5/1,0", "N7:0", AbLegacyDataType.Int, ArrayLength: 5)); await drv.InitializeAsync("{}", CancellationToken.None); factory.Customise = p => new FakeAbLegacyTag(p) { ArrayValue = new short[] { 100, 101, 102, 103, 104 } }; var snapshots = await drv.ReadAsync(["Levels"], CancellationToken.None); snapshots.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good); var arr = snapshots.Single().Value.ShouldBeOfType(); arr.ShouldBe(new short[] { 100, 101, 102, 103, 104 }); } /// An L-file (32-bit long) array tag reads an int[]. [Fact] public async Task Read_L_file_array_returns_int_array() { var (drv, factory) = NewDriver( new AbLegacyTagDefinition("Counts", "ab://10.0.0.5/1,0", "L9:0", AbLegacyDataType.Long, ArrayLength: 3)); await drv.InitializeAsync("{}", CancellationToken.None); factory.Customise = p => new FakeAbLegacyTag(p) { ArrayValue = new[] { 70000, 80000, 90000 } }; var snapshots = await drv.ReadAsync(["Counts"], CancellationToken.None); var arr = snapshots.Single().Value.ShouldBeOfType(); arr.ShouldBe(new[] { 70000, 80000, 90000 }); } /// An F-file (32-bit float) array tag reads a float[]. [Fact] public async Task Read_F_file_array_returns_float_array() { var (drv, factory) = NewDriver( new AbLegacyTagDefinition("Temps", "ab://10.0.0.5/1,0", "F8:0", AbLegacyDataType.Float, ArrayLength: 4)); await drv.InitializeAsync("{}", CancellationToken.None); factory.Customise = p => new FakeAbLegacyTag(p) { ArrayValue = new[] { 1.5f, 2.5f, 3.5f, 4.5f } }; var snapshots = await drv.ReadAsync(["Temps"], CancellationToken.None); var arr = snapshots.Single().Value.ShouldBeOfType(); arr.ShouldBe(new[] { 1.5f, 2.5f, 3.5f, 4.5f }); } /// A B-file (bit) array tag reads a bool[]. [Fact] public async Task Read_B_file_array_returns_bool_array() { var (drv, factory) = NewDriver( new AbLegacyTagDefinition("Flags", "ab://10.0.0.5/1,0", "B3:0", AbLegacyDataType.Bit, ArrayLength: 6)); await drv.InitializeAsync("{}", CancellationToken.None); factory.Customise = p => new FakeAbLegacyTag(p) { ArrayValue = new[] { true, false, true, false, true, false } }; var snapshots = await drv.ReadAsync(["Flags"], CancellationToken.None); var arr = snapshots.Single().Value.ShouldBeOfType(); arr.ShouldBe(new[] { true, false, true, false, true, false }); } /// The element count flows into the create params so libplctag reads N words. [Fact] public async Task Array_tag_threads_element_count_into_create_params() { var (drv, factory) = NewDriver( new AbLegacyTagDefinition("Levels", "ab://10.0.0.5/1,0", "N7:0", AbLegacyDataType.Int, ArrayLength: 5)); await drv.InitializeAsync("{}", CancellationToken.None); factory.Customise = p => new FakeAbLegacyTag(p) { ArrayValue = new short[] { 1, 2, 3, 4, 5 } }; await drv.ReadAsync(["Levels"], CancellationToken.None); factory.Tags["N7:0"].CreationParams.ElementCount.ShouldBe(5); } /// A read failure on an array tag surfaces the mapped Bad status, not a partial array. [Fact] public async Task Array_read_failure_surfaces_bad_status() { var (drv, factory) = NewDriver( new AbLegacyTagDefinition("Levels", "ab://10.0.0.5/1,0", "N7:0", AbLegacyDataType.Int, ArrayLength: 5)); await drv.InitializeAsync("{}", CancellationToken.None); factory.Customise = p => new FakeAbLegacyTag(p) { Status = (int)libplctag.Status.ErrorTimeout }; var snapshots = await drv.ReadAsync(["Levels"], CancellationToken.None); snapshots.Single().Value.ShouldBeNull(); snapshots.Single().StatusCode.ShouldNotBe(AbLegacyStatusMapper.Good); } // ---- Scalar regression ---- /// A scalar tag (no ArrayLength) still reads a single value, not an array. [Fact] public async Task Scalar_tag_still_reads_single_value() { var (drv, factory) = NewDriver( new AbLegacyTagDefinition("Counter", "ab://10.0.0.5/1,0", "N7:0", AbLegacyDataType.Int)); await drv.InitializeAsync("{}", CancellationToken.None); factory.Customise = p => new FakeAbLegacyTag(p) { Value = 42 }; var snapshots = await drv.ReadAsync(["Counter"], CancellationToken.None); snapshots.Single().Value.ShouldBe(42); snapshots.Single().Value.ShouldNotBeOfType(); factory.Tags["N7:0"].CreationParams.ElementCount.ShouldBe(1); } /// An ArrayLength of 1 behaves like a scalar (single value, ElementCount 1). [Fact] public async Task ArrayLength_of_one_behaves_like_scalar() { var (drv, factory) = NewDriver( new AbLegacyTagDefinition("Counter", "ab://10.0.0.5/1,0", "N7:0", AbLegacyDataType.Int, ArrayLength: 1)); await drv.InitializeAsync("{}", CancellationToken.None); factory.Customise = p => new FakeAbLegacyTag(p) { Value = 7 }; var snapshots = await drv.ReadAsync(["Counter"], CancellationToken.None); snapshots.Single().Value.ShouldBe(7); factory.Tags["N7:0"].CreationParams.ElementCount.ShouldBe(1); } // ---- Discovery ---- /// Discovery flips IsArray and surfaces ArrayDim for an array file tag. [Fact] public async Task Discovery_surfaces_IsArray_and_ArrayDim_for_array_tag() { var captured = new List(); var drv = new AbLegacyDriver(new AbLegacyDriverOptions { Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0")], Tags = [new AbLegacyTagDefinition("Vector", "ab://10.0.0.5/1,0", "N7:0", AbLegacyDataType.Int, ArrayLength: 8)], Probe = new AbLegacyProbeOptions { Enabled = false }, }, "ablegacy-array", new FakeAbLegacyTagFactory()); await drv.InitializeAsync("{}", CancellationToken.None); await drv.DiscoverAsync(new RecordingBuilder(captured), CancellationToken.None); captured.Count.ShouldBe(1); captured[0].IsArray.ShouldBeTrue(); captured[0].ArrayDim.ShouldBe(8u); } /// Scalar tag discovery keeps IsArray false / ArrayDim null (regression guard). [Fact] public async Task Discovery_keeps_scalar_tag_non_array() { var captured = new List(); var (drv, _) = NewDriver( new AbLegacyTagDefinition("Single", "ab://10.0.0.5/1,0", "N7:0", AbLegacyDataType.Int)); await drv.InitializeAsync("{}", CancellationToken.None); await drv.DiscoverAsync(new RecordingBuilder(captured), CancellationToken.None); captured[0].IsArray.ShouldBeFalse(); captured[0].ArrayDim.ShouldBeNull(); } /// Discovery caps a 1024-element tag's ArrayDim at the PCCC 256-word file maximum. [Fact] public async Task Discovery_caps_array_dim_at_256() { var captured = new List(); var (drv, _) = NewDriver( new AbLegacyTagDefinition("Big", "ab://10.0.0.5/1,0", "N7:0", AbLegacyDataType.Int, ArrayLength: 1024)); await drv.InitializeAsync("{}", CancellationToken.None); await drv.DiscoverAsync(new RecordingBuilder(captured), CancellationToken.None); captured[0].IsArray.ShouldBeTrue(); captured[0].ArrayDim.ShouldBe(256u); } // ---- Equipment-tag resolver threads arrayLength ---- /// The equipment-tag parser threads arrayLength into the transient definition. [Fact] public void EquipmentTagParser_threads_arrayLength() { var json = """{"deviceHostAddress":"ab://10.0.0.5/1,0","address":"N7:0","dataType":"Int","isArray":true,"arrayLength":10}"""; AbLegacyEquipmentTagParser.TryParse(json, out var def).ShouldBeTrue(); def!.ArrayLength.ShouldBe(10); } /// The parser caps arrayLength at the PCCC 256-word file maximum. [Fact] public void EquipmentTagParser_caps_arrayLength_at_256() { var json = """{"deviceHostAddress":"ab://10.0.0.5/1,0","address":"N7:0","dataType":"Int","arrayLength":99999}"""; AbLegacyEquipmentTagParser.TryParse(json, out var def).ShouldBeTrue(); def!.ArrayLength.ShouldBe(256); } /// A scalar equipment tag (no arrayLength) leaves ArrayLength null. [Fact] public void EquipmentTagParser_scalar_leaves_arrayLength_null() { var json = """{"deviceHostAddress":"ab://10.0.0.5/1,0","address":"N7:0","dataType":"Int"}"""; AbLegacyEquipmentTagParser.TryParse(json, out var def).ShouldBeTrue(); def!.ArrayLength.ShouldBeNull(); } /// /// End-to-end: an AbLegacy driver with NO authored tags reads an equipment-tag ref whose /// TagConfig carries arrayLength — the resolver threads the count and the read /// surfaces a typed array, capping the libplctag element count at 256. /// [Fact] public async Task Driver_reads_equipment_array_ref_as_typed_array() { var json = """{"deviceHostAddress":"ab://10.0.0.5/1,0","address":"N7:0","dataType":"Int","arrayLength":3}"""; var factory = new FakeAbLegacyTagFactory { Customise = p => new FakeAbLegacyTag(p) { ArrayValue = new short[] { 11, 22, 33 } } }; var drv = new AbLegacyDriver(new AbLegacyDriverOptions { Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0")], Tags = [], Probe = new AbLegacyProbeOptions { Enabled = false }, }, "ablegacy-eq-array", factory); await drv.InitializeAsync("{}", CancellationToken.None); var r = await drv.ReadAsync([json], CancellationToken.None); r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good); var arr = r[0].Value.ShouldBeOfType(); arr.ShouldBe(new short[] { 11, 22, 33 }); factory.Tags["N7:0"].CreationParams.ElementCount.ShouldBe(3); } private sealed class RecordingBuilder(List captured) : IAddressSpaceBuilder { public IAddressSpaceBuilder Folder(string browseName, string displayName) => this; public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo info) { captured.Add(info); return new Handle(info.FullName); } public void AddProperty(string name, DriverDataType dataType, object? value) { } 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) { } } } }