Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/SparkplugProtoCodegenTests.cs
T
Joseph Doherty 1ae26675ac feat(mqtt): vendor Tahu sparkplug_b.proto + Grpc.Tools codegen
Vendors the NORMATIVE (proto2) Eclipse Tahu sparkplug_b.proto and compiles it
in .Contracts with build-time Grpc.Tools, message-only (GrpcServices="None") --
Sparkplug rides MQTT, there is no gRPC service, so no Grpc.Core.Api is pulled in.

Provenance rides in the file header: repo, path, pinned commit
5736e404889d4b95910613040a99ba79589ffb13, permalink, git blob SHA-1
bf72ab5f..., content SHA-256 4432c5c4..., EPL-2.0, and a re-verify recipe. The
body below line 38 is byte-for-byte upstream; the blob SHA-1 matches the tree
entry at that commit, so the copy is provably genuine and not reconstructed.

Chose the proto2 file over upstream's sibling sparkplug_b_c_sharp.proto: the
sibling is a lossy proto3 restatement that drops explicit presence, and protoc
generates valid C# from proto2 into the identical namespace
(Org.Eclipse.Tahu.Protobuf, PascalCased from the package -- no
csharp_namespace option). Presence matters downstream: an NBIRTH legitimately
carries seq = 0 and a DATA metric legitimately omits `name`, so Has{Seq,Name,
Alias,IsNull,Datatype} is the difference between "absent" and "present, zero".

.Contracts stays transport-free: Google.Protobuf is a serialization dependency
with a framework-only graph, Grpc.Tools is PrivateAssets=all, and the resolved
graph is exactly {Google.Protobuf, Grpc.Tools, Core.Abstractions} -- no MQTTnet.

Codegen tests pin the round-trip, the namespace, the Sparkplug field numbers,
the DataType spec indices, and protoc's enum-name mangling (UInt64 -> Uint64,
UUID -> Uuid) that Task 17's datatype map has to spell correctly.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 20:54:23 -04:00

101 lines
4.3 KiB
C#

using Google.Protobuf;
using Org.Eclipse.Tahu.Protobuf;
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests;
/// <summary>
/// Pins the vendored Eclipse Tahu <c>sparkplug_b</c> protobuf codegen: the generated types exist,
/// land in the namespace the rest of P2 is written against, and carry the Sparkplug-B field numbers
/// on the wire. Nothing here decodes a real payload — that is Task 16 (<c>SparkplugCodec</c> +
/// golden vectors); this suite exists so a broken/renamed/mangled vendored proto fails at the
/// codegen seam rather than as a mysterious decode failure three tasks later.
/// </summary>
public sealed class SparkplugProtoCodegenTests
{
[Fact]
public void GeneratedPayload_RoundTrips()
{
var p = new Payload { Seq = 3 };
p.Metrics.Add(new Payload.Types.Metric { Name = "Temperature", Alias = 5 });
var back = Payload.Parser.ParseFrom(p.ToByteArray());
back.Seq.ShouldBe(3ul);
back.Metrics[0].Name.ShouldBe("Temperature");
back.Metrics[0].Alias.ShouldBe(5ul);
}
/// <summary>
/// Tasks 16 / 20 / 25 name <c>Org.Eclipse.Tahu.Protobuf</c> explicitly, and the namespace is
/// derived (not declared) — protoc PascalCases the proto <c>package</c> because the vendored file
/// carries no <c>option csharp_namespace</c>. Editing the package line to "tidy" it would silently
/// rehome every generated type.
/// </summary>
[Fact]
public void GeneratedTypes_LiveInTheTahuPackageNamespace()
{
typeof(Payload).Namespace.ShouldBe("Org.Eclipse.Tahu.Protobuf");
Payload.Descriptor.File.Package.ShouldBe("org.eclipse.tahu.protobuf");
}
/// <summary>
/// Field numbers are the wire contract with every Sparkplug edge node in the plant; a vendored
/// proto that was hand-edited or reconstructed rather than copied from upstream would most likely
/// drift here first.
/// </summary>
[Fact]
public void PayloadAndMetric_CarrySparkplugBFieldNumbers()
{
Payload.TimestampFieldNumber.ShouldBe(1);
Payload.MetricsFieldNumber.ShouldBe(2);
Payload.SeqFieldNumber.ShouldBe(3);
Payload.UuidFieldNumber.ShouldBe(4);
Payload.BodyFieldNumber.ShouldBe(5);
Payload.Types.Metric.NameFieldNumber.ShouldBe(1);
Payload.Types.Metric.AliasFieldNumber.ShouldBe(2);
Payload.Types.Metric.TimestampFieldNumber.ShouldBe(3);
Payload.Types.Metric.DatatypeFieldNumber.ShouldBe(4);
Payload.Types.Metric.IsNullFieldNumber.ShouldBe(7);
}
/// <summary>
/// Task 17's datatype map keys off these indices. They are Sparkplug-spec constants, not protobuf
/// tags, so they are worth pinning independently of the field numbers above.
/// </summary>
[Fact]
public void DataTypeEnum_MatchesSparkplugSpecIndices()
{
((int)DataType.Int8).ShouldBe(1);
((int)DataType.Int32).ShouldBe(3);
((int)DataType.Uint64).ShouldBe(8);
((int)DataType.Float).ShouldBe(9);
((int)DataType.Double).ShouldBe(10);
((int)DataType.Boolean).ShouldBe(11);
((int)DataType.String).ShouldBe(12);
((int)DataType.DateTime).ShouldBe(13);
((int)DataType.DataSet).ShouldBe(16);
((int)DataType.Template).ShouldBe(19);
((int)DataType.Int32Array).ShouldBe(24);
}
/// <summary>
/// protoc's C# generator RE-CASES enum member names: the proto's <c>UInt64</c> becomes
/// <c>Uint64</c> and <c>UUID</c> becomes <c>Uuid</c> (only the leading character of each
/// underscore-separated word survives as a capital). Task 17's datatype map has to spell them
/// the mangled way, so pin the mangling rather than letting it be rediscovered as a build error.
/// The wire-facing spelling is preserved on <c>OriginalName</c>, which is what
/// <c>DataType.Parser</c>-style JSON round-trips use.
/// </summary>
[Fact]
public void DataTypeEnum_CSharpNamesAreProtocMangled_NotTheProtoSpelling()
{
Enum.GetNames<DataType>().ShouldContain("Uint64");
Enum.GetNames<DataType>().ShouldNotContain("UInt64");
Enum.GetNames<DataType>().ShouldContain("Uuid");
Enum.GetNames<DataType>().ShouldNotContain("UUID");
}
}