feat(mqtt): Sparkplug topic parse/format + datatype map
SparkplugTopic (Driver.Mqtt/Sparkplug/) parses/formats spBv1.0 topics for
every message type (NBIRTH/DBIRTH/NDATA/DDATA/NDEATH/DDEATH/NCMD/DCMD/STATE).
TryParse never throws -- it is fed every topic a live spBv1.0/{group}/#
subscription delivers -- and rejects device/node-scope mismatches and MQTT
wildcard chars in a segment. STATE is handled honestly rather than force-fit
into the {group}/{type}/{node} mould: it parses both the v3.0
spBv1.0/STATE/{hostId} form and the legacy no-prefix STATE/{hostId} form
(tolerated on receive only -- Format/FormatState always emit v3.0). Format
gives Task 20's NCMD/DCMD write path a builder instead of hand-concatenation.
SparkplugDataType is a `global using` alias for the vendored proto's
generated Org.Eclipse.Tahu.Protobuf.DataType, not a second hand-duplicated
enum -- Metric.Datatype is a raw wire uint32 (no enum-typed field forces a
second CLR type to exist), and SparkplugCodec (Task 16, landed concurrently)
already casts straight to the generated type. A duplicate enum would be the
same enum-drift hazard this repo already names systemic (CLAUDE.md's driver
enum-serialization bug) and would force every downstream task to cast
between two value-compatible-but-nominally-different enums. ToDriverDataType()
maps per design doc SS3.5: Int8/UInt8 widen to Int16/UInt16 (no 8-bit
DriverDataType member), Float/Double to Float32/Float64 (there is no
DriverDataType.Double), Text/UUID/Bytes/File to String, *Array variants to
their element type (IsSparkplugArray carries the array bit separately), and
DataSet/Template/PropertySet/PropertySetList/Unknown return null -- an
explicit "unsupported, caller must skip+warn" rather than a guessed String.
The completeness test enumerates the live generated DataType member set
(via the alias) and asserts every member is mapped or on the explicit
unsupported list, so a future Tahu proto change is caught automatically
instead of silently falling through a stale duplicate enum's default.
Falsifiability verified by hand for three defect shapes (each reverted after
observing RED): wrong Int8 widening, a dropped mapping falling through
undetected, and inverted node/device topic scoping.
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
This commit is contained in:
@@ -0,0 +1,127 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
// See SparkplugTopicTests.cs for why this alias is redeclared locally in every consuming project
|
||||
// rather than inherited: `SparkplugDataType` is a `global using` alias for the generated
|
||||
// `Org.Eclipse.Tahu.Protobuf.DataType` (declared in Contracts/SparkplugDataType.cs), and `global using`
|
||||
// scope does not cross a ProjectReference.
|
||||
using SparkplugDataType = Org.Eclipse.Tahu.Protobuf.DataType;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="SparkplugDataTypeExtensions.ToDriverDataType"/> / <see cref="SparkplugDataTypeExtensions.IsSparkplugArray"/>
|
||||
/// coverage (Task 17), per design doc §3.5. <see cref="ToDriverDataType_HandlesEveryGeneratedDataTypeMember_MappedOrExplicitlyUnsupported"/>
|
||||
/// is the drift guard: because <c>SparkplugDataType</c> is an alias for the vendored proto's
|
||||
/// generated enum (not a hand-duplicated copy — see the remarks on
|
||||
/// <see cref="SparkplugDataTypeExtensions"/>), enumerating it enumerates the live generated member
|
||||
/// set, so a future Eclipse Tahu proto change that adds/removes a <c>DataType</c> member is caught
|
||||
/// here automatically rather than needing a second enum kept manually in sync.
|
||||
/// </summary>
|
||||
public sealed class SparkplugDataTypeTests
|
||||
{
|
||||
/// <summary>Scalar (non-array) datatypes, per the design §3.5 table.</summary>
|
||||
[Theory]
|
||||
[InlineData(SparkplugDataType.Int8, DriverDataType.Int16)] // widened: no 8-bit DriverDataType member
|
||||
[InlineData(SparkplugDataType.Int16, DriverDataType.Int16)]
|
||||
[InlineData(SparkplugDataType.Int32, DriverDataType.Int32)]
|
||||
[InlineData(SparkplugDataType.Int64, DriverDataType.Int64)]
|
||||
[InlineData(SparkplugDataType.Uint8, DriverDataType.UInt16)] // widened
|
||||
[InlineData(SparkplugDataType.Uint16, DriverDataType.UInt16)]
|
||||
[InlineData(SparkplugDataType.Uint32, DriverDataType.UInt32)]
|
||||
[InlineData(SparkplugDataType.Uint64, DriverDataType.UInt64)]
|
||||
[InlineData(SparkplugDataType.Float, DriverDataType.Float32)]
|
||||
[InlineData(SparkplugDataType.Double, DriverDataType.Float64)] // NOT DriverDataType.Double — no such member
|
||||
[InlineData(SparkplugDataType.Boolean, DriverDataType.Boolean)]
|
||||
[InlineData(SparkplugDataType.String, DriverDataType.String)]
|
||||
[InlineData(SparkplugDataType.DateTime, DriverDataType.DateTime)]
|
||||
[InlineData(SparkplugDataType.Text, DriverDataType.String)]
|
||||
[InlineData(SparkplugDataType.Uuid, DriverDataType.String)] // generated name for the proto's `UUID`
|
||||
[InlineData(SparkplugDataType.Bytes, DriverDataType.String)] // v1 base64/raw fallback
|
||||
[InlineData(SparkplugDataType.File, DriverDataType.String)] // v1 base64/raw fallback
|
||||
public void ToDriverDataType_MapsScalarTypes(SparkplugDataType s, DriverDataType d)
|
||||
=> s.ToDriverDataType().ShouldBe(d);
|
||||
|
||||
/// <summary>
|
||||
/// <c>*Array</c> variants map to their scalar element type; <see cref="SparkplugDataTypeExtensions.IsSparkplugArray"/>
|
||||
/// carries the "and it's an array" bit, since <see cref="DriverDataType"/> has no array concept
|
||||
/// of its own (that's an OPC UA ValueRank/ArrayDimensions concern owned further up the stack).
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(SparkplugDataType.Int8Array, DriverDataType.Int16)]
|
||||
[InlineData(SparkplugDataType.Int16Array, DriverDataType.Int16)]
|
||||
[InlineData(SparkplugDataType.Int32Array, DriverDataType.Int32)]
|
||||
[InlineData(SparkplugDataType.Int64Array, DriverDataType.Int64)]
|
||||
[InlineData(SparkplugDataType.Uint8Array, DriverDataType.UInt16)]
|
||||
[InlineData(SparkplugDataType.Uint16Array, DriverDataType.UInt16)]
|
||||
[InlineData(SparkplugDataType.Uint32Array, DriverDataType.UInt32)]
|
||||
[InlineData(SparkplugDataType.Uint64Array, DriverDataType.UInt64)]
|
||||
[InlineData(SparkplugDataType.FloatArray, DriverDataType.Float32)]
|
||||
[InlineData(SparkplugDataType.DoubleArray, DriverDataType.Float64)]
|
||||
[InlineData(SparkplugDataType.BooleanArray, DriverDataType.Boolean)]
|
||||
[InlineData(SparkplugDataType.StringArray, DriverDataType.String)]
|
||||
[InlineData(SparkplugDataType.DateTimeArray, DriverDataType.DateTime)]
|
||||
public void ToDriverDataType_ArrayVariants_MapToElementType_AndFlagIsArray(SparkplugDataType s, DriverDataType d)
|
||||
{
|
||||
s.ToDriverDataType().ShouldBe(d);
|
||||
s.IsSparkplugArray().ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(SparkplugDataType.Int32)]
|
||||
[InlineData(SparkplugDataType.Boolean)]
|
||||
[InlineData(SparkplugDataType.String)]
|
||||
[InlineData(SparkplugDataType.Bytes)]
|
||||
public void IsSparkplugArray_ScalarTypes_ReturnsFalse(SparkplugDataType s)
|
||||
=> s.IsSparkplugArray().ShouldBeFalse();
|
||||
|
||||
/// <summary>
|
||||
/// Design §3.5: "DataSet, Template → unsupported v1". Extended here to the two PropertyValue-only
|
||||
/// variants and the proto's explicit placeholder — all five must come back <see langword="null"/>,
|
||||
/// never a guessed <see cref="DriverDataType.String"/>.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(SparkplugDataType.Unknown)]
|
||||
[InlineData(SparkplugDataType.DataSet)]
|
||||
[InlineData(SparkplugDataType.Template)]
|
||||
[InlineData(SparkplugDataType.PropertySet)]
|
||||
[InlineData(SparkplugDataType.PropertySetList)]
|
||||
public void ToDriverDataType_UnsupportedTypes_ReturnsNull_NotAGuess(SparkplugDataType s)
|
||||
=> s.ToDriverDataType().ShouldBeNull();
|
||||
|
||||
/// <summary>
|
||||
/// The drift guard (see class remarks). Enumerates the live generated <c>DataType</c> member
|
||||
/// set (via the <c>SparkplugDataType</c> alias) and asserts every member is either mapped to a
|
||||
/// <see cref="DriverDataType"/> or is one of the five documented-unsupported members — nothing
|
||||
/// silently falls through the map's <c>_ => null</c> default undetected.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ToDriverDataType_HandlesEveryGeneratedDataTypeMember_MappedOrExplicitlyUnsupported()
|
||||
{
|
||||
var unsupported = new HashSet<SparkplugDataType>
|
||||
{
|
||||
SparkplugDataType.Unknown,
|
||||
SparkplugDataType.DataSet,
|
||||
SparkplugDataType.Template,
|
||||
SparkplugDataType.PropertySet,
|
||||
SparkplugDataType.PropertySetList,
|
||||
};
|
||||
|
||||
var allMembers = Enum.GetValues<SparkplugDataType>();
|
||||
allMembers.Length.ShouldBe(35, "this pins the generated member count Task 15 vendored; a changed count means the proto changed and every assertion below needs re-auditing.");
|
||||
|
||||
foreach (var value in allMembers)
|
||||
{
|
||||
var mapped = value.ToDriverDataType();
|
||||
if (unsupported.Contains(value))
|
||||
{
|
||||
mapped.ShouldBeNull($"{value} is documented unsupported (design §3.5) and must map to null, not a guessed DriverDataType.");
|
||||
}
|
||||
else
|
||||
{
|
||||
mapped.ShouldNotBeNull($"{value} has no ToDriverDataType() mapping — every generated DataType member must be mapped or explicitly listed as unsupported.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Sparkplug;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
// Local alias so this file can spell the mapping table the way design doc §3.5 does
|
||||
// (`SparkplugDataType.Int8`, etc.) — `SparkplugDataType` is a `global using` alias for the generated
|
||||
// `Org.Eclipse.Tahu.Protobuf.DataType` declared in `SparkplugDataType.cs` (Contracts project); that
|
||||
// `global using` is scoped to the project that declares it and does not cross the ProjectReference
|
||||
// into this test project, so it is redeclared here, locally, rather than duplicating the enum itself.
|
||||
// See the remarks on `SparkplugDataTypeExtensions` for the full alias-vs-duplicate rationale.
|
||||
using SparkplugDataType = Org.Eclipse.Tahu.Protobuf.DataType;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="SparkplugTopic"/> parse/format coverage (Task 17). <see cref="SparkplugTopic.TryParse"/>
|
||||
/// is fed every topic string a live <c>spBv1.0/{group}/#</c> subscription delivers, so a large
|
||||
/// share of this suite is "garbage in, <see langword="false"/> out, never a throw" — see
|
||||
/// <see cref="TryParse_NeverThrows_ForArbitraryInput"/>.
|
||||
/// </summary>
|
||||
public sealed class SparkplugTopicTests
|
||||
{
|
||||
[Fact]
|
||||
public void Parse_DeviceData_ExtractsAllSegments()
|
||||
{
|
||||
var t = SparkplugTopic.Parse("spBv1.0/Plant1/DDATA/EdgeA/Filler1");
|
||||
|
||||
t.GroupId.ShouldBe("Plant1");
|
||||
t.Type.ShouldBe(SparkplugMessageType.DDATA);
|
||||
t.EdgeNodeId.ShouldBe("EdgeA");
|
||||
t.DeviceId.ShouldBe("Filler1");
|
||||
t.HostId.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_NodeData_HasNoDeviceSegment()
|
||||
{
|
||||
var t = SparkplugTopic.Parse("spBv1.0/Plant1/NDATA/EdgeA");
|
||||
|
||||
t.GroupId.ShouldBe("Plant1");
|
||||
t.Type.ShouldBe(SparkplugMessageType.NDATA);
|
||||
t.EdgeNodeId.ShouldBe("EdgeA");
|
||||
t.DeviceId.ShouldBeNull();
|
||||
t.HostId.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("spBv1.0/Plant1/NBIRTH/EdgeA", SparkplugMessageType.NBIRTH)]
|
||||
[InlineData("spBv1.0/Plant1/NDATA/EdgeA", SparkplugMessageType.NDATA)]
|
||||
[InlineData("spBv1.0/Plant1/NDEATH/EdgeA", SparkplugMessageType.NDEATH)]
|
||||
[InlineData("spBv1.0/Plant1/NCMD/EdgeA", SparkplugMessageType.NCMD)]
|
||||
public void Parse_NodeScopedTypes_Recognised(string topic, SparkplugMessageType expected)
|
||||
=> SparkplugTopic.Parse(topic).Type.ShouldBe(expected);
|
||||
|
||||
[Theory]
|
||||
[InlineData("spBv1.0/Plant1/DBIRTH/EdgeA/Filler1", SparkplugMessageType.DBIRTH)]
|
||||
[InlineData("spBv1.0/Plant1/DDATA/EdgeA/Filler1", SparkplugMessageType.DDATA)]
|
||||
[InlineData("spBv1.0/Plant1/DDEATH/EdgeA/Filler1", SparkplugMessageType.DDEATH)]
|
||||
[InlineData("spBv1.0/Plant1/DCMD/EdgeA/Filler1", SparkplugMessageType.DCMD)]
|
||||
public void Parse_DeviceScopedTypes_Recognised(string topic, SparkplugMessageType expected)
|
||||
=> SparkplugTopic.Parse(topic).Type.ShouldBe(expected);
|
||||
|
||||
[Fact]
|
||||
public void Parse_V3StateForm_ExtractsHostId()
|
||||
{
|
||||
var t = SparkplugTopic.Parse("spBv1.0/STATE/otopcua-host-1");
|
||||
|
||||
t.Type.ShouldBe(SparkplugMessageType.STATE);
|
||||
t.HostId.ShouldBe("otopcua-host-1");
|
||||
t.GroupId.ShouldBeNull();
|
||||
t.EdgeNodeId.ShouldBeNull();
|
||||
t.DeviceId.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_LegacyStateForm_ExtractsHostId()
|
||||
{
|
||||
// Pre-3.0 peers publish `STATE/{hostId}` with no `spBv1.0` namespace prefix — tolerated on
|
||||
// receive per design §3.1, even though this driver only ever *emits* the v3.0 form.
|
||||
var t = SparkplugTopic.Parse("STATE/otopcua-host-1");
|
||||
|
||||
t.Type.ShouldBe(SparkplugMessageType.STATE);
|
||||
t.HostId.ShouldBe("otopcua-host-1");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData("not/a/sparkplug/topic/at/all/too/many/segments")]
|
||||
[InlineData("spBv1.0")]
|
||||
[InlineData("spBv1.0/")]
|
||||
[InlineData("spBv1.0/Plant1")]
|
||||
[InlineData("spBv1.0/Plant1/BOGUS/EdgeA")]
|
||||
[InlineData("spBv1.0/Plant1/NDATA")]
|
||||
[InlineData("spBv1.0/Plant1/NDATA/EdgeA/UnexpectedDevice")]
|
||||
[InlineData("spBv1.0/Plant1/DDATA/EdgeA")]
|
||||
[InlineData("spBv1.0/Plant1/DDATA/EdgeA/Filler1/Extra")]
|
||||
[InlineData("wrong-namespace/Plant1/NDATA/EdgeA")]
|
||||
[InlineData("spBv1.0//NDATA/EdgeA")]
|
||||
[InlineData("spBv1.0/Plant1/NDATA/")]
|
||||
[InlineData("spBv1.0/Plant1/DDATA/EdgeA/")]
|
||||
[InlineData("spBv1.0/Plant+/NDATA/EdgeA")]
|
||||
[InlineData("spBv1.0/Plant1/NDATA/Edge#A")]
|
||||
[InlineData("spBv1.0/STATE")]
|
||||
[InlineData("spBv1.0/STATE/")]
|
||||
[InlineData("spBv1.0/STATE/Host/Extra")]
|
||||
[InlineData("STATE")]
|
||||
[InlineData("STATE/")]
|
||||
public void TryParse_NeverThrows_ForArbitraryInput(string? topic)
|
||||
{
|
||||
var ex = Record.Exception(() => SparkplugTopic.TryParse(topic, out var result));
|
||||
ex.ShouldBeNull();
|
||||
SparkplugTopic.TryParse(topic, out var result2).ShouldBeFalse();
|
||||
result2.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_InvalidTopic_ThrowsFormatException()
|
||||
=> Should.Throw<FormatException>(() => SparkplugTopic.Parse("not-a-sparkplug-topic"));
|
||||
|
||||
[Fact]
|
||||
public void Format_NodeScoped_BuildsExpectedTopic()
|
||||
=> SparkplugTopic.Format("Plant1", SparkplugMessageType.NCMD, "EdgeA")
|
||||
.ShouldBe("spBv1.0/Plant1/NCMD/EdgeA");
|
||||
|
||||
[Fact]
|
||||
public void Format_DeviceScoped_BuildsExpectedTopic()
|
||||
=> SparkplugTopic.Format("Plant1", SparkplugMessageType.DCMD, "EdgeA", "Filler1")
|
||||
.ShouldBe("spBv1.0/Plant1/DCMD/EdgeA/Filler1");
|
||||
|
||||
[Fact]
|
||||
public void Format_State_Throws_UseFormatStateInstead()
|
||||
=> Should.Throw<ArgumentException>(() => SparkplugTopic.Format("Plant1", SparkplugMessageType.STATE, "EdgeA"));
|
||||
|
||||
[Fact]
|
||||
public void FormatState_BuildsV3Topic()
|
||||
=> SparkplugTopic.FormatState("otopcua-host-1").ShouldBe("spBv1.0/STATE/otopcua-host-1");
|
||||
|
||||
[Theory]
|
||||
[InlineData("spBv1.0/Plant1/NBIRTH/EdgeA")]
|
||||
[InlineData("spBv1.0/Plant1/NDATA/EdgeA")]
|
||||
[InlineData("spBv1.0/Plant1/NDEATH/EdgeA")]
|
||||
[InlineData("spBv1.0/Plant1/NCMD/EdgeA")]
|
||||
[InlineData("spBv1.0/Plant1/DBIRTH/EdgeA/Filler1")]
|
||||
[InlineData("spBv1.0/Plant1/DDATA/EdgeA/Filler1")]
|
||||
[InlineData("spBv1.0/Plant1/DDEATH/EdgeA/Filler1")]
|
||||
[InlineData("spBv1.0/Plant1/DCMD/EdgeA/Filler1")]
|
||||
[InlineData("spBv1.0/STATE/otopcua-host-1")]
|
||||
public void ToTopicString_RoundTrips(string topic)
|
||||
=> SparkplugTopic.Parse(topic).ToTopicString().ShouldBe(topic);
|
||||
|
||||
[Fact]
|
||||
public void ToTopicString_LegacyStateTopic_NormalisesToV3Form()
|
||||
// Parsed from the legacy no-prefix form, but formatting always emits the v3.0 shape.
|
||||
=> SparkplugTopic.Parse("STATE/otopcua-host-1").ToTopicString().ShouldBe("spBv1.0/STATE/otopcua-host-1");
|
||||
|
||||
// ---- The plan's illustrative datatype-map smoke theory (spelled with the protoc-mangled member
|
||||
// names: the generated enum has `Uint8`, not `UInt8` — see SparkplugDataTypeTests for the
|
||||
// full table). Kept here alongside the topic tests per the Task 17 plan's original grouping;
|
||||
// SparkplugDataTypeTests.cs carries the exhaustive coverage + the completeness/drift guard. ----
|
||||
[Theory]
|
||||
[InlineData(SparkplugDataType.Int8, DriverDataType.Int16)]
|
||||
[InlineData(SparkplugDataType.Uint8, DriverDataType.UInt16)]
|
||||
[InlineData(SparkplugDataType.Float, DriverDataType.Float32)]
|
||||
public void ToDriverDataType_MapsAndWidens(SparkplugDataType s, DriverDataType d)
|
||||
=> s.ToDriverDataType().ShouldBe(d);
|
||||
}
|
||||
Reference in New Issue
Block a user