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)], RawTags = TwinCATRawTags.Entries( [ 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)], RawTags = TwinCATRawTags.Entries([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 ---- private const string ArrayRawPath = "equip/Speeds"; private const string ScalarRawPath = "equip/Speed"; /// An array raw tag reads a typed CLR array (boxed as object) through the driver. [Fact] public async Task Driver_reads_an_array_raw_tag_as_typed_clr_array() { var blob = """{"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)], RawTags = [new RawTagEntry(ArrayRawPath, blob, WriteIdempotent: false, DeviceName: Host)], Probe = new TwinCATProbeOptions { Enabled = false }, }, "twincat-arr", factory); await drv.InitializeAsync("{}", CancellationToken.None); var r = await drv.ReadAsync([ArrayRawPath], 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 raw tag reads with a null array count (scalar path unchanged). [Fact] public async Task Driver_reads_a_scalar_raw_tag_with_null_array_count() { var blob = """{"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)], RawTags = [new RawTagEntry(ScalarRawPath, blob, WriteIdempotent: false, DeviceName: Host)], Probe = new TwinCATProbeOptions { Enabled = false }, }, "twincat-scalar", factory); await drv.InitializeAsync("{}", CancellationToken.None); var r = await drv.ReadAsync([ScalarRawPath], CancellationToken.None); r[0].StatusCode.ShouldBe(TwinCATStatusMapper.Good); r[0].Value.ShouldBe(7); factory.Clients[0].LastReadArrayCount.ShouldBeNull(); } // ---- (3) mapper: FromTagConfig threads arrayLength ---- /// The tag-definition factory threads arrayLength into the definition. [Fact] public void FromTagConfig_threads_arrayLength_into_the_definition() { var blob = """{"symbolPath":"MAIN.Speeds","dataType":"DInt","isArray":true,"arrayLength":12}"""; TwinCATTagDefinitionFactory.FromTagConfig(blob, ArrayRawPath, out var def).ShouldBeTrue(); def!.ArrayLength.ShouldBe(12); } /// A blob without isArray (or with isArray=false) leaves ArrayLength null. [Fact] public void FromTagConfig_leaves_ArrayLength_null_for_a_scalar_blob() { TwinCATTagDefinitionFactory.FromTagConfig( """{"symbolPath":"MAIN.X","dataType":"DInt"}""", ScalarRawPath, out var def).ShouldBeTrue(); def!.ArrayLength.ShouldBeNull(); } /// arrayLength is ignored when isArray is absent/false (no orphan length). [Fact] public void FromTagConfig_ignores_arrayLength_when_isArray_is_false() { TwinCATTagDefinitionFactory.FromTagConfig( """{"symbolPath":"MAIN.X","dataType":"DInt","isArray":false,"arrayLength":9}""", ScalarRawPath, out var def).ShouldBeTrue(); def!.ArrayLength.ShouldBeNull(); } // ---- (4) Driver.TwinCAT-017 — array raw tag delivered via driver config JSON rawTags ---- /// /// An array raw tag in the driver config JSON rawTags array must bind + map to a /// with the correct , /// which in turn drives IsArray/ArrayDim at discovery and a native array read /// at runtime (Driver.TwinCAT-017). /// [Fact] public void Array_raw_tag_arrayLength_binds_from_driver_config_json() { var blob = """{"symbolPath":"MAIN.Speeds","dataType":"DInt","isArray":true,"arrayLength":8}"""; var json = System.Text.Json.JsonSerializer.Serialize(new { devices = new[] { new { hostAddress = Host } }, rawTags = new[] { new { rawPath = "Speeds", tagConfig = blob, writeIdempotent = false, deviceName = Host } }, }); var parsed = TwinCATDriverFactoryExtensions.ParseOptionsForTests(json, "drv-1"); var entry = parsed.RawTags.Single(); TwinCATTagDefinitionFactory.FromTagConfig(entry.TagConfig, entry.RawPath, out var def).ShouldBeTrue(); def!.ArrayLength.ShouldBe(8); } /// /// An array raw tag delivered via the driver config JSON must surface as IsArray=true / /// ArrayDim=8 in the discovery address space. /// [Fact] public async Task Array_raw_tag_from_json_reports_IsArray_in_discovery() { var blob = """{"symbolPath":"MAIN.Speeds","dataType":"DInt","isArray":true,"arrayLength":8}"""; var configJson = System.Text.Json.JsonSerializer.Serialize(new { devices = new[] { new { hostAddress = Host } }, rawTags = new[] { new { rawPath = "Speeds", tagConfig = blob, writeIdempotent = false, deviceName = Host } }, }); var builder = new RecordingBuilder(); var drv = new TwinCATDriver(new TwinCATDriverOptions(), "drv-1", new FakeTwinCATClientFactory()); await drv.InitializeAsync(configJson, 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); } // ---- 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) { } } } }