Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/SparkplugArrayCodecTests.cs
T
Joseph Doherty 7f29a75edb
v2-ci / unit-tests (push) Has been cancelled
feat(mqtt): read Sparkplug B array metrics as 1-D OPC UA arrays (lmxopcua#754)
R06:M-5 made Sparkplug *Array leaves unbindable because the ingestor refused
them. The ingestor now decodes the packed bytes_value of all 13 array types
(new SparkplugArrayCodec: LE fixed widths, int64-ms DateTime, uint32-count +
MSB-first BooleanArray, NUL-terminated UTF-8 StringArray; golden vectors are
the spec's examples, reproduced byte-for-byte by SparkplugNet 1.3.10) and
publishes a typed 1-D array through the scalar coercion, all-or-nothing.

Shape uses the fleet's one array model: the tag's platform isArray/arrayLength
keys (ValueRank=1, ArrayDimensions=[arrayLength] or [0] = variable length).
The driver reads them with TagConfigIntent's exact semantics and never rejects
on them, so composer, driver, authoring model and deploy inspector agree
(MqttArrayShapeParityTests). Shape mismatch (array metric on a scalar tag, or
the reverse) is BadTypeMismatch - a String tag no longer gets the packed
buffer as base64; a malformed body or a length differing from a declared
arrayLength is BadDecodingError (MTConnect #627 verdict). Template/DataSet
members of an array type take the same path. Arrays stay read-only (encoder
refuses them; MQTT is not in DriverArrayWriteSupport).

Browser: array leaves are bindable again and state isArray in AddressFields;
the commit mapper writes "isArray": true (no arrayLength). Discovery reports
IsArray/ArrayDim. Docs: Mqtt.md "Array metrics", design table, drivers rule.

Tests: codec (golden + malformed), ingest through the real codec + Dispatch
(the project's MiniBroker never forwards a PUBLISH, so there is no in-process
broker harness), browse, discovery, commit mapper, parity corpus. Mutating the
fix back out fails 18 MQTT + 9 AdminUI tests.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 13:58:38 -05:00

127 lines
5.8 KiB
C#

using System.Buffers.Binary;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Sparkplug;
using TahuDataType = Org.Eclipse.Tahu.Protobuf.DataType;
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests;
/// <summary>
/// Covers <see cref="SparkplugArrayCodec"/> (lmxopcua#754). The golden vectors are the Sparkplug B 3.0
/// specification's own "Datatype details" examples, and an independent encoder (SparkplugNet 1.3.10,
/// <c>PayloadConverter.ConvertVersionBMetric</c>) was run against the same inputs and produced the same bytes
/// — so the bit order of a BooleanArray and the string terminator are not this repo agreeing with itself.
/// </summary>
public sealed class SparkplugArrayCodecTests
{
/// <summary>Decodes and pins the WIRE element type the codec hands the ingestor.</summary>
private static T[] Decode<T>(TahuDataType type, params byte[] packed)
{
SparkplugArrayCodec.TryDecode(type, packed, out var elements, out var error).ShouldBeTrue(error);
return elements.ShouldBeOfType<T[]>();
}
private static string Refuse(TahuDataType type, params byte[] packed)
{
SparkplugArrayCodec.TryDecode(type, packed, out var elements, out var error).ShouldBeFalse();
elements.ShouldBeNull();
return error.ShouldNotBeNull();
}
// ---- golden vectors (spec examples; SparkplugNet reproduces them byte for byte) ----
/// <summary>uint32 LE count, then the bits MSB-first: 0x34 = F,F,T,T,F,T,F,F and 0xD0 = T,T,F,T + padding.</summary>
[Fact]
public void BooleanArray_IsCountPrefixed_AndPackedMostSignificantBitFirst()
=> Decode<bool>(TahuDataType.BooleanArray, 0x0C, 0x00, 0x00, 0x00, 0x34, 0xD0)
.ShouldBe(new[] { false, false, true, true, false, true, false, false, true, true, false, true });
[Fact]
public void Int8Array_IsOneSignedBytePerElement()
=> Decode<sbyte>(TahuDataType.Int8Array, 0xE9, 0x7B).ShouldBe(new sbyte[] { -23, 123 });
[Fact]
public void Int16Array_IsLittleEndian()
=> Decode<short>(TahuDataType.Int16Array, 0xD0, 0x8A, 0x30, 0x75).ShouldBe(new short[] { -30000, 30000 });
[Fact]
public void Uint32Array_IsLittleEndian()
=> Decode<uint>(TahuDataType.Uint32Array, 0x34, 0x00, 0x00, 0x00, 0x49, 0xFB, 0x55, 0xC4)
.ShouldBe(new uint[] { 52, 3293969225 });
[Fact]
public void FloatArray_IsLittleEndianIeee754()
=> Decode<float>(TahuDataType.FloatArray, 0xA4, 0x70, 0x9D, 0x3F, 0x98, 0xAE, 0xB2, 0x42)
.ShouldBe(new[] { 1.23f, 89.341f });
[Fact]
public void StringArray_IsNullTerminatedUtf8()
=> Decode<string>(TahuDataType.StringArray, 0x41, 0x42, 0x43, 0x00, 0x68, 0x65, 0x6C, 0x6C, 0x6F, 0x00)
.ShouldBe(new[] { "ABC", "hello" });
/// <summary>Signed int64 epoch milliseconds — left as the wire long; the ingestor coerces it to a DateTime.</summary>
[Fact]
public void DateTimeArray_IsInt64EpochMilliseconds()
=> Decode<long>(TahuDataType.DateTimeArray, 0xC7, 0xD0, 0x90, 0x75, 0x24, 0x01, 0x00, 0x00)
.ShouldBe(new[] { 1256102875335L });
// ---- the remaining widths, encoded with the BCL so the decoder is not checked against itself ----
[Fact]
public void RemainingWidths_RoundTripTheBclLittleEndianEncoding()
{
var bytes = new byte[16];
BinaryPrimitives.WriteInt64LittleEndian(bytes, -5L);
BinaryPrimitives.WriteInt64LittleEndian(bytes.AsSpan(8), long.MaxValue);
Decode<long>(TahuDataType.Int64Array, bytes).ShouldBe(new[] { -5L, long.MaxValue });
BinaryPrimitives.WriteUInt64LittleEndian(bytes, ulong.MaxValue);
BinaryPrimitives.WriteUInt64LittleEndian(bytes.AsSpan(8), 7UL);
Decode<ulong>(TahuDataType.Uint64Array, bytes).ShouldBe(new[] { ulong.MaxValue, 7UL });
BinaryPrimitives.WriteDoubleLittleEndian(bytes, -1.5);
BinaryPrimitives.WriteDoubleLittleEndian(bytes.AsSpan(8), 1e300);
Decode<double>(TahuDataType.DoubleArray, bytes).ShouldBe(new[] { -1.5, 1e300 });
Decode<byte>(TahuDataType.Uint8Array, 0x00, 0xFF).ShouldBe(new byte[] { 0, 255 });
Decode<ushort>(TahuDataType.Uint16Array, 0xFF, 0xFF, 0x01, 0x00).ShouldBe(new ushort[] { 65535, 1 });
Decode<int>(TahuDataType.Int32Array, 0xD6, 0xFF, 0xFF, 0xFF).ShouldBe(new[] { -42 });
}
[Fact]
public void EmptyBodies_AreEmptyArrays_ExceptBooleanWhichNeedsItsCount()
{
Decode<int>(TahuDataType.Int32Array).Length.ShouldBe(0);
Decode<string>(TahuDataType.StringArray).Length.ShouldBe(0);
Decode<bool>(TahuDataType.BooleanArray, 0x00, 0x00, 0x00, 0x00).Length.ShouldBe(0);
Refuse(TahuDataType.BooleanArray);
}
// ---- malformed bodies: all-or-nothing, never a partial vector ----
[Fact]
public void ABodyThatIsNotAWholeNumberOfElements_IsRefused()
=> Refuse(TahuDataType.Int32Array, 1, 2, 3, 4, 5).ShouldContain("whole number");
[Fact]
public void ABooleanCountTheBodyCannotHold_IsRefused()
=> Refuse(TahuDataType.BooleanArray, 0x0C, 0x00, 0x00, 0x00, 0x34).ShouldContain("needs 2");
/// <summary>Exact, not "at least": a trailing byte means the count and the body disagree.</summary>
[Fact]
public void ABooleanBodyLongerThanItsCountNeeds_IsRefused()
=> Refuse(TahuDataType.BooleanArray, 0x08, 0x00, 0x00, 0x00, 0xFF, 0x00).ShouldContain("needs 1");
[Fact]
public void AnUnterminatedLastString_IsRefused()
=> Refuse(TahuDataType.StringArray, 0x41, 0x00, 0x42).ShouldContain("terminated");
[Fact]
public void InvalidUtf8_IsRefused_NotReplacementCharactered()
=> Refuse(TahuDataType.StringArray, 0xC0, 0xC0, 0x00).ShouldContain("UTF-8");
[Fact]
public void AScalarDatatype_IsNotAnArray()
=> Refuse(TahuDataType.Int32, 0, 0, 0, 0).ShouldContain("not a Sparkplug array");
}