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:
@@ -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) { } }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user