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();
}
}