7f29a75edb
v2-ci / unit-tests (push) Has been cancelled
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
127 lines
5.8 KiB
C#
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");
|
|
}
|