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; /// /// parse/format coverage (Task 17). /// is fed every topic string a live spBv1.0/{group}/# subscription delivers, so a large /// share of this suite is "garbage in, out, never a throw" — see /// . /// 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(() => 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(() => 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); }