feat(twincat): 1-D array symbol read via ADS + IsArray discovery
This commit is contained in:
@@ -27,6 +27,10 @@ internal class FakeTwinCATClient : ITwinCATClient
|
||||
public Dictionary<string, uint> ReadStatuses { get; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
/// <summary>Gets the write statuses by symbol path.</summary>
|
||||
public Dictionary<string, uint> WriteStatuses { get; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
/// <summary>Records the <c>arrayCount</c> argument of the most recent <see cref="ReadValueAsync"/>
|
||||
/// call (null = scalar read) so Phase 4c array-read tests can assert the driver requested
|
||||
/// the array with the authored length.</summary>
|
||||
public int? LastReadArrayCount { get; private set; }
|
||||
/// <summary>Gets the log of all write operations.</summary>
|
||||
public List<(string symbol, TwinCATDataType type, int? bit, object? value)> WriteLog { get; } = new();
|
||||
/// <summary>Gets or sets the result returned by ProbeAsync.</summary>
|
||||
@@ -55,12 +59,14 @@ internal class FakeTwinCATClient : ITwinCATClient
|
||||
/// <param name="symbolPath">The path to the symbol to read.</param>
|
||||
/// <param name="type">The data type of the symbol.</param>
|
||||
/// <param name="bitIndex">The optional bit index for bit-level reads.</param>
|
||||
/// <param name="arrayCount">The optional 1-D array element count (null = scalar read).</param>
|
||||
/// <param name="ct">The cancellation token.</param>
|
||||
/// <returns>A task that returns the simulated value and status.</returns>
|
||||
public virtual Task<(object? value, uint status)> ReadValueAsync(
|
||||
string symbolPath, TwinCATDataType type, int? bitIndex, CancellationToken ct)
|
||||
string symbolPath, TwinCATDataType type, int? bitIndex, int? arrayCount, CancellationToken ct)
|
||||
{
|
||||
if (ThrowOnRead) throw Exception ?? new InvalidOperationException();
|
||||
LastReadArrayCount = arrayCount;
|
||||
var status = ReadStatuses.TryGetValue(symbolPath, out var s) ? s : TwinCATStatusMapper.Good;
|
||||
var value = Values.TryGetValue(symbolPath, out var v) ? v : null;
|
||||
return Task.FromResult((value, status));
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// <c>arrayLength</c> from the TagConfig JSON.
|
||||
/// </summary>
|
||||
[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 ----
|
||||
|
||||
/// <summary>A pre-declared tag carrying an ArrayLength surfaces as a 1-D array node.</summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>A scalar pre-declared tag still reports IsArray=false / ArrayDim=null.</summary>
|
||||
[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 ----
|
||||
|
||||
/// <summary>A discovered (browsed) 1-D array symbol surfaces as a 1-D array node.</summary>
|
||||
[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 ----
|
||||
|
||||
/// <summary>An equipment array tag reads a typed CLR array (boxed as object) through the driver.</summary>
|
||||
[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<int[]>().ShouldBe(expected);
|
||||
// The driver must request the array read with the authored length.
|
||||
factory.Clients[0].LastReadArrayCount.ShouldBe(4);
|
||||
}
|
||||
|
||||
/// <summary>A scalar equipment tag reads with a null array count (scalar path unchanged).</summary>
|
||||
[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 ----
|
||||
|
||||
/// <summary>The equipment-tag parser threads <c>arrayLength</c> into the transient definition.</summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>A blob without isArray (or with isArray=false) leaves ArrayLength null.</summary>
|
||||
[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();
|
||||
}
|
||||
|
||||
/// <summary>arrayLength is ignored when isArray is absent/false (no orphan length).</summary>
|
||||
[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) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user