Auto: twincat-1.4 — whole-array reads

Surface int[]? ArrayDimensions on TwinCATTagDefinition + thread it through
ITwinCATClient.ReadValueAsync / WriteValueAsync. When non-null + non-empty,
AdsTwinCATClient issues a single ADS read against the symbol with
clrType.MakeArrayType() and returns the flat 1-D CLR Array; for IEC TIME /
DATE / DT / TOD element types we project per-element to the native
TimeSpan / DateTime so consumers see consistent types regardless of rank.

DiscoverAsync surfaces IsArray=true + ArrayDim=product(dims) onto
DriverAttributeInfo via a new ResolveArrayShape helper. Multi-dim shapes
flatten to the product on the wire — DriverAttributeInfo.ArrayDim is
single-uint today and the OPC UA layer reflects rank via its own metadata.

Native ADS notification subscriptions skip whole-array tags so the OPC UA
layer falls through to a polled snapshot — the per-element AdsNotificationEx
callback shape doesn't fit a flat array. Whole-array WRITES are out of
scope for this PR — AdsTwinCATClient.WriteValueAsync returns
BadNotSupported when ArrayDimensions is set.

Tests: TwinCATArrayReadTests covers ResolveArrayShape (null / empty /
single-dim / multi-dim flatten / non-positive defensive), DiscoverAsync
emitting IsArray + ArrayDim for declared array tags, single-dim + multi-dim
fake-client read fan-out, and the BadNotSupported gate on whole-array
writes. Existing 137 unit tests still pass — total now 143.

Closes #308
This commit is contained in:
Joseph Doherty
2026-04-25 17:36:15 -04:00
parent e6a55add20
commit b699052324
6 changed files with 260 additions and 11 deletions

View File

@@ -0,0 +1,176 @@
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) { } }
}
}