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; /// /// Covers (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, /// PayloadConverter.ConvertVersionBMetric) 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. /// public sealed class SparkplugArrayCodecTests { /// Decodes and pins the WIRE element type the codec hands the ingestor. private static T[] Decode(TahuDataType type, params byte[] packed) { SparkplugArrayCodec.TryDecode(type, packed, out var elements, out var error).ShouldBeTrue(error); return elements.ShouldBeOfType(); } 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) ---- /// uint32 LE count, then the bits MSB-first: 0x34 = F,F,T,T,F,T,F,F and 0xD0 = T,T,F,T + padding. [Fact] public void BooleanArray_IsCountPrefixed_AndPackedMostSignificantBitFirst() => Decode(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(TahuDataType.Int8Array, 0xE9, 0x7B).ShouldBe(new sbyte[] { -23, 123 }); [Fact] public void Int16Array_IsLittleEndian() => Decode(TahuDataType.Int16Array, 0xD0, 0x8A, 0x30, 0x75).ShouldBe(new short[] { -30000, 30000 }); [Fact] public void Uint32Array_IsLittleEndian() => Decode(TahuDataType.Uint32Array, 0x34, 0x00, 0x00, 0x00, 0x49, 0xFB, 0x55, 0xC4) .ShouldBe(new uint[] { 52, 3293969225 }); [Fact] public void FloatArray_IsLittleEndianIeee754() => Decode(TahuDataType.FloatArray, 0xA4, 0x70, 0x9D, 0x3F, 0x98, 0xAE, 0xB2, 0x42) .ShouldBe(new[] { 1.23f, 89.341f }); [Fact] public void StringArray_IsNullTerminatedUtf8() => Decode(TahuDataType.StringArray, 0x41, 0x42, 0x43, 0x00, 0x68, 0x65, 0x6C, 0x6C, 0x6F, 0x00) .ShouldBe(new[] { "ABC", "hello" }); /// Signed int64 epoch milliseconds — left as the wire long; the ingestor coerces it to a DateTime. [Fact] public void DateTimeArray_IsInt64EpochMilliseconds() => Decode(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(TahuDataType.Int64Array, bytes).ShouldBe(new[] { -5L, long.MaxValue }); BinaryPrimitives.WriteUInt64LittleEndian(bytes, ulong.MaxValue); BinaryPrimitives.WriteUInt64LittleEndian(bytes.AsSpan(8), 7UL); Decode(TahuDataType.Uint64Array, bytes).ShouldBe(new[] { ulong.MaxValue, 7UL }); BinaryPrimitives.WriteDoubleLittleEndian(bytes, -1.5); BinaryPrimitives.WriteDoubleLittleEndian(bytes.AsSpan(8), 1e300); Decode(TahuDataType.DoubleArray, bytes).ShouldBe(new[] { -1.5, 1e300 }); Decode(TahuDataType.Uint8Array, 0x00, 0xFF).ShouldBe(new byte[] { 0, 255 }); Decode(TahuDataType.Uint16Array, 0xFF, 0xFF, 0x01, 0x00).ShouldBe(new ushort[] { 65535, 1 }); Decode(TahuDataType.Int32Array, 0xD6, 0xFF, 0xFF, 0xFF).ShouldBe(new[] { -42 }); } [Fact] public void EmptyBodies_AreEmptyArrays_ExceptBooleanWhichNeedsItsCount() { Decode(TahuDataType.Int32Array).Length.ShouldBe(0); Decode(TahuDataType.StringArray).Length.ShouldBe(0); Decode(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"); /// Exact, not "at least": a trailing byte means the count and the body disagree. [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"); }