Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipArrayWriteTests.cs
T
Joseph Doherty 7cc8415310 feat(abcip): array writes encode count elements at the element width
U10 (user decision: implement array encode now): config, the AdminUI and
discovery all advertised array tags Operate-writable, but both runtimes encoded
a scalar - an int[] threw InvalidCastException (BadTypeMismatch AND Degraded),
a scalar wrote element 0 natively, and libplctag re-sent its whole last-read
buffer with element 0 patched (stale elements 1..n-1).

- AbCipArrayCodec encodes count elements little-endian at the element width,
  CONVERTING each element (ScadaBridge writes int[] to INT arrays; overflow is
  BadOutOfRange, unconvertible BadTypeMismatch, both before anything is sent).
- The driver checks the shape before any runtime exists: not a list ->
  BadTypeMismatch, length != elementCount -> BadOutOfRange, BOOL/STRING/
  structure element types -> BadNotSupported; and, before sending, a controller
  buffer that is not width x count -> BadTypeMismatch. Refusals warn once and
  write no health.
- Native: ONE Write Tag of count elements with the controller's type code (the
  client fragments a large one); libplctag: the codec's bytes copied into the
  elem_count-sized buffer, refused TooSmall if it does not fit.
- IAbCipTagRuntime.EncodeArray (default: NotSupportedException).

Real-controller proof is a hardware userGate (see the lane report recipe).

Refs #737
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 14:30:11 -04:00

179 lines
8.4 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Fakes;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
/// <summary>
/// Review 07 U10: array writes. Config and the AdminUI allowed them and discovery advertised array nodes
/// Operate-writable, but both runtimes encoded a SCALAR: an <c>int[]</c> threw InvalidCastException (BadTypeMismatch
/// AND Degraded), a scalar wrote element 0 natively, and libplctag re-sent the whole last-read buffer with only
/// element 0 patched. The driver now encodes <c>count</c> elements at the element width, refuses a length mismatch
/// with <c>BadOutOfRange</c>, and converts element values (ScadaBridge writes <c>int[]</c> to INT arrays).
/// </summary>
[Trait("Category", "Unit")]
public sealed class AbCipArrayWriteTests
{
private const string Device = "ab://10.0.0.5/1,0";
private static async Task<(AbCipDriver Driver, FakeAbCipTagFactory Factory)> BuildAsync(
AbCipDataType type, int count, int size = -1)
{
var factory = new FakeAbCipTagFactory { Customise = p => new FakeAbCipTag(p) { Size = size } };
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(Device)],
RawTags = AbCipRawTags.From(new AbCipTagDefinition("Arr", Device, "Arr", type, ElementCount: count, IsArray: true)),
Probe = new AbCipProbeOptions { Enabled = false },
}, "abcip-array-write", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
return (drv, factory);
}
// ---- the codec ----
/// <summary>Each element is converted and laid out little-endian at the element width.</summary>
[Fact]
public void The_codec_converts_elements_and_lays_them_out_at_the_element_width()
{
AbCipArrayCodec.Encode(AbCipDataType.Int, [1, -2, 300]).ShouldBe(Convert.FromHexString("0100FEFF2C01"));
AbCipArrayCodec.Encode(AbCipDataType.SInt, [(short)-1, 5L]).ShouldBe(Convert.FromHexString("FF05"));
AbCipArrayCodec.Encode(AbCipDataType.DInt, [1.0, 2]).ShouldBe(Convert.FromHexString("0100000002000000"));
AbCipArrayCodec.Encode(AbCipDataType.Real, [1, 2.5]).ShouldBe(Convert.FromHexString("0000803F00002040"));
AbCipArrayCodec.Encode(AbCipDataType.ULInt, [ulong.MaxValue]).ShouldBe(Convert.FromHexString("FFFFFFFFFFFFFFFF"));
Should.Throw<OverflowException>(() => AbCipArrayCodec.Encode(AbCipDataType.Int, [40000]));
Should.Throw<NotSupportedException>(() => AbCipArrayCodec.Encode(AbCipDataType.Bool, [true]));
}
// ---- the driver ----
/// <summary>ScadaBridge's wire shape: an <c>int[]</c> to an INT[3] encodes 3 two-byte elements and writes once, Good.</summary>
[Fact]
public async Task An_int_array_to_an_INT_array_encodes_every_element()
{
var (drv, factory) = await BuildAsync(AbCipDataType.Int, 3, size: 6);
var r = await drv.WriteAsync([new WriteRequest("Arr", new[] { 1, 2, 3 })], CancellationToken.None);
r[0].StatusCode.ShouldBe(AbCipStatusMapper.Good);
var tag = factory.Tags["Arr"];
tag.EncodedArrayType.ShouldBe(AbCipDataType.Int);
tag.EncodedArrayCount.ShouldBe(3);
tag.EncodedArrayBytes.ShouldBe(Convert.FromHexString("010002000300"));
tag.WriteCount.ShouldBe(1);
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
}
/// <summary>A list of a different length is BadOutOfRange; nothing is created, encoded or written.</summary>
/// <param name="length">The client's element count.</param>
[Theory]
[InlineData(2)]
[InlineData(4)]
public async Task A_length_mismatch_is_BadOutOfRange_and_nothing_is_written(int length)
{
var (drv, factory) = await BuildAsync(AbCipDataType.DInt, 3);
var r = await drv.WriteAsync([new WriteRequest("Arr", Enumerable.Range(0, length).ToArray())], CancellationToken.None);
r[0].StatusCode.ShouldBe(AbCipStatusMapper.BadOutOfRange);
factory.Tags.ShouldNotContainKey("Arr"); // refused before any runtime exists
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
}
/// <summary>A scalar (or a string) to an array tag is BadTypeMismatch — it used to write element 0 only.</summary>
[Fact]
public async Task A_scalar_to_an_array_tag_is_BadTypeMismatch()
{
var (drv, factory) = await BuildAsync(AbCipDataType.DInt, 3);
var r = await drv.WriteAsync(
[new WriteRequest("Arr", 5), new WriteRequest("Arr", "1,2,3")], CancellationToken.None);
r[0].StatusCode.ShouldBe(AbCipStatusMapper.BadTypeMismatch);
r[1].StatusCode.ShouldBe(AbCipStatusMapper.BadTypeMismatch);
factory.Tags.ShouldNotContainKey("Arr");
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
}
/// <summary>An element that does not fit is BadOutOfRange with no health write and nothing sent.</summary>
[Fact]
public async Task An_element_overflow_is_BadOutOfRange_and_nothing_is_sent()
{
var (drv, factory) = await BuildAsync(AbCipDataType.Int, 2, size: 4);
var r = await drv.WriteAsync([new WriteRequest("Arr", new[] { 1, 70000 })], CancellationToken.None);
r[0].StatusCode.ShouldBe(AbCipStatusMapper.BadOutOfRange);
factory.Tags["Arr"].WriteCount.ShouldBe(0);
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
}
/// <summary>BOOL / STRING arrays are refused BadNotSupported before any runtime exists.</summary>
/// <param name="type">The array element type.</param>
[Theory]
[InlineData(AbCipDataType.Bool)]
[InlineData(AbCipDataType.String)]
public async Task Unencodable_element_types_are_BadNotSupported(AbCipDataType type)
{
var (drv, factory) = await BuildAsync(type, 2);
var r = await drv.WriteAsync([new WriteRequest("Arr", new object[] { true, false })], CancellationToken.None);
r[0].StatusCode.ShouldBe(AbCipStatusMapper.BadNotSupported);
factory.Tags.ShouldNotContainKey("Arr");
}
/// <summary>The controller holding a different shape than authored (size ≠ width × count) is BadTypeMismatch, nothing sent.</summary>
[Fact]
public async Task A_controller_shape_mismatch_is_BadTypeMismatch_and_nothing_is_sent()
{
var (drv, factory) = await BuildAsync(AbCipDataType.Int, 3, size: 12); // DINT[3] on the controller
var r = await drv.WriteAsync([new WriteRequest("Arr", new[] { 1, 2, 3 })], CancellationToken.None);
r[0].StatusCode.ShouldBe(AbCipStatusMapper.BadTypeMismatch);
factory.Tags["Arr"].WriteCount.ShouldBe(0);
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
}
/// <summary>
/// End to end over the REAL native runtime: an <c>int[]</c> to an INT[3] goes out as ONE Write Tag of type
/// 0xC3 with element count 3 and the three 2-byte elements.
/// </summary>
[Fact]
public async Task A_native_array_write_is_one_write_of_count_elements()
{
await using var target = new FakeCipTarget();
target.ConnectedHandler = req => Task.FromResult<byte[]?>(req[0] switch
{
0x52 => [0xD2, 0x00, 0x00, 0x00, 0xC3, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00], // INT[3] = 0,0,0
0x4D => [0xCD, 0x00, 0x00, 0x00],
_ => target.ServiceReply(req),
});
var host = $"ab://127.0.0.1:{target.Port}/1,0";
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(host, AbCipPlcFamily.ControlLogix)],
RawTags = AbCipRawTags.From(new AbCipTagDefinition("Arr", host, "Arr", AbCipDataType.Int, ElementCount: 3, IsArray: true)),
Probe = new AbCipProbeOptions { Enabled = false },
Timeout = TimeSpan.FromSeconds(5),
}, "abcip-array-native", new NativeAbCipTagFactory(new DeviceSessionPool()));
await drv.InitializeAsync("{}", CancellationToken.None);
try
{
var r = await drv.WriteAsync([new WriteRequest("Arr", new[] { 1, -2, 300 })], CancellationToken.None);
r[0].StatusCode.ShouldBe(AbCipStatusMapper.Good);
var write = target.ConnectedCips.Single(c => c[0] == 0x4D);
var typeAt = 2 + write[1] * 2;
write.AsSpan(typeAt).ToArray().ShouldBe(Convert.FromHexString("C300" + "0300" + "0100FEFF2C01"));
}
finally
{
await drv.DisposeAsync();
}
}
}