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;
///
/// Phase 4c #137 array support for the AbLegacy (PCCC/DF1) driver. A PCCC data file
/// (e.g. N7) is inherently an array of up to 256 words; an equipment tag carrying an
/// arrayLength reads count consecutive file elements from the base address
/// (N7:0 reading count words) via libplctag's native element-count and surfaces
/// them as a typed CLR array. Proven here against ; there is no
/// live AbLegacy fixture on this machine.
///
[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 ----
/// An N-file (16-bit integer) array tag reads a short[].
[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();
arr.ShouldBe(new short[] { 100, 101, 102, 103, 104 });
}
/// An L-file (32-bit long) array tag reads an int[].
[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();
arr.ShouldBe(new[] { 70000, 80000, 90000 });
}
/// An F-file (32-bit float) array tag reads a float[].
[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();
arr.ShouldBe(new[] { 1.5f, 2.5f, 3.5f, 4.5f });
}
/// A B-file (bit) array tag reads a bool[].
[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();
arr.ShouldBe(new[] { true, false, true, false, true, false });
}
/// The element count flows into the create params so libplctag reads N words.
[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);
}
/// A read failure on an array tag surfaces the mapped Bad status, not a partial array.
[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 ----
/// A scalar tag (no ArrayLength) still reads a single value, not an array.
[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();
factory.Tags["N7:0"].CreationParams.ElementCount.ShouldBe(1);
}
///
/// A 1-element array (ArrayLength: 1) reads as a 1-element typed array, NOT a scalar.
/// The foundation materialises a [1] OPC UA array node for isArray:true,
/// arrayLength:1, so the driver read must return a single-element array to match
/// (review I-3). ElementCount stays 1.
///
[Fact]
public async Task ArrayLength_of_one_reads_a_one_element_array()
{
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) { ArrayValue = new short[] { 7 } };
var snapshots = await drv.ReadAsync(["Counter"], CancellationToken.None);
var arr = snapshots.Single().Value.ShouldBeOfType();
arr.ShouldBe(new short[] { 7 });
factory.Tags["N7:0"].CreationParams.ElementCount.ShouldBe(1);
}
// ---- Discovery ----
/// Discovery flips IsArray and surfaces ArrayDim for an array file tag.
[Fact]
public async Task Discovery_surfaces_IsArray_and_ArrayDim_for_array_tag()
{
var captured = new List();
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);
}
///
/// I-3: a 1-element array tag (ArrayLength: 1) discovers as IsArray=true / ArrayDim=1 —
/// a [1] OPC UA array node, not a scalar.
///
[Fact]
public async Task Discovery_surfaces_one_element_array_as_IsArray_with_dim_one()
{
var captured = new List();
var (drv, _) = NewDriver(
new AbLegacyTagDefinition("Single", "ab://10.0.0.5/1,0", "N7:0", AbLegacyDataType.Int, ArrayLength: 1));
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.DiscoverAsync(new RecordingBuilder(captured), CancellationToken.None);
captured[0].IsArray.ShouldBeTrue();
captured[0].ArrayDim.ShouldBe(1u);
}
/// Scalar tag discovery keeps IsArray false / ArrayDim null (regression guard).
[Fact]
public async Task Discovery_keeps_scalar_tag_non_array()
{
var captured = new List();
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();
}
/// Discovery caps a 1024-element tag's ArrayDim at the PCCC 256-word file maximum.
[Fact]
public async Task Discovery_caps_array_dim_at_256()
{
var captured = new List();
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 ----
/// The equipment-tag parser threads arrayLength into the transient definition.
[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);
}
/// The parser caps arrayLength at the PCCC 256-word file maximum (isArray:true).
[Fact]
public void EquipmentTagParser_caps_arrayLength_at_256()
{
var json = """{"deviceHostAddress":"ab://10.0.0.5/1,0","address":"N7:0","dataType":"Int","isArray":true,"arrayLength":99999}""";
AbLegacyEquipmentTagParser.TryParse(json, out var def).ShouldBeTrue();
def!.ArrayLength.ShouldBe(256);
}
/// A scalar equipment tag (no arrayLength) leaves ArrayLength null.
[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();
}
///
/// C-2 regression: an arrayLength WITHOUT isArray:true is a SCALAR. The
/// canonical contract gates an array on isArray:true, so a stale length behind a
/// cleared / absent isArray must leave ArrayLength null and never produce an orphan array.
///
[Fact]
public void EquipmentTagParser_arrayLength_without_isArray_is_scalar()
{
var json = """{"deviceHostAddress":"ab://10.0.0.5/1,0","address":"N7:0","dataType":"Int","arrayLength":8}""";
AbLegacyEquipmentTagParser.TryParse(json, out var def).ShouldBeTrue();
def!.ArrayLength.ShouldBeNull();
}
/// C-2 regression: isArray:false with a positive arrayLength is a SCALAR.
[Fact]
public void EquipmentTagParser_isArray_false_with_arrayLength_is_scalar()
{
var json = """{"deviceHostAddress":"ab://10.0.0.5/1,0","address":"N7:0","dataType":"Int","isArray":false,"arrayLength":8}""";
AbLegacyEquipmentTagParser.TryParse(json, out var def).ShouldBeTrue();
def!.ArrayLength.ShouldBeNull();
}
///
/// I-3: isArray:true, arrayLength:1 is a valid 1-element array — ArrayLength is 1
/// (not null), so the foundation materialises a [1] node and the driver reads it
/// as a 1-element array.
///
[Fact]
public void EquipmentTagParser_isArray_true_arrayLength_one_is_one_element_array()
{
var json = """{"deviceHostAddress":"ab://10.0.0.5/1,0","address":"N7:0","dataType":"Int","isArray":true,"arrayLength":1}""";
AbLegacyEquipmentTagParser.TryParse(json, out var def).ShouldBeTrue();
def!.ArrayLength.ShouldBe(1);
}
///
/// End-to-end: an AbLegacy driver with NO authored tags reads an equipment-tag ref whose
/// TagConfig carries isArray:true + arrayLength — the resolver threads the
/// count and the read surfaces a typed array, capping the libplctag element count at 256.
///
[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","isArray":true,"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();
arr.ShouldBe(new short[] { 11, 22, 33 });
factory.Tags["N7:0"].CreationParams.ElementCount.ShouldBe(3);
}
///
/// C-2 end-to-end: an equipment-tag ref carrying isArray:false, arrayLength:8 reads
/// a single SCALAR value (the parser drops the orphan length), so the read never decodes a
/// phantom 8-element array against a scalar node. ElementCount stays 1.
///
[Fact]
public async Task Driver_reads_isArray_false_with_arrayLength_as_scalar()
{
var json = """{"deviceHostAddress":"ab://10.0.0.5/1,0","address":"N7:0","dataType":"Int","isArray":false,"arrayLength":8}""";
var factory = new FakeAbLegacyTagFactory { Customise = p => new FakeAbLegacyTag(p) { Value = 99 } };
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
{
Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0")],
Tags = [],
Probe = new AbLegacyProbeOptions { Enabled = false },
}, "ablegacy-eq-scalar", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var r = await drv.ReadAsync([json], CancellationToken.None);
r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
r[0].Value.ShouldBe(99);
r[0].Value.ShouldNotBeOfType();
factory.Tags["N7:0"].CreationParams.ElementCount.ShouldBe(1);
}
private sealed class RecordingBuilder(List 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) { }
}
}
}