216 lines
8.8 KiB
C#
216 lines
8.8 KiB
C#
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) { }
|
|
}
|
|
}
|
|
}
|