Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/SparkplugCodecTests.cs
T
Joseph Doherty 2589774480 feat(mqtt): SparkplugCodec decode + golden payload vectors
Decodes Sparkplug-B wire bytes into a driver-side projection (SparkplugPayload /
SparkplugMetric / SparkplugValueKind) for the Task 21 ingest state machine.

- Never throws, for ANY input. It sits on MQTTnet's shared dispatcher thread, so
  an escaping exception would stall delivery for every subscription on the
  connection, not one tag. Garbage, truncation, zero-length, a valid protobuf of
  another schema and an over-nested Template all resolve to a verdict.
- Zero-length input is INVALID, not "a payload with no metrics" — protobuf would
  parse it as all-defaults, and that is how a truncated-to-nothing body gets
  mistaken for a well-formed one.
- Explicit presence throughout (Has{Seq,Name,Alias,Datatype,Timestamp}), never a
  zero-check: an NBIRTH legitimately carries seq = 0, and every DATA metric after
  a birth carries an alias with no name and no datatype.
- Values are projected RAW, boxed, with the value oneof reported as an explicit
  ValueKind — Absent / Null / Scalar / Unsupported all mean different things and
  three of them carry a null value. DataSet/Template/extension decode as
  Unsupported (v1 scope) rather than throwing or silently vanishing.
- ReinterpretSigned() undoes Sparkplug's two's-complement-in-an-unsigned-field
  encoding of Int8/16/32/64. Kept out of decode because a DATA metric carries no
  datatype — only the consumer, holding the birth's alias table, knows which
  applies. Skipping it publishes 4294967254 for a tag whose value is -42.
- Datatype is carried as the generated Org.Eclipse.Tahu.Protobuf.DataType; the
  SparkplugDataType map is Task 17's and is applied downstream (Task 21).

Golden vectors: nbirth.bin (seq=0, named/aliased catalog, a negative Int32, an
is_null metric, a DataSet metric) + ndata.bin (alias-only metrics, no name, no
datatype) are hand-built by SparkplugGoldenPayloads, committed, and pinned by a
drift guard that rebuilds and byte-compares them on every run — plus a test that
they actually reach the output directory, since a missing copy item is invisible
in source.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 21:09:14 -04:00

393 lines
16 KiB
C#

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;
/// <summary>
/// Covers <see cref="SparkplugCodec"/>: 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.
/// </summary>
public sealed class SparkplugCodecTests
{
/// <summary>
/// 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.
/// </summary>
[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));
}
/// <summary>
/// <b>Explicit presence, case 1.</b> A Sparkplug NBIRTH is REQUIRED to carry <c>seq = 0</c>, 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.
/// </summary>
[Fact]
public void Decode_GoldenNbirth_SeqZero_IsPresentNotAbsent()
{
var payload = SparkplugCodec.Decode(SparkplugGoldenPayloads.Read(SparkplugGoldenPayloads.NbirthFile));
payload.Seq.HasValue.ShouldBeTrue();
payload.Seq!.Value.ShouldBe(0UL);
}
/// <summary>
/// <b>Explicit presence, case 2.</b> A payload that genuinely omits <c>seq</c> must report
/// absence, not zero — the mirror image of the test above, and the pair is what makes the
/// distinction falsifiable rather than incidental.
/// </summary>
[Fact]
public void Decode_PayloadWithoutSeq_ReportsAbsentSeq()
{
var wire = new Payload { Timestamp = 1UL }.ToByteArray();
SparkplugCodec.Decode(wire).Seq.ShouldBeNull();
}
/// <summary>
/// <b>Explicit presence, case 3.</b> Every real Sparkplug DATA metric omits <c>name</c> and
/// <c>datatype</c> and carries only its alias. Reporting an absent name as the empty string
/// would make it indistinguishable from a (malformed) metric genuinely named "".
/// </summary>
[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));
}
/// <summary>
/// <b>Explicit presence, case 4.</b> "The metric's value is null" (<c>is_null</c>) is a different
/// fact from "the metric carried no value field", and both differ from "the value is zero".
/// </summary>
[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);
}
/// <summary>
/// <b>Explicit presence, case 5 — the one that makes the other four falsifiable.</b> A publisher
/// may legitimately set a metric's name to the empty string, its datatype to <c>Unknown</c> (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.
/// </summary>
[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();
}
/// <summary>A metric carrying neither a value nor <c>is_null</c> decodes as absent, not null.</summary>
[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();
}
/// <summary>
/// Every scalar arm of the metric value <c>oneof</c> projects to a CLR value of the wire's own
/// type — nothing is coerced here; coercion against the authored tag type happens downstream.
/// </summary>
[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
}
/// <summary>A <c>bytes</c> metric projects to a byte array rather than a protobuf <c>ByteString</c>.</summary>
[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<byte[]>().ShouldBe(new byte[] { 1, 2, 3 });
}
/// <summary>
/// <c>DataSet</c> and <c>Template</c> 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.
/// </summary>
[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);
}
/// <summary>A <c>Template</c> metric takes the same unsupported path as <c>DataSet</c>.</summary>
[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);
}
/// <summary>Payload and per-metric timestamps survive as raw Sparkplug epoch milliseconds.</summary>
[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();
}
/// <summary>
/// Sparkplug carries signed integers up to 32 bits as two's complement in the UNSIGNED
/// <c>int_value</c> field. The codec hands over the raw wire value; this is the helper that
/// turns it back into the number the edge node meant.
/// </summary>
[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);
}
/// <summary>The 64-bit arm reinterprets <c>long_value</c>; the golden NBIRTH's Count is the 32-bit arm.</summary>
[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);
}
/// <summary>Unsigned and non-integer datatypes pass through untouched.</summary>
[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();
}
/// <summary>
/// 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.
/// </summary>
[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);
}
/// <summary>
/// <b>Never throws, case 1.</b> 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.
/// </summary>
[Fact]
public void TryDecode_EmptyInput_IsInvalid_AndDoesNotThrow()
{
SparkplugCodec.TryDecode([], out var payload).ShouldBeFalse();
payload.IsValid.ShouldBeFalse();
payload.Metrics.ShouldBeEmpty();
payload.Seq.ShouldBeNull();
}
/// <summary>
/// <b>Never throws, case 2.</b> 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.
/// </summary>
[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 _));
}
}
/// <summary>
/// <b>Never throws, case 3.</b> Every truncation of a real payload — the shape a dropped
/// connection or a size-capped broker actually produces.
/// </summary>
[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 _));
}
}
}
/// <summary>
/// <b>Never throws, case 4.</b> 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.
/// </summary>
[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 _));
}
/// <summary>
/// <b>Never throws, case 5.</b> A deeply nested Template — protobuf's recursion limit rejects it,
/// and the rejection must arrive as a verdict rather than as an exception.
/// </summary>
[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 _));
}
/// <summary>
/// <see cref="SparkplugCodec.Decode"/> is the convenience shape the plan's call sites use: it
/// never returns <see langword="null"/> and never throws, reporting failure through
/// <see cref="SparkplugPayload.IsValid"/>.
/// </summary>
[Fact]
public void Decode_OnUndecodableInput_ReturnsTheInvalidPayload_RatherThanThrowing()
{
var payload = SparkplugCodec.Decode([]);
payload.ShouldNotBeNull();
payload.IsValid.ShouldBeFalse();
payload.Metrics.ShouldBeEmpty();
}
}