feat(abcip): 1-D array read via libplctag + IsArray discovery
This commit is contained in:
@@ -0,0 +1,229 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Phase 4c (Task 6) — 1-D array support for AbCip. Covers: discovery flips
|
||||
/// <see cref="DriverAttributeInfo.IsArray"/> / <see cref="DriverAttributeInfo.ArrayDim"/>
|
||||
/// for an array atomic tag + an array UDT member; the read path returns a typed CLR array
|
||||
/// boxed as <see cref="object"/>; and the equipment-tag resolver threads
|
||||
/// <c>arrayLength</c> from the TagConfig into the transient definition's element count so
|
||||
/// an <c>isArray</c> equipment tag reads the whole array.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AbCipArrayTests
|
||||
{
|
||||
private static (AbCipDriver drv, FakeAbCipTagFactory factory) NewDriver(params AbCipTagDefinition[] tags)
|
||||
{
|
||||
var factory = new FakeAbCipTagFactory();
|
||||
var opts = new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
Tags = tags,
|
||||
};
|
||||
var drv = new AbCipDriver(opts, "drv-array", factory);
|
||||
return (drv, factory);
|
||||
}
|
||||
|
||||
// ---- Discovery: IsArray / ArrayDim flips ----
|
||||
|
||||
/// <summary>An atomic pre-declared tag with ElementCount > 1 discovers as a 1-D array.</summary>
|
||||
[Fact]
|
||||
public async Task PreDeclared_array_tag_discovers_as_IsArray_with_ArrayDim()
|
||||
{
|
||||
var builder = new RecordingBuilder();
|
||||
var (drv, _) = NewDriver(
|
||||
new AbCipTagDefinition("Recipe", "ab://10.0.0.5/1,0", "Recipe", AbCipDataType.DInt, ElementCount: 10),
|
||||
new AbCipTagDefinition("Single", "ab://10.0.0.5/1,0", "Single", AbCipDataType.DInt));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await drv.DiscoverAsync(builder, CancellationToken.None);
|
||||
|
||||
var arr = builder.Variables.Single(v => v.BrowseName == "Recipe").Info;
|
||||
arr.IsArray.ShouldBeTrue();
|
||||
arr.ArrayDim.ShouldBe(10u);
|
||||
|
||||
var scalar = builder.Variables.Single(v => v.BrowseName == "Single").Info;
|
||||
scalar.IsArray.ShouldBeFalse();
|
||||
scalar.ArrayDim.ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>A UDT member with ElementCount > 1 discovers as a 1-D array variable.</summary>
|
||||
[Fact]
|
||||
public async Task Udt_array_member_discovers_as_IsArray_with_ArrayDim()
|
||||
{
|
||||
var builder = new RecordingBuilder();
|
||||
var (drv, _) = NewDriver(
|
||||
new AbCipTagDefinition("Motor", "ab://10.0.0.5/1,0", "Motor", AbCipDataType.Structure,
|
||||
Members:
|
||||
[
|
||||
new AbCipStructureMember("Setpoints", AbCipDataType.Real, ElementCount: 4),
|
||||
new AbCipStructureMember("Speed", AbCipDataType.DInt),
|
||||
]));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await drv.DiscoverAsync(builder, CancellationToken.None);
|
||||
|
||||
var member = builder.Variables.Single(v => v.BrowseName == "Setpoints").Info;
|
||||
member.IsArray.ShouldBeTrue();
|
||||
member.ArrayDim.ShouldBe(4u);
|
||||
|
||||
var scalarMember = builder.Variables.Single(v => v.BrowseName == "Speed").Info;
|
||||
scalarMember.IsArray.ShouldBeFalse();
|
||||
scalarMember.ArrayDim.ShouldBeNull();
|
||||
}
|
||||
|
||||
// ---- Read: typed CLR array ----
|
||||
|
||||
/// <summary>An array DInt tag reads as a boxed int[] of the configured element count.</summary>
|
||||
[Fact]
|
||||
public async Task Array_DInt_read_returns_typed_int_array()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new AbCipTagDefinition("Recipe", "ab://10.0.0.5/1,0", "Recipe", AbCipDataType.DInt, ElementCount: 4));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = p => new ArrayFakeAbCipTag(p, new int[] { 11, 22, 33, 44 });
|
||||
|
||||
var snapshots = await drv.ReadAsync(["Recipe"], CancellationToken.None);
|
||||
|
||||
snapshots.Single().StatusCode.ShouldBe(AbCipStatusMapper.Good);
|
||||
var value = snapshots.Single().Value.ShouldBeOfType<int[]>();
|
||||
value.ShouldBe([11, 22, 33, 44]);
|
||||
// libplctag element count must be threaded to the runtime params.
|
||||
factory.Tags["Recipe"].CreationParams.ElementCount.ShouldBe(4);
|
||||
}
|
||||
|
||||
/// <summary>An array Real tag reads as a boxed float[].</summary>
|
||||
[Fact]
|
||||
public async Task Array_Real_read_returns_typed_float_array()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new AbCipTagDefinition("Floats", "ab://10.0.0.5/1,0", "Floats", AbCipDataType.Real, ElementCount: 3));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = p => new ArrayFakeAbCipTag(p, new float[] { 1.5f, 2.5f, 3.5f });
|
||||
|
||||
var snapshots = await drv.ReadAsync(["Floats"], CancellationToken.None);
|
||||
|
||||
var value = snapshots.Single().Value.ShouldBeOfType<float[]>();
|
||||
value.ShouldBe([1.5f, 2.5f, 3.5f]);
|
||||
}
|
||||
|
||||
/// <summary>An array Bool tag reads as a boxed bool[].</summary>
|
||||
[Fact]
|
||||
public async Task Array_Bool_read_returns_typed_bool_array()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new AbCipTagDefinition("Flags", "ab://10.0.0.5/1,0", "Flags", AbCipDataType.Bool, ElementCount: 3));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = p => new ArrayFakeAbCipTag(p, new bool[] { true, false, true });
|
||||
|
||||
var snapshots = await drv.ReadAsync(["Flags"], CancellationToken.None);
|
||||
|
||||
var value = snapshots.Single().Value.ShouldBeOfType<bool[]>();
|
||||
value.ShouldBe([true, false, true]);
|
||||
}
|
||||
|
||||
/// <summary>An array String tag reads as a boxed string[].</summary>
|
||||
[Fact]
|
||||
public async Task Array_String_read_returns_typed_string_array()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new AbCipTagDefinition("Names", "ab://10.0.0.5/1,0", "Names", AbCipDataType.String, ElementCount: 2));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = p => new ArrayFakeAbCipTag(p, new string[] { "a", "b" });
|
||||
|
||||
var snapshots = await drv.ReadAsync(["Names"], CancellationToken.None);
|
||||
|
||||
var value = snapshots.Single().Value.ShouldBeOfType<string[]>();
|
||||
value.ShouldBe(["a", "b"]);
|
||||
}
|
||||
|
||||
/// <summary>A scalar tag (ElementCount 1) is unaffected — still a boxed scalar, not an array.</summary>
|
||||
[Fact]
|
||||
public async Task Scalar_read_path_unchanged_for_element_count_one()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new AbCipTagDefinition("Speed", "ab://10.0.0.5/1,0", "Speed", AbCipDataType.DInt));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = p => new FakeAbCipTag(p) { Value = 4200 };
|
||||
|
||||
var snapshots = await drv.ReadAsync(["Speed"], CancellationToken.None);
|
||||
|
||||
snapshots.Single().Value.ShouldBe(4200);
|
||||
snapshots.Single().Value.ShouldNotBeOfType<int[]>();
|
||||
}
|
||||
|
||||
// ---- Resolver: arrayLength threading ----
|
||||
|
||||
/// <summary>The equipment-tag resolver threads arrayLength into the def's ElementCount.</summary>
|
||||
[Fact]
|
||||
public async Task Equipment_ref_with_arrayLength_reads_as_a_typed_array()
|
||||
{
|
||||
var json = """{"deviceHostAddress":"ab://10.0.0.5/1,0","tagPath":"Recipe","dataType":"DInt","isArray":true,"arrayLength":4}""";
|
||||
var factory = new FakeAbCipTagFactory();
|
||||
var opts = new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
Tags = [],
|
||||
};
|
||||
var drv = new AbCipDriver(opts, "abcip-eq-array", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = p => new ArrayFakeAbCipTag(p, new int[] { 7, 8, 9, 10 });
|
||||
|
||||
var snapshots = await drv.ReadAsync([json], CancellationToken.None);
|
||||
|
||||
snapshots.Single().StatusCode.ShouldBe(AbCipStatusMapper.Good);
|
||||
var value = snapshots.Single().Value.ShouldBeOfType<int[]>();
|
||||
value.ShouldBe([7, 8, 9, 10]);
|
||||
factory.Tags["Recipe"].CreationParams.ElementCount.ShouldBe(4);
|
||||
}
|
||||
|
||||
/// <summary>The parser threads arrayLength into the transient definition's ElementCount.</summary>
|
||||
[Fact]
|
||||
public void Parser_threads_arrayLength_into_ElementCount()
|
||||
{
|
||||
var json = """{"tagPath":"Recipe","dataType":"DInt","isArray":true,"arrayLength":8}""";
|
||||
AbCipEquipmentTagParser.TryParse(json, out var def).ShouldBeTrue();
|
||||
def!.ElementCount.ShouldBe(8);
|
||||
}
|
||||
|
||||
/// <summary>A non-array equipment ref defaults ElementCount to 1 (scalar).</summary>
|
||||
[Fact]
|
||||
public void Parser_defaults_ElementCount_to_one_when_not_an_array()
|
||||
{
|
||||
var json = """{"tagPath":"Recipe","dataType":"DInt"}""";
|
||||
AbCipEquipmentTagParser.TryParse(json, out var def).ShouldBeTrue();
|
||||
def!.ElementCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
/// <summary>Minimal <see cref="IAddressSpaceBuilder"/> recorder for the discovery assertions.</summary>
|
||||
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) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -88,6 +88,20 @@ internal class FakeAbCipTag : IAbCipTagRuntime
|
||||
return offset == 0 ? Value : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Phase 4c array-read seam. Returns <see cref="ArrayValue"/> (the boxed element-typed
|
||||
/// CLR array a test seeds) regardless of <paramref name="type"/> / <paramref name="count"/>,
|
||||
/// so a test asserts that the driver routes a 1-D array tag through this method instead of
|
||||
/// the scalar <see cref="DecodeValue"/>. <see cref="ArrayFakeAbCipTag"/> is the convenient
|
||||
/// constructor-seeded variant.
|
||||
/// </summary>
|
||||
/// <param name="type">The element data type being decoded.</param>
|
||||
/// <param name="count">The number of elements to decode.</param>
|
||||
public virtual object? DecodeArray(AbCipDataType type, int count) => ArrayValue;
|
||||
|
||||
/// <summary>Gets or sets the boxed array value returned from <see cref="DecodeArray"/>.</summary>
|
||||
public object? ArrayValue { get; set; }
|
||||
|
||||
/// <summary>Encodes a value into the mock tag storage.</summary>
|
||||
/// <param name="type">The data type being encoded.</param>
|
||||
/// <param name="bitIndex">The optional bit index for bit operations.</param>
|
||||
@@ -98,6 +112,20 @@ internal class FakeAbCipTag : IAbCipTagRuntime
|
||||
public virtual void Dispose() => Disposed = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A <see cref="FakeAbCipTag"/> pre-seeded with a boxed element-typed CLR array, returned
|
||||
/// from <see cref="DecodeArray"/>. Mirrors a real libplctag array read where
|
||||
/// <c>elem_count</c> elements come back in one transaction.
|
||||
/// </summary>
|
||||
internal sealed class ArrayFakeAbCipTag : FakeAbCipTag
|
||||
{
|
||||
/// <summary>Initializes the fake with a boxed array value.</summary>
|
||||
/// <param name="createParams">The tag creation parameters.</param>
|
||||
/// <param name="array">The boxed element-typed CLR array the read returns.</param>
|
||||
public ArrayFakeAbCipTag(AbCipTagCreateParams createParams, object array) : base(createParams)
|
||||
=> ArrayValue = array;
|
||||
}
|
||||
|
||||
/// <summary>Test factory that produces <see cref="FakeAbCipTag"/>s and indexes them for assertion.</summary>
|
||||
internal sealed class FakeAbCipTagFactory : IAbCipTagFactory
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user