Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipArrayWriteTests.cs
T
Joseph Doherty 459cbb444e fix(abcip): review fix round 1 - BOOL-array fallback, local refusals, minors
I1: the U11 Bits[n] -> Bits[n/32] rewrite was unconditional. The native
runtime now keeps the original element path until the first reply settles the
layout: 0xD3 confirms DWORD addressing (bit n%32); any other reply type (e.g.
a 0xC1 per-element BOOL array) or a refusal of the DWORD index falls back to
Bits[n] and re-reads, and a write settles the layout by a read first, so it can
never decode or write element n/32 of an element-addressed array.

I2: libplctag ErrorNoMatch/ErrorBadDevice no longer fold into NotFound (they
now read as BadConnection: comms, Degraded, LastSuccessfulRead preserved).
ErrorNotImplemented - and the native runtime's own pre-send refusals - raise
AbCipLocalRefusalException: BadNotSupported, no health, LastSuccessfulRead NOT
advanced, no backoff record, not reachable for the probe or Test Connect.

M1: the bit RMW's parent read/write status words follow the health contract
(refusal: per-tag Bad + Warning; comms: evict the parent + Degraded).
M2: device Unsupported read refusals return Refused (advance
LastSuccessfulRead); device write refusals call RecordWriteAnswered (not the
pre-send native type gate).
M3: a good write clears a Degraded a write comms fault recorded (a read-side
Degraded is still left for the read path).
M4: a superseded init throws AbCipInitializationSupersededException (not an
OCE); ShutdownAsync stamps Unknown only if no newer init published meanwhile.
M5: the sync-Dispose timing assert is 2 s against a 4 s probe read; the
unencodable-array test asserts health.

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

180 lines
8.5 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");
drv.GetHealth().State.ShouldBe(DriverState.Healthy); // M5: a refusal writes no health
}
/// <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();
}
}
}