feat(ablegacy): PCCC multi-element file array read + IsArray discovery
This commit is contained in:
@@ -0,0 +1,286 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Phase 4c #137 array support for the AbLegacy (PCCC/DF1) driver. A PCCC data file
|
||||
/// (e.g. <c>N7</c>) is inherently an array of up to 256 words; an equipment tag carrying an
|
||||
/// <c>arrayLength</c> reads <c>count</c> consecutive file elements from the base address
|
||||
/// (<c>N7:0</c> reading <c>count</c> words) via libplctag's native element-count and surfaces
|
||||
/// them as a typed CLR array. Proven here against <see cref="FakeAbLegacyTag"/>; there is no
|
||||
/// live AbLegacy fixture on this machine.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AbLegacyArrayTests
|
||||
{
|
||||
private static (AbLegacyDriver drv, FakeAbLegacyTagFactory factory) NewDriver(params AbLegacyTagDefinition[] tags)
|
||||
{
|
||||
var factory = new FakeAbLegacyTagFactory();
|
||||
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||
{
|
||||
Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
Tags = tags,
|
||||
Probe = new AbLegacyProbeOptions { Enabled = false },
|
||||
}, "ablegacy-array", factory);
|
||||
return (drv, factory);
|
||||
}
|
||||
|
||||
// ---- Read: typed CLR arrays ----
|
||||
|
||||
/// <summary>An N-file (16-bit integer) array tag reads a <c>short[]</c>.</summary>
|
||||
[Fact]
|
||||
public async Task Read_N_file_array_returns_short_array()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new AbLegacyTagDefinition("Levels", "ab://10.0.0.5/1,0", "N7:0", AbLegacyDataType.Int, ArrayLength: 5));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = p => new FakeAbLegacyTag(p) { ArrayValue = new short[] { 100, 101, 102, 103, 104 } };
|
||||
|
||||
var snapshots = await drv.ReadAsync(["Levels"], CancellationToken.None);
|
||||
|
||||
snapshots.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
|
||||
var arr = snapshots.Single().Value.ShouldBeOfType<short[]>();
|
||||
arr.ShouldBe(new short[] { 100, 101, 102, 103, 104 });
|
||||
}
|
||||
|
||||
/// <summary>An L-file (32-bit long) array tag reads an <c>int[]</c>.</summary>
|
||||
[Fact]
|
||||
public async Task Read_L_file_array_returns_int_array()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new AbLegacyTagDefinition("Counts", "ab://10.0.0.5/1,0", "L9:0", AbLegacyDataType.Long, ArrayLength: 3));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = p => new FakeAbLegacyTag(p) { ArrayValue = new[] { 70000, 80000, 90000 } };
|
||||
|
||||
var snapshots = await drv.ReadAsync(["Counts"], CancellationToken.None);
|
||||
|
||||
var arr = snapshots.Single().Value.ShouldBeOfType<int[]>();
|
||||
arr.ShouldBe(new[] { 70000, 80000, 90000 });
|
||||
}
|
||||
|
||||
/// <summary>An F-file (32-bit float) array tag reads a <c>float[]</c>.</summary>
|
||||
[Fact]
|
||||
public async Task Read_F_file_array_returns_float_array()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new AbLegacyTagDefinition("Temps", "ab://10.0.0.5/1,0", "F8:0", AbLegacyDataType.Float, ArrayLength: 4));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = p => new FakeAbLegacyTag(p) { ArrayValue = new[] { 1.5f, 2.5f, 3.5f, 4.5f } };
|
||||
|
||||
var snapshots = await drv.ReadAsync(["Temps"], CancellationToken.None);
|
||||
|
||||
var arr = snapshots.Single().Value.ShouldBeOfType<float[]>();
|
||||
arr.ShouldBe(new[] { 1.5f, 2.5f, 3.5f, 4.5f });
|
||||
}
|
||||
|
||||
/// <summary>A B-file (bit) array tag reads a <c>bool[]</c>.</summary>
|
||||
[Fact]
|
||||
public async Task Read_B_file_array_returns_bool_array()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new AbLegacyTagDefinition("Flags", "ab://10.0.0.5/1,0", "B3:0", AbLegacyDataType.Bit, ArrayLength: 6));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = p => new FakeAbLegacyTag(p) { ArrayValue = new[] { true, false, true, false, true, false } };
|
||||
|
||||
var snapshots = await drv.ReadAsync(["Flags"], CancellationToken.None);
|
||||
|
||||
var arr = snapshots.Single().Value.ShouldBeOfType<bool[]>();
|
||||
arr.ShouldBe(new[] { true, false, true, false, true, false });
|
||||
}
|
||||
|
||||
/// <summary>The element count flows into the create params so libplctag reads N words.</summary>
|
||||
[Fact]
|
||||
public async Task Array_tag_threads_element_count_into_create_params()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new AbLegacyTagDefinition("Levels", "ab://10.0.0.5/1,0", "N7:0", AbLegacyDataType.Int, ArrayLength: 5));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = p => new FakeAbLegacyTag(p) { ArrayValue = new short[] { 1, 2, 3, 4, 5 } };
|
||||
|
||||
await drv.ReadAsync(["Levels"], CancellationToken.None);
|
||||
|
||||
factory.Tags["N7:0"].CreationParams.ElementCount.ShouldBe(5);
|
||||
}
|
||||
|
||||
/// <summary>A read failure on an array tag surfaces the mapped Bad status, not a partial array.</summary>
|
||||
[Fact]
|
||||
public async Task Array_read_failure_surfaces_bad_status()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new AbLegacyTagDefinition("Levels", "ab://10.0.0.5/1,0", "N7:0", AbLegacyDataType.Int, ArrayLength: 5));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = p => new FakeAbLegacyTag(p) { Status = (int)libplctag.Status.ErrorTimeout };
|
||||
|
||||
var snapshots = await drv.ReadAsync(["Levels"], CancellationToken.None);
|
||||
|
||||
snapshots.Single().Value.ShouldBeNull();
|
||||
snapshots.Single().StatusCode.ShouldNotBe(AbLegacyStatusMapper.Good);
|
||||
}
|
||||
|
||||
// ---- Scalar regression ----
|
||||
|
||||
/// <summary>A scalar tag (no ArrayLength) still reads a single value, not an array.</summary>
|
||||
[Fact]
|
||||
public async Task Scalar_tag_still_reads_single_value()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new AbLegacyTagDefinition("Counter", "ab://10.0.0.5/1,0", "N7:0", AbLegacyDataType.Int));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = p => new FakeAbLegacyTag(p) { Value = 42 };
|
||||
|
||||
var snapshots = await drv.ReadAsync(["Counter"], CancellationToken.None);
|
||||
|
||||
snapshots.Single().Value.ShouldBe(42);
|
||||
snapshots.Single().Value.ShouldNotBeOfType<short[]>();
|
||||
factory.Tags["N7:0"].CreationParams.ElementCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>An ArrayLength of 1 behaves like a scalar (single value, ElementCount 1).</summary>
|
||||
[Fact]
|
||||
public async Task ArrayLength_of_one_behaves_like_scalar()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new AbLegacyTagDefinition("Counter", "ab://10.0.0.5/1,0", "N7:0", AbLegacyDataType.Int, ArrayLength: 1));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = p => new FakeAbLegacyTag(p) { Value = 7 };
|
||||
|
||||
var snapshots = await drv.ReadAsync(["Counter"], CancellationToken.None);
|
||||
|
||||
snapshots.Single().Value.ShouldBe(7);
|
||||
factory.Tags["N7:0"].CreationParams.ElementCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
// ---- Discovery ----
|
||||
|
||||
/// <summary>Discovery flips IsArray and surfaces ArrayDim for an array file tag.</summary>
|
||||
[Fact]
|
||||
public async Task Discovery_surfaces_IsArray_and_ArrayDim_for_array_tag()
|
||||
{
|
||||
var captured = new List<DriverAttributeInfo>();
|
||||
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||
{
|
||||
Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
Tags = [new AbLegacyTagDefinition("Vector", "ab://10.0.0.5/1,0", "N7:0", AbLegacyDataType.Int, ArrayLength: 8)],
|
||||
Probe = new AbLegacyProbeOptions { Enabled = false },
|
||||
}, "ablegacy-array", new FakeAbLegacyTagFactory());
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await drv.DiscoverAsync(new RecordingBuilder(captured), CancellationToken.None);
|
||||
|
||||
captured.Count.ShouldBe(1);
|
||||
captured[0].IsArray.ShouldBeTrue();
|
||||
captured[0].ArrayDim.ShouldBe(8u);
|
||||
}
|
||||
|
||||
/// <summary>Scalar tag discovery keeps IsArray false / ArrayDim null (regression guard).</summary>
|
||||
[Fact]
|
||||
public async Task Discovery_keeps_scalar_tag_non_array()
|
||||
{
|
||||
var captured = new List<DriverAttributeInfo>();
|
||||
var (drv, _) = NewDriver(
|
||||
new AbLegacyTagDefinition("Single", "ab://10.0.0.5/1,0", "N7:0", AbLegacyDataType.Int));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await drv.DiscoverAsync(new RecordingBuilder(captured), CancellationToken.None);
|
||||
|
||||
captured[0].IsArray.ShouldBeFalse();
|
||||
captured[0].ArrayDim.ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>Discovery caps a 1024-element tag's ArrayDim at the PCCC 256-word file maximum.</summary>
|
||||
[Fact]
|
||||
public async Task Discovery_caps_array_dim_at_256()
|
||||
{
|
||||
var captured = new List<DriverAttributeInfo>();
|
||||
var (drv, _) = NewDriver(
|
||||
new AbLegacyTagDefinition("Big", "ab://10.0.0.5/1,0", "N7:0", AbLegacyDataType.Int, ArrayLength: 1024));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await drv.DiscoverAsync(new RecordingBuilder(captured), CancellationToken.None);
|
||||
|
||||
captured[0].IsArray.ShouldBeTrue();
|
||||
captured[0].ArrayDim.ShouldBe(256u);
|
||||
}
|
||||
|
||||
// ---- Equipment-tag resolver threads arrayLength ----
|
||||
|
||||
/// <summary>The equipment-tag parser threads <c>arrayLength</c> into the transient definition.</summary>
|
||||
[Fact]
|
||||
public void EquipmentTagParser_threads_arrayLength()
|
||||
{
|
||||
var json = """{"deviceHostAddress":"ab://10.0.0.5/1,0","address":"N7:0","dataType":"Int","isArray":true,"arrayLength":10}""";
|
||||
AbLegacyEquipmentTagParser.TryParse(json, out var def).ShouldBeTrue();
|
||||
def!.ArrayLength.ShouldBe(10);
|
||||
}
|
||||
|
||||
/// <summary>The parser caps <c>arrayLength</c> at the PCCC 256-word file maximum.</summary>
|
||||
[Fact]
|
||||
public void EquipmentTagParser_caps_arrayLength_at_256()
|
||||
{
|
||||
var json = """{"deviceHostAddress":"ab://10.0.0.5/1,0","address":"N7:0","dataType":"Int","arrayLength":99999}""";
|
||||
AbLegacyEquipmentTagParser.TryParse(json, out var def).ShouldBeTrue();
|
||||
def!.ArrayLength.ShouldBe(256);
|
||||
}
|
||||
|
||||
/// <summary>A scalar equipment tag (no arrayLength) leaves ArrayLength null.</summary>
|
||||
[Fact]
|
||||
public void EquipmentTagParser_scalar_leaves_arrayLength_null()
|
||||
{
|
||||
var json = """{"deviceHostAddress":"ab://10.0.0.5/1,0","address":"N7:0","dataType":"Int"}""";
|
||||
AbLegacyEquipmentTagParser.TryParse(json, out var def).ShouldBeTrue();
|
||||
def!.ArrayLength.ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// End-to-end: an AbLegacy driver with NO authored tags reads an equipment-tag ref whose
|
||||
/// TagConfig carries <c>arrayLength</c> — the resolver threads the count and the read
|
||||
/// surfaces a typed array, capping the libplctag element count at 256.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Driver_reads_equipment_array_ref_as_typed_array()
|
||||
{
|
||||
var json = """{"deviceHostAddress":"ab://10.0.0.5/1,0","address":"N7:0","dataType":"Int","arrayLength":3}""";
|
||||
var factory = new FakeAbLegacyTagFactory { Customise = p => new FakeAbLegacyTag(p) { ArrayValue = new short[] { 11, 22, 33 } } };
|
||||
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||
{
|
||||
Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
Tags = [],
|
||||
Probe = new AbLegacyProbeOptions { Enabled = false },
|
||||
}, "ablegacy-eq-array", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var r = await drv.ReadAsync([json], CancellationToken.None);
|
||||
|
||||
r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
|
||||
var arr = r[0].Value.ShouldBeOfType<short[]>();
|
||||
arr.ShouldBe(new short[] { 11, 22, 33 });
|
||||
factory.Tags["N7:0"].CreationParams.ElementCount.ShouldBe(3);
|
||||
}
|
||||
|
||||
private sealed class RecordingBuilder(List<DriverAttributeInfo> captured) : IAddressSpaceBuilder
|
||||
{
|
||||
public IAddressSpaceBuilder Folder(string browseName, string displayName) => this;
|
||||
|
||||
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo info)
|
||||
{
|
||||
captured.Add(info);
|
||||
return new Handle(info.FullName);
|
||||
}
|
||||
|
||||
public void AddProperty(string name, DriverDataType dataType, object? value) { }
|
||||
|
||||
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) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,13 @@ internal class FakeAbLegacyTag : IAbLegacyTagRuntime
|
||||
/// <summary>Gets or sets the tag value.</summary>
|
||||
public object? Value { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the typed CLR array returned by <see cref="DecodeArray"/>. Set this to a
|
||||
/// <c>short[]</c> / <c>int[]</c> / <c>float[]</c> / <c>bool[]</c> to simulate a libplctag
|
||||
/// multi-element PCCC file read; the driver boxes it straight through.
|
||||
/// </summary>
|
||||
public object? ArrayValue { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the tag status code.</summary>
|
||||
public int Status { get; set; }
|
||||
|
||||
@@ -81,6 +88,12 @@ internal class FakeAbLegacyTag : IAbLegacyTagRuntime
|
||||
/// <returns>The decoded value.</returns>
|
||||
public virtual object? DecodeValue(AbLegacyDataType type, int? bitIndex) => Value;
|
||||
|
||||
/// <summary>Decodes <paramref name="count"/> elements as a typed CLR array.</summary>
|
||||
/// <param name="type">The AbLegacy data type.</param>
|
||||
/// <param name="count">The element count.</param>
|
||||
/// <returns>The pre-set <see cref="ArrayValue"/>.</returns>
|
||||
public virtual object? DecodeArray(AbLegacyDataType type, int count) => ArrayValue;
|
||||
|
||||
/// <summary>Encodes the tag value based on the specified data type and bit index.</summary>
|
||||
/// <param name="type">The AbLegacy data type.</param>
|
||||
/// <param name="bitIndex">The bit index if applicable.</param>
|
||||
|
||||
Reference in New Issue
Block a user