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; [Trait("Category", "Unit")] public sealed class TwinCATArrayReadTests { private const string Host = "ads://5.23.91.23.1.1:851"; private static (TwinCATDriver drv, FakeTwinCATClientFactory factory) NewDriver(params TwinCATTagDefinition[] tags) { var factory = new FakeTwinCATClientFactory(); var drv = new TwinCATDriver(new TwinCATDriverOptions { Devices = [new TwinCATDeviceOptions(Host)], Tags = tags, Probe = new TwinCATProbeOptions { Enabled = false }, }, "drv-1", factory); return (drv, factory); } // ---- Array shape mapping ---- [Fact] public void ResolveArrayShape_returns_scalar_for_null_dimensions() { var (isArray, dim) = TwinCATDriver.ResolveArrayShape(null); isArray.ShouldBeFalse(); dim.ShouldBeNull(); } [Fact] public void ResolveArrayShape_returns_scalar_for_empty_dimensions() { var (isArray, dim) = TwinCATDriver.ResolveArrayShape([]); isArray.ShouldBeFalse(); dim.ShouldBeNull(); } [Fact] public void ResolveArrayShape_returns_length_for_single_dim() { var (isArray, dim) = TwinCATDriver.ResolveArrayShape([10]); isArray.ShouldBeTrue(); dim.ShouldBe(10u); } [Fact] public void ResolveArrayShape_flattens_multi_dim_to_product() { var (isArray, dim) = TwinCATDriver.ResolveArrayShape([3, 4]); isArray.ShouldBeTrue(); dim.ShouldBe(12u); } [Fact] public void ResolveArrayShape_rejects_non_positive_dim_as_scalar() { // Defensive — bad config flattens to scalar so the read path still runs without // dragging a Make-Array-Type call into an empty allocation. var (isArray, dim) = TwinCATDriver.ResolveArrayShape([3, 0, 2]); isArray.ShouldBeFalse(); dim.ShouldBeNull(); } // ---- Discovery surfaces IsArray + ArrayDim ---- [Fact] public async Task DiscoverAsync_emits_IsArray_for_array_tags() { var builder = new RecordingBuilder(); var drv = new TwinCATDriver(new TwinCATDriverOptions { Devices = [new TwinCATDeviceOptions(Host)], Tags = [ new TwinCATTagDefinition("Vec", Host, "MAIN.Vec", TwinCATDataType.DInt, ArrayDimensions: [10]), new TwinCATTagDefinition("Mat", Host, "MAIN.Mat", TwinCATDataType.Real, ArrayDimensions: [3, 4]), new TwinCATTagDefinition("Scalar", Host, "MAIN.S", TwinCATDataType.DInt), ], Probe = new TwinCATProbeOptions { Enabled = false }, }, "drv-1"); await drv.InitializeAsync("{}", CancellationToken.None); await drv.DiscoverAsync(builder, CancellationToken.None); var vec = builder.Variables.Single(v => v.BrowseName == "Vec").Info; vec.IsArray.ShouldBeTrue(); vec.ArrayDim.ShouldBe(10u); var mat = builder.Variables.Single(v => v.BrowseName == "Mat").Info; mat.IsArray.ShouldBeTrue(); mat.ArrayDim.ShouldBe(12u); // 3 * 4 flattened var scalar = builder.Variables.Single(v => v.BrowseName == "Scalar").Info; scalar.IsArray.ShouldBeFalse(); scalar.ArrayDim.ShouldBeNull(); } // ---- Whole-array read fans through to the client with ArrayDimensions ---- [Fact] public async Task Whole_array_read_passes_dimensions_to_client_and_returns_array() { var (drv, factory) = NewDriver( new TwinCATTagDefinition("Vec", Host, "MAIN.Vec", TwinCATDataType.DInt, ArrayDimensions: [4])); await drv.InitializeAsync("{}", CancellationToken.None); factory.Customise = () => new FakeTwinCATClient { Values = { ["MAIN.Vec"] = new[] { 1, 2, 3, 4 } } }; var snapshots = await drv.ReadAsync(["Vec"], CancellationToken.None); snapshots.Single().StatusCode.ShouldBe(TwinCATStatusMapper.Good); snapshots.Single().Value.ShouldBe(new[] { 1, 2, 3, 4 }); var read = factory.Clients[0].ReadLog.Single(); read.symbol.ShouldBe("MAIN.Vec"); read.arrayDimensions.ShouldBe(new[] { 4 }); } [Fact] public async Task Multi_dim_array_read_flattens_to_product_on_wire() { var (drv, factory) = NewDriver( new TwinCATTagDefinition("Mat", Host, "MAIN.Mat", TwinCATDataType.Real, ArrayDimensions: [2, 3])); await drv.InitializeAsync("{}", CancellationToken.None); var flat = new[] { 1.0f, 2.0f, 3.0f, 4.0f, 5.0f, 6.0f }; factory.Customise = () => new FakeTwinCATClient { Values = { ["MAIN.Mat"] = flat } }; var snapshots = await drv.ReadAsync(["Mat"], CancellationToken.None); snapshots.Single().StatusCode.ShouldBe(TwinCATStatusMapper.Good); snapshots.Single().Value.ShouldBe(flat); var read = factory.Clients[0].ReadLog.Single(); read.arrayDimensions.ShouldBe(new[] { 2, 3 }); } // ---- Whole-array writes are out of scope (read-only PR) ---- [Fact] public async Task Whole_array_write_returns_BadNotSupported_via_AdsTwinCATClient() { // Use the production AdsTwinCATClient gate directly — driver-level writes against // the fake client would succeed because the fake doesn't model the real ADS surface. var client = new AdsTwinCATClient(); var status = await client.WriteValueAsync( "MAIN.Vec", TwinCATDataType.DInt, bitIndex: null, arrayDimensions: [4], value: new[] { 1, 2, 3, 4 }, CancellationToken.None); status.ShouldBe(TwinCATStatusMapper.BadNotSupported); client.Dispose(); } 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) { } } } }