using Google.Protobuf; using Org.Eclipse.Tahu.Protobuf; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Sparkplug; namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests; /// /// Covers : the golden NBIRTH/NDATA vectors decode into the driver-side /// projection with explicit presence preserved, and no input — malformed, truncated, empty, or /// simply not Sparkplug — makes the decoder throw. /// public sealed class SparkplugCodecTests { /// /// The headline case from the plan: a golden NBIRTH yields its sequence number and its metric /// catalog, with name, alias, datatype and value all surviving. /// [Fact] public void Decode_GoldenNbirth_ExtractsMetrics() { var payload = SparkplugCodec.Decode(SparkplugGoldenPayloads.Read(SparkplugGoldenPayloads.NbirthFile)); payload.IsValid.ShouldBeTrue(); payload.Seq.ShouldBe(0UL); payload.Metrics.ShouldContain(m => m.Name == "Temperature" && m.Alias == 5UL && m.DataType == DataType.Float && Equals(m.Value, 21.5f)); } /// /// Explicit presence, case 1. A Sparkplug NBIRTH is REQUIRED to carry seq = 0, so a /// decoder that reads absence off a zero value reports "no sequence" for every birth ever /// published — and the sequence tracker then has no birth to anchor on. /// [Fact] public void Decode_GoldenNbirth_SeqZero_IsPresentNotAbsent() { var payload = SparkplugCodec.Decode(SparkplugGoldenPayloads.Read(SparkplugGoldenPayloads.NbirthFile)); payload.Seq.HasValue.ShouldBeTrue(); payload.Seq!.Value.ShouldBe(0UL); } /// /// Explicit presence, case 2. A payload that genuinely omits seq must report /// absence, not zero — the mirror image of the test above, and the pair is what makes the /// distinction falsifiable rather than incidental. /// [Fact] public void Decode_PayloadWithoutSeq_ReportsAbsentSeq() { var wire = new Payload { Timestamp = 1UL }.ToByteArray(); SparkplugCodec.Decode(wire).Seq.ShouldBeNull(); } /// /// Explicit presence, case 3. Every real Sparkplug DATA metric omits name and /// datatype and carries only its alias. Reporting an absent name as the empty string /// would make it indistinguishable from a (malformed) metric genuinely named "". /// [Fact] public void Decode_GoldenNdata_MetricsCarryAliasOnly_NameAndDataTypeAbsent() { var payload = SparkplugCodec.Decode(SparkplugGoldenPayloads.Read(SparkplugGoldenPayloads.NdataFile)); payload.IsValid.ShouldBeTrue(); payload.Seq.ShouldBe(1UL); payload.Metrics.Count.ShouldBe(3); foreach (var metric in payload.Metrics) { metric.Name.ShouldBeNull(); metric.DataType.ShouldBeNull(); metric.Alias.HasValue.ShouldBeTrue(); } payload.Metrics.ShouldContain(m => m.Alias == SparkplugGoldenPayloads.TemperatureAlias && Equals(m.Value, 22.25f)); } /// /// Explicit presence, case 4. "The metric's value is null" (is_null) is a different /// fact from "the metric carried no value field", and both differ from "the value is zero". /// [Fact] public void Decode_GoldenNbirth_ExplicitNullMetric_IsNullKind() { var payload = SparkplugCodec.Decode(SparkplugGoldenPayloads.Read(SparkplugGoldenPayloads.NbirthFile)); var missing = payload.Metrics.Single(m => m.Name == "Missing"); missing.ValueKind.ShouldBe(SparkplugValueKind.Null); missing.Value.ShouldBeNull(); missing.DataType.ShouldBe(DataType.Float); } /// /// Explicit presence, case 5 — the one that makes the other four falsifiable. A publisher /// may legitimately set a metric's name to the empty string, its datatype to Unknown (0) /// and its timestamp to 0 (the Unix epoch). Those are present-and-default, and the golden /// vectors alone cannot tell a presence check from a default check because nothing in them sets /// a field to its own default. This pins the distinction directly: set-to-default must decode as /// PRESENT, unset must decode as ABSENT, on every field that carries presence. /// [Fact] public void Decode_FieldsSetToTheirOwnDefault_ArePresent_WhileUnsetFieldsAreAbsent() { var payload = new Payload(); payload.Metrics.Add(new Payload.Types.Metric { Name = string.Empty, Alias = 0UL, Datatype = (uint)DataType.Unknown, Timestamp = 0UL, IntValue = 0U, }); payload.Metrics.Add(new Payload.Types.Metric { StringValue = "unset-everything-else" }); var decoded = SparkplugCodec.Decode(payload.ToByteArray()); var present = decoded.Metrics[0]; present.Name.ShouldBe(string.Empty); present.Alias.ShouldBe(0UL); present.DataType.ShouldBe(DataType.Unknown); present.TimestampMs.ShouldBe(0UL); var absent = decoded.Metrics[1]; absent.Name.ShouldBeNull(); absent.Alias.ShouldBeNull(); absent.DataType.ShouldBeNull(); absent.TimestampMs.ShouldBeNull(); } /// A metric carrying neither a value nor is_null decodes as absent, not null. [Fact] public void Decode_MetricWithNoValueAndNoIsNull_IsAbsentKind() { var payload = new Payload { Seq = 4UL }; payload.Metrics.Add(new Payload.Types.Metric { Name = "Bare" }); var decoded = SparkplugCodec.Decode(payload.ToByteArray()); decoded.Metrics.Single().ValueKind.ShouldBe(SparkplugValueKind.Absent); decoded.Metrics.Single().Value.ShouldBeNull(); } /// /// Every scalar arm of the metric value oneof projects to a CLR value of the wire's own /// type — nothing is coerced here; coercion against the authored tag type happens downstream. /// [Fact] public void Decode_GoldenNbirth_ProjectsEveryScalarValueArm() { var payload = SparkplugCodec.Decode(SparkplugGoldenPayloads.Read(SparkplugGoldenPayloads.NbirthFile)); var byName = payload.Metrics.Where(m => m.Name is not null).ToDictionary(m => m.Name!, m => m); byName["bdSeq"].Value.ShouldBe(7UL); // long_value → ulong byName["Node Control/Rebirth"].Value.ShouldBe(false); // boolean_value → bool byName["Temperature"].Value.ShouldBe(21.5f); // float_value → float byName["Pressure"].Value.ShouldBe(101.325d); // double_value → double byName["Running"].Value.ShouldBe(true); byName["Serial"].Value.ShouldBe("EDGE-A-001"); // string_value → string byName["Count"].Value.ShouldBe(unchecked((uint)-42)); // int_value → uint, RAW } /// A bytes metric projects to a byte array rather than a protobuf ByteString. [Fact] public void Decode_BytesMetric_ProjectsToByteArray() { var payload = new Payload(); payload.Metrics.Add(new Payload.Types.Metric { Name = "Blob", Datatype = (uint)DataType.Bytes, BytesValue = ByteString.CopyFrom(1, 2, 3), }); var decoded = SparkplugCodec.Decode(payload.ToByteArray()); decoded.Metrics.Single().Value.ShouldBeOfType().ShouldBe(new byte[] { 1, 2, 3 }); } /// /// DataSet and Template metrics are out of scope for v1. They must decode to an /// explicit "unsupported" kind — never an exception on the MQTT dispatcher thread, and never a /// silently-dropped metric that looks identical to a metric that was never published. /// [Fact] public void Decode_GoldenNbirth_DataSetMetric_IsUnsupported_NotAThrow() { var payload = SparkplugCodec.Decode(SparkplugGoldenPayloads.Read(SparkplugGoldenPayloads.NbirthFile)); var config = payload.Metrics.Single(m => m.Name == "Config"); config.ValueKind.ShouldBe(SparkplugValueKind.Unsupported); config.Value.ShouldBeNull(); config.DataType.ShouldBe(DataType.DataSet); } /// A Template metric takes the same unsupported path as DataSet. [Fact] public void Decode_TemplateMetric_IsUnsupported() { var payload = new Payload(); payload.Metrics.Add(new Payload.Types.Metric { Name = "Motor", Datatype = (uint)DataType.Template, TemplateValue = new Payload.Types.Template { Version = "1", IsDefinition = false }, }); SparkplugCodec.Decode(payload.ToByteArray()).Metrics.Single().ValueKind .ShouldBe(SparkplugValueKind.Unsupported); } /// Payload and per-metric timestamps survive as raw Sparkplug epoch milliseconds. [Fact] public void Decode_GoldenVectors_PreserveTimestamps() { var nbirth = SparkplugCodec.Decode(SparkplugGoldenPayloads.Read(SparkplugGoldenPayloads.NbirthFile)); nbirth.TimestampMs.ShouldBe(SparkplugGoldenPayloads.NbirthTimestampMs); nbirth.Metrics.Single(m => m.Name == "Temperature").TimestampMs .ShouldBe(SparkplugGoldenPayloads.NbirthTimestampMs); // A metric that carried no timestamp of its own reports absence, not the payload's. nbirth.Metrics.Single(m => m.Name == "Pressure").TimestampMs.ShouldBeNull(); } /// /// Sparkplug carries signed integers up to 32 bits as two's complement in the UNSIGNED /// int_value field. The codec hands over the raw wire value; this is the helper that /// turns it back into the number the edge node meant. /// [Theory] [InlineData(DataType.Int8, -5)] [InlineData(DataType.Int16, -1234)] [InlineData(DataType.Int32, -42)] public void ReinterpretSigned_RecoversNegativeIntegersFromTheUnsignedWireField(DataType datatype, int expected) { var raw = unchecked((uint)expected); var result = SparkplugCodec.ReinterpretSigned(raw, datatype); Convert.ToInt64(result).ShouldBe(expected); } /// The 64-bit arm reinterprets long_value; the golden NBIRTH's Count is the 32-bit arm. [Fact] public void ReinterpretSigned_HandlesInt64_AndTheGoldenCountMetric() { SparkplugCodec.ReinterpretSigned(unchecked((ulong)-9_000_000_000L), DataType.Int64).ShouldBe(-9_000_000_000L); var payload = SparkplugCodec.Decode(SparkplugGoldenPayloads.Read(SparkplugGoldenPayloads.NbirthFile)); var count = payload.Metrics.Single(m => m.Name == "Count"); SparkplugCodec.ReinterpretSigned(count.Value, count.DataType!.Value) .ShouldBe(SparkplugGoldenPayloads.CountBirthValue); } /// Unsigned and non-integer datatypes pass through untouched. [Fact] public void ReinterpretSigned_LeavesUnsignedAndNonIntegerValuesAlone() { SparkplugCodec.ReinterpretSigned(7U, DataType.Uint32).ShouldBe(7U); SparkplugCodec.ReinterpretSigned(21.5f, DataType.Float).ShouldBe(21.5f); SparkplugCodec.ReinterpretSigned("text", DataType.String).ShouldBe("text"); SparkplugCodec.ReinterpretSigned(null, DataType.Int32).ShouldBeNull(); } /// /// A datatype index this build's enum does not define (a future Sparkplug revision, or a /// misbehaving publisher) is preserved verbatim rather than dropped or coerced to Unknown — /// the mapping layer decides what to do with it, and it cannot decide about data it never saw. /// [Fact] public void Decode_UnrecognisedDataTypeIndex_IsPreservedVerbatim() { var payload = new Payload(); payload.Metrics.Add(new Payload.Types.Metric { Name = "Future", Datatype = 250U, IntValue = 1U }); var decoded = SparkplugCodec.Decode(payload.ToByteArray()); ((int)decoded.Metrics.Single().DataType!.Value).ShouldBe(250); } /// /// Never throws, case 1. Zero-length input is not "a payload with no metrics" — it is no /// answer at all, and is reported as invalid so a truncated-to-nothing body cannot pass as a /// well-formed message with an empty metric list. /// [Fact] public void TryDecode_EmptyInput_IsInvalid_AndDoesNotThrow() { SparkplugCodec.TryDecode([], out var payload).ShouldBeFalse(); payload.IsValid.ShouldBeFalse(); payload.Metrics.ShouldBeEmpty(); payload.Seq.ShouldBeNull(); } /// /// Never throws, case 2. Garbage bytes: a decoder that let an exception escape here would /// take down the MQTT dispatcher thread — every subscription on the connection, not one tag. /// [Fact] public void TryDecode_GarbageBytes_NeverThrows() { var random = new Random(20260724); for (var i = 0; i < 2000; i++) { var bytes = new byte[random.Next(1, 64)]; random.NextBytes(bytes); // The verdict is irrelevant — some random byte strings ARE valid protobuf. Not throwing is // the whole assertion. Should.NotThrow(() => SparkplugCodec.TryDecode(bytes, out _)); } } /// /// Never throws, case 3. Every truncation of a real payload — the shape a dropped /// connection or a size-capped broker actually produces. /// [Fact] public void TryDecode_EveryTruncationOfAGoldenVector_NeverThrows() { foreach (var file in new[] { SparkplugGoldenPayloads.NbirthFile, SparkplugGoldenPayloads.NdataFile }) { var full = SparkplugGoldenPayloads.Read(file); for (var length = 0; length <= full.Length; length++) { var prefix = full.AsSpan(0, length).ToArray(); Should.NotThrow(() => SparkplugCodec.TryDecode(prefix, out _)); } } } /// /// Never throws, case 4. A valid protobuf message that is not a Sparkplug payload. Protobuf /// is permissive, so this may well parse (as unknown fields) — what matters is that it neither /// throws nor invents metrics. /// [Fact] public void TryDecode_ValidProtobufThatIsNotASparkplugPayload_NeverThrows() { // A DataSet message, serialized standalone — legal protobuf, wrong schema. var alien = new Payload.Types.DataSet { NumOfColumns = 3UL }; alien.Columns.Add("a"); Should.NotThrow(() => SparkplugCodec.TryDecode(alien.ToByteArray(), out _)); } /// /// Never throws, case 5. A deeply nested Template — protobuf's recursion limit rejects it, /// and the rejection must arrive as a verdict rather than as an exception. /// [Fact] public void TryDecode_PathologicallyNestedTemplate_NeverThrows() { var template = new Payload.Types.Template { Version = "1" }; for (var depth = 0; depth < 200; depth++) { var outer = new Payload.Types.Template { Version = "1" }; outer.Metrics.Add(new Payload.Types.Metric { Name = "n", TemplateValue = template }); template = outer; } var payload = new Payload(); payload.Metrics.Add(new Payload.Types.Metric { Name = "Deep", TemplateValue = template }); Should.NotThrow(() => SparkplugCodec.TryDecode(payload.ToByteArray(), out _)); } /// /// is the convenience shape the plan's call sites use: it /// never returns and never throws, reporting failure through /// . /// [Fact] public void Decode_OnUndecodableInput_ReturnsTheInvalidPayload_RatherThanThrowing() { var payload = SparkplugCodec.Decode([]); payload.ShouldNotBeNull(); payload.IsValid.ShouldBeFalse(); payload.Metrics.ShouldBeEmpty(); } }