Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyArrayWriteTests.cs
T
Joseph Doherty d758dd1e16 fix(ablegacy): encode array writes at the data file's element width
Review r0 I3. The array encoder used the AUTHORED dataType's width while both
runtimes size their buffer from the FILE: an Int array on F8 wrote four shorts
into a 16-byte float buffer and sent it all (garbage neighbours, answered
Good); Long/Float on N overran it.

- The write path resolves the element width from the data file the address
  names (N/B/A/I/O/S 16-bit word, L 32-bit, F float) and encodes at THAT width.
- An authored type that does not match its file is refused BadTypeMismatch
  before any runtime exists (warned once, no health write).
- The same rule (AbLegacyTagDefinitionFactory.ArrayFileMismatch) backs an
  Inspect deploy warning and the AdminUI model's Validate error; FromTagConfig
  still accepts the tag so persisted configs keep loading.
- M6: the skipped F1 parity test gains the shapes the write path refuses
  (/bit, .sub, indirect, type/file mismatch, PLC-5 native).

Tests: one case per file type, a mismatch per pairing with nothing created,
and a positive control.

Refs #738
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 15:20:10 -04:00

246 lines
11 KiB
C#

using System.Buffers.Binary;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Fakes;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
/// <summary>
/// Review 07 U10 (user decision "implement array encode now"): an array write used to encode a SCALAR — an
/// <c>int[]</c> threw <see cref="InvalidCastException"/> into <c>BadTypeMismatch</c> + Degraded, and a scalar to an
/// array tag wrote element 0 (native) or re-sent the stale last-read buffer (libplctag). The driver now encodes
/// exactly <c>count</c> elements at the element width, converting each element (ScadaBridge writes CLR lists
/// such as <c>int[]</c> to Int16 nodes), and refuses a length mismatch with <c>BadOutOfRange</c>.
/// </summary>
[Trait("Category", "Unit")]
public sealed class AbLegacyArrayWriteTests
{
private const string Host = "ab://10.0.0.5/1,0";
private sealed class RecordingTag(AbLegacyTagCreateParams p) : FakeAbLegacyTag(p)
{
public Array? Encoded { get; private set; }
public AbLegacyDataType? EncodedType { get; private set; }
public override void EncodeArray(AbLegacyDataType type, Array values)
{
EncodedType = type;
Encoded = values;
}
}
private static async Task<(AbLegacyDriver Drv, FakeAbLegacyTagFactory Factory)> NewAsync(
AbLegacyDataType type, string address, int length)
{
var factory = new FakeAbLegacyTagFactory { Customise = p => new RecordingTag(p) };
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
{
Devices = [new AbLegacyDeviceOptions(Host)],
RawTags = AbLegacyRawTags.Entries(new AbLegacyTagDefinition("Arr", Host, address, type, ArrayLength: length)),
Probe = new AbLegacyProbeOptions { Enabled = false },
}, "drv-u10", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
return (drv, factory);
}
public static TheoryData<string> IntPayloads => ["int[]", "short[]", "List<int>", "object[]"];
private static object Payload(string kind) => kind switch
{
"int[]" => new[] { 1, -2, 3 }, // ScadaBridge: CLR int[] to an Int16 node
"short[]" => new short[] { 1, -2, 3 },
"List<int>" => new List<int> { 1, -2, 3 },
_ => new object[] { 1L, (short)-2, 3.0 },
};
[Theory]
[MemberData(nameof(IntPayloads))]
public async Task Int_array_write_converts_each_element_and_encodes_count_elements(string kind)
{
var (drv, f) = await NewAsync(AbLegacyDataType.Int, "N7:0", 3);
var r = await drv.WriteAsync([new WriteRequest("Arr", Payload(kind))], CancellationToken.None);
r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
var tag = (RecordingTag)f.Tags["N7:0"];
tag.CreationParams.ElementCount.ShouldBe(3);
tag.EncodedType.ShouldBe(AbLegacyDataType.Int);
tag.Encoded.ShouldBe(new short[] { 1, -2, 3 });
tag.WriteCount.ShouldBe(1);
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
}
/// <summary>Review 07 r0 I3: every data file type encodes at ITS element width (one case per file).</summary>
[Theory]
[InlineData("N7:0", AbLegacyDataType.Int, AbLegacyDataType.Int)]
[InlineData("N7:0", AbLegacyDataType.AnalogInt, AbLegacyDataType.Int)]
[InlineData("B3:0", AbLegacyDataType.Int, AbLegacyDataType.Int)]
[InlineData("A9:0", AbLegacyDataType.Int, AbLegacyDataType.Int)]
[InlineData("L9:0", AbLegacyDataType.Long, AbLegacyDataType.Long)]
[InlineData("F8:0", AbLegacyDataType.Float, AbLegacyDataType.Float)]
public async Task Each_file_type_encodes_at_the_file_element_width(string address, AbLegacyDataType authored, AbLegacyDataType fileWidth)
{
var (drv, f) = await NewAsync(authored, address, 2);
var r = await drv.WriteAsync([new WriteRequest("Arr", new[] { 1, 2 })], CancellationToken.None);
r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
var tag = (RecordingTag)f.Tags[address];
tag.EncodedType.ShouldBe(fileWidth);
tag.Encoded!.GetType().ShouldBe(fileWidth switch
{
AbLegacyDataType.Long => typeof(int[]),
AbLegacyDataType.Float => typeof(float[]),
_ => typeof(short[]),
});
tag.WriteCount.ShouldBe(1);
}
/// <summary>
/// Review 07 r0 I3: an array whose authored type does not match its data file (Int on F8, Long on N7, Float on
/// L9) used to be encoded at the AUTHORED width into the file-typed buffer and sent — garbage in the
/// neighbouring elements, answered Good. It is now refused BadTypeMismatch before any runtime exists.
/// </summary>
[Theory]
[InlineData("F8:0", AbLegacyDataType.Int)]
[InlineData("N7:0", AbLegacyDataType.Long)]
[InlineData("N7:0", AbLegacyDataType.Float)]
[InlineData("L9:0", AbLegacyDataType.Float)]
[InlineData("L9:0", AbLegacyDataType.Int)]
public async Task A_type_that_does_not_match_its_file_is_refused_and_never_written(string address, AbLegacyDataType authored)
{
var (drv, f) = await NewAsync(authored, address, 4);
var r = await drv.WriteAsync([new WriteRequest("Arr", new[] { 1, 2, 3, 4 })], CancellationToken.None);
r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadTypeMismatch);
f.Tags.ShouldBeEmpty(); // nothing created, nothing on the wire
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
AbLegacyTagDefinitionFactory.Inspect(
$$"""{"address":"{{address}}","dataType":"{{authored}}","isArray":true,"arrayLength":4}""")
.ShouldContain(w => w.Contains("element width")); // and the deploy gate names it
}
[Fact]
public async Task Positive_control_a_Float_array_on_an_F_file_is_written()
{
var (drv, f) = await NewAsync(AbLegacyDataType.Float, "F8:0", 4);
(await drv.WriteAsync([new WriteRequest("Arr", new[] { 1, 2, 3, 4 })], CancellationToken.None))[0]
.StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
((RecordingTag)f.Tags["F8:0"]).Encoded.ShouldBe(new[] { 1f, 2f, 3f, 4f });
AbLegacyTagDefinitionFactory.Inspect("""{"address":"F8:0","dataType":"Float","isArray":true,"arrayLength":4}""")
.ShouldBeEmpty();
}
[Fact]
public async Task Float_and_Long_arrays_encode_at_their_element_width()
{
var (fd, ff) = await NewAsync(AbLegacyDataType.Float, "F8:0", 2);
(await fd.WriteAsync([new WriteRequest("Arr", new[] { 1.5, 2 })], CancellationToken.None))[0]
.StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
((RecordingTag)ff.Tags["F8:0"]).Encoded.ShouldBe(new[] { 1.5f, 2f });
var (ld, lf) = await NewAsync(AbLegacyDataType.Long, "L9:0", 2);
(await ld.WriteAsync([new WriteRequest("Arr", new short[] { 7, -8 })], CancellationToken.None))[0]
.StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
((RecordingTag)lf.Tags["L9:0"]).Encoded.ShouldBe(new[] { 7, -8 });
}
[Theory]
[InlineData(2)]
[InlineData(4)]
public async Task Length_mismatch_is_BadOutOfRange_and_nothing_is_sent(int supplied)
{
var (drv, f) = await NewAsync(AbLegacyDataType.Int, "N7:0", 3);
var r = await drv.WriteAsync([new WriteRequest("Arr", new int[supplied])], CancellationToken.None);
r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadOutOfRange);
f.Tags.Values.ShouldAllBe(t => t.WriteCount == 0);
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
}
[Fact]
public async Task Element_overflow_is_BadOutOfRange_and_nothing_is_sent()
{
var (drv, f) = await NewAsync(AbLegacyDataType.Int, "N7:0", 2);
var r = await drv.WriteAsync([new WriteRequest("Arr", new[] { 1, 70000 })], CancellationToken.None);
r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadOutOfRange);
f.Tags.Values.ShouldAllBe(t => t.WriteCount == 0);
}
[Theory]
[InlineData(5)]
[InlineData("1,2")]
public async Task Non_array_payload_or_unconvertible_element_is_BadTypeMismatch(object payload)
{
var (drv, f) = await NewAsync(AbLegacyDataType.Int, "N7:0", 2);
var r = await drv.WriteAsync([new WriteRequest("Arr", payload)], CancellationToken.None);
var e = await drv.WriteAsync([new WriteRequest("Arr", new object[] { 1, "x" })], CancellationToken.None);
r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadTypeMismatch);
e[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadTypeMismatch);
f.Tags.Values.ShouldAllBe(t => t.WriteCount == 0);
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
}
[Fact]
public async Task Bit_array_write_is_refused_BadNotSupported_before_any_runtime()
{
// Bit arrays are bit-packed in words: an encode of N bits would rewrite whole words from a stale read.
var (drv, f) = await NewAsync(AbLegacyDataType.Bit, "B3:0", 16);
var r = await drv.WriteAsync([new WriteRequest("Arr", new bool[16])], CancellationToken.None);
r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadNotSupported);
f.Tags.ShouldBeEmpty();
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
}
[Fact]
public async Task Native_array_encode_writes_every_element_at_its_offset()
{
var channel = new FakeCipChannel();
await using var scheduler = new RequestScheduler(channel, allowPacking: false);
var rt = new NativeAbLegacyTagRuntime(
new AbLegacyTagCreateParams("10.0.0.1", 44818, "1,0", "slc500", "N7:0", TimeSpan.FromSeconds(2), 3),
scheduler, 0xF33D, 0x21504345);
channel.Respond = req => Task.FromResult(NativeEcho(req));
rt.EncodeArray(AbLegacyDataType.Int, new short[] { 10, -1, 300 });
await rt.WriteAsync(CancellationToken.None);
var data = channel.Sent.Single()[23..];
data.Length.ShouldBe(6);
BinaryPrimitives.ReadInt16LittleEndian(data.AsSpan(0)).ShouldBe((short)10);
BinaryPrimitives.ReadInt16LittleEndian(data.AsSpan(2)).ShouldBe((short)-1);
BinaryPrimitives.ReadInt16LittleEndian(data.AsSpan(4)).ShouldBe((short)300);
rt.Dispose();
}
[Fact]
public void Native_array_encode_refuses_a_length_other_than_the_runtime_count()
{
var channel = new FakeCipChannel();
var scheduler = new RequestScheduler(channel, allowPacking: false);
var rt = new NativeAbLegacyTagRuntime(
new AbLegacyTagCreateParams("10.0.0.1", 44818, "1,0", "slc500", "F8:0", TimeSpan.FromSeconds(2), 3),
scheduler, 0xF33D, 0x21504345);
Should.Throw<ArgumentException>(() => rt.EncodeArray(AbLegacyDataType.Float, new float[2]));
rt.Dispose();
}
// Execute PCCC reply echoing the request's requestor id and TNS with STS 0 and no data.
private static byte[] NativeEcho(byte[] req)
{
var body = new List<byte>(req[6..13]) { 0x4F, 0x00, req[15], req[16] };
return FakeCipChannel.Reply(0x4B, 0x00, [.. body]);
}
}