2589774480
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
88 lines
4.4 KiB
C#
88 lines
4.4 KiB
C#
using System.Runtime.CompilerServices;
|
|
using Google.Protobuf;
|
|
using Shouldly;
|
|
using Xunit;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests;
|
|
|
|
/// <summary>
|
|
/// Guards the committed <c>Golden/*.bin</c> Sparkplug-B wire vectors: they must still be exactly
|
|
/// what <see cref="SparkplugGoldenPayloads"/> produces, and they must actually reach the test
|
|
/// binary's output directory.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// Without the drift guard the vectors would be write-once decoration — a re-vendored proto,
|
|
/// a protoc upgrade, or an edited builder could change the encoding while every decode test
|
|
/// kept passing against stale bytes. With it, the committed files are a genuine pin on the
|
|
/// wire format the driver is expected to read.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>To regenerate</b> (only after a deliberate, reviewed change to the vectors):
|
|
/// <c>OTOPCUA_SPARKPLUG_GOLDEN_REGEN=1 dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests
|
|
/// --filter "FullyQualifiedName~SparkplugGoldenVectorTests"</c>, then review and commit the
|
|
/// resulting <c>.bin</c> diff.
|
|
/// </para>
|
|
/// </remarks>
|
|
public sealed class SparkplugGoldenVectorTests
|
|
{
|
|
private const string RegenEnvVar = "OTOPCUA_SPARKPLUG_GOLDEN_REGEN";
|
|
|
|
/// <summary>The committed NBIRTH vector is byte-identical to its builder's output.</summary>
|
|
[Fact]
|
|
public void Nbirth_CommittedBytes_MatchTheBuilder() =>
|
|
AssertMatchesBuilder(SparkplugGoldenPayloads.NbirthFile, SparkplugGoldenPayloads.BuildNbirth().ToByteArray());
|
|
|
|
/// <summary>The committed NDATA vector is byte-identical to its builder's output.</summary>
|
|
[Fact]
|
|
public void Ndata_CommittedBytes_MatchTheBuilder() =>
|
|
AssertMatchesBuilder(SparkplugGoldenPayloads.NdataFile, SparkplugGoldenPayloads.BuildNdata().ToByteArray());
|
|
|
|
/// <summary>
|
|
/// Both vectors are present in the test binary's output directory — i.e. the csproj really does
|
|
/// copy <c>Golden/**</c>. A missing copy item is invisible in source and turns every decode test
|
|
/// into a <see cref="FileNotFoundException"/> at runtime.
|
|
/// </summary>
|
|
[Fact]
|
|
public void GoldenVectors_AreCopiedToTheOutputDirectory()
|
|
{
|
|
File.Exists(SparkplugGoldenPayloads.PathTo(SparkplugGoldenPayloads.NbirthFile)).ShouldBeTrue();
|
|
File.Exists(SparkplugGoldenPayloads.PathTo(SparkplugGoldenPayloads.NdataFile)).ShouldBeTrue();
|
|
}
|
|
|
|
/// <summary>Compares a committed vector to freshly built bytes, regenerating first when asked to.</summary>
|
|
/// <param name="fileName">The vector's file name.</param>
|
|
/// <param name="built">The bytes the builder produces now.</param>
|
|
private static void AssertMatchesBuilder(string fileName, byte[] built)
|
|
{
|
|
if (Environment.GetEnvironmentVariable(RegenEnvVar) == "1")
|
|
{
|
|
// Write to BOTH the source tree (so the change can be committed) and the output directory
|
|
// (so the assertion below reads what was just written rather than a stale copied file).
|
|
var source = Path.Combine(SourceDirectory(), SparkplugGoldenPayloads.Directory, fileName);
|
|
System.IO.Directory.CreateDirectory(Path.GetDirectoryName(source)!);
|
|
File.WriteAllBytes(source, built);
|
|
|
|
var output = SparkplugGoldenPayloads.PathTo(fileName);
|
|
System.IO.Directory.CreateDirectory(Path.GetDirectoryName(output)!);
|
|
File.WriteAllBytes(output, built);
|
|
}
|
|
|
|
var path = SparkplugGoldenPayloads.PathTo(fileName);
|
|
File.Exists(path).ShouldBeTrue(
|
|
$"Golden vector '{fileName}' is missing from the output directory. If the csproj copy item is "
|
|
+ $"intact, regenerate with {RegenEnvVar}=1.");
|
|
|
|
File.ReadAllBytes(path).ShouldBe(
|
|
built,
|
|
$"Golden vector '{fileName}' no longer matches SparkplugGoldenPayloads. Either the builder or "
|
|
+ $"the vendored proto changed. If the change is intended, regenerate with {RegenEnvVar}=1 and "
|
|
+ "commit the .bin diff.");
|
|
}
|
|
|
|
/// <summary>This file's own source directory, resolved at compile time.</summary>
|
|
/// <param name="path">Supplied by the compiler; never pass it.</param>
|
|
/// <returns>The directory holding the test sources.</returns>
|
|
private static string SourceDirectory([CallerFilePath] string path = "") => Path.GetDirectoryName(path)!;
|
|
}
|