using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT; namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests; /// /// Phase 4c — 1-D array support for the TwinCAT (ADS) driver. Covers the two discovery /// hard-wire flips (pre-declared + discovered symbols now report IsArray/ArrayDim from the /// ADS symbol's array dimension), the array READ path (the equipment tag's arrayLength drives /// a native array read boxed into a typed CLR array), and the equipment-tag parser threading /// arrayLength from the TagConfig JSON. /// [Trait("Category", "Unit")] public sealed class TwinCATArraySupportTests { private const string Host = "ads://5.23.91.23.1.1:851"; // ---- (1) discovery: pre-declared array tag flips IsArray/ArrayDim ---- /// A pre-declared tag carrying an ArrayLength surfaces as a 1-D array node. [Fact] public async Task Predeclared_array_tag_reports_IsArray_and_ArrayDim() { var builder = new RecordingBuilder(); var drv = new TwinCATDriver(new TwinCATDriverOptions { Devices = [new TwinCATDeviceOptions(Host)], Tags = [ new TwinCATTagDefinition("Speeds", Host, "MAIN.Speeds", TwinCATDataType.DInt, Writable: true, WriteIdempotent: false, ArrayLength: 8), ], Probe = new TwinCATProbeOptions { Enabled = false }, EnableControllerBrowse = false, }, "drv-1", new FakeTwinCATClientFactory()); await drv.InitializeAsync("{}", CancellationToken.None); await drv.DiscoverAsync(builder, CancellationToken.None); var v = builder.Variables.Single(x => x.BrowseName == "Speeds").Info; v.IsArray.ShouldBeTrue(); v.ArrayDim.ShouldBe(8u); } /// A scalar pre-declared tag still reports IsArray=false / ArrayDim=null. [Fact] public async Task Predeclared_scalar_tag_stays_scalar() { var builder = new RecordingBuilder(); var drv = new TwinCATDriver(new TwinCATDriverOptions { Devices = [new TwinCATDeviceOptions(Host)], Tags = [new TwinCATTagDefinition("Speed", Host, "MAIN.Speed", TwinCATDataType.DInt)], Probe = new TwinCATProbeOptions { Enabled = false }, }, "drv-1", new FakeTwinCATClientFactory()); await drv.InitializeAsync("{}", CancellationToken.None); await drv.DiscoverAsync(builder, CancellationToken.None); var v = builder.Variables.Single(x => x.BrowseName == "Speed").Info; v.IsArray.ShouldBeFalse(); v.ArrayDim.ShouldBeNull(); } // ---- (1) discovery: discovered (browsed) array symbol flips IsArray/ArrayDim ---- /// A discovered (browsed) 1-D array symbol surfaces as a 1-D array node. [Fact] public async Task Discovered_array_symbol_reports_IsArray_and_ArrayDim() { var builder = new RecordingBuilder(); var factory = new FakeTwinCATClientFactory { Customise = () => { var c = new FakeTwinCATClient(); c.BrowseResults.Add(new TwinCATDiscoveredSymbol( "MAIN.Buffer", TwinCATDataType.Int, ReadOnly: false, ArrayLength: 16)); c.BrowseResults.Add(new TwinCATDiscoveredSymbol( "GVL.Scalar", TwinCATDataType.Real, ReadOnly: false)); return c; }, }; var drv = new TwinCATDriver(new TwinCATDriverOptions { Devices = [new TwinCATDeviceOptions(Host)], Probe = new TwinCATProbeOptions { Enabled = false }, EnableControllerBrowse = true, }, "drv-1", factory); await drv.InitializeAsync("{}", CancellationToken.None); await drv.DiscoverAsync(builder, CancellationToken.None); var arr = builder.Variables.Single(x => x.Info.FullName == "MAIN.Buffer").Info; arr.IsArray.ShouldBeTrue(); arr.ArrayDim.ShouldBe(16u); var scalar = builder.Variables.Single(x => x.Info.FullName == "GVL.Scalar").Info; scalar.IsArray.ShouldBeFalse(); scalar.ArrayDim.ShouldBeNull(); } // ---- (2) read path: array read returns a typed CLR array ---- /// An equipment array tag reads a typed CLR array (boxed as object) through the driver. [Fact] public async Task Driver_reads_an_array_equipment_tag_as_typed_clr_array() { var json = $$"""{"deviceHostAddress":"{{Host}}","symbolPath":"MAIN.Speeds","dataType":"DInt","isArray":true,"arrayLength":4}"""; var expected = new[] { 10, 20, 30, 40 }; var factory = new FakeTwinCATClientFactory { Customise = () => new FakeTwinCATClient { Values = { ["MAIN.Speeds"] = expected } }, }; var drv = new TwinCATDriver(new TwinCATDriverOptions { Devices = [new TwinCATDeviceOptions(Host)], Tags = [], Probe = new TwinCATProbeOptions { Enabled = false }, }, "twincat-arr", factory); await drv.InitializeAsync("{}", CancellationToken.None); var r = await drv.ReadAsync([json], CancellationToken.None); r[0].StatusCode.ShouldBe(TwinCATStatusMapper.Good); r[0].Value.ShouldBeOfType().ShouldBe(expected); // The driver must request the array read with the authored length. factory.Clients[0].LastReadArrayCount.ShouldBe(4); } /// A scalar equipment tag reads with a null array count (scalar path unchanged). [Fact] public async Task Driver_reads_a_scalar_equipment_tag_with_null_array_count() { var json = $$"""{"deviceHostAddress":"{{Host}}","symbolPath":"MAIN.Speed","dataType":"DInt"}"""; var factory = new FakeTwinCATClientFactory { Customise = () => new FakeTwinCATClient { Values = { ["MAIN.Speed"] = 7 } }, }; var drv = new TwinCATDriver(new TwinCATDriverOptions { Devices = [new TwinCATDeviceOptions(Host)], Tags = [], Probe = new TwinCATProbeOptions { Enabled = false }, }, "twincat-scalar", factory); await drv.InitializeAsync("{}", CancellationToken.None); var r = await drv.ReadAsync([json], CancellationToken.None); r[0].StatusCode.ShouldBe(TwinCATStatusMapper.Good); r[0].Value.ShouldBe(7); factory.Clients[0].LastReadArrayCount.ShouldBeNull(); } // ---- (3) resolver: equipment-tag parser threads arrayLength ---- /// The equipment-tag parser threads arrayLength into the transient definition. [Fact] public void Parser_threads_arrayLength_into_the_definition() { var json = """{"deviceHostAddress":"ads://5.23.91.23.1.1:851","symbolPath":"MAIN.Speeds","dataType":"DInt","isArray":true,"arrayLength":12}"""; TwinCATEquipmentTagParser.TryParse(json, out var def).ShouldBeTrue(); def!.ArrayLength.ShouldBe(12); } /// A blob without isArray (or with isArray=false) leaves ArrayLength null. [Fact] public void Parser_leaves_ArrayLength_null_for_a_scalar_blob() { TwinCATEquipmentTagParser.TryParse( """{"symbolPath":"MAIN.X","dataType":"DInt"}""", out var def).ShouldBeTrue(); def!.ArrayLength.ShouldBeNull(); } /// arrayLength is ignored when isArray is absent/false (no orphan length). [Fact] public void Parser_ignores_arrayLength_when_isArray_is_false() { TwinCATEquipmentTagParser.TryParse( """{"symbolPath":"MAIN.X","dataType":"DInt","isArray":false,"arrayLength":9}""", out var def).ShouldBeTrue(); def!.ArrayLength.ShouldBeNull(); } // ---- helpers ---- 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) { } } } }