93f70504bd
Driver.Mqtt.Contracts-004/-005/-006/-007. -004: retainSeed was the last behavioural key read leniently, so "retainSeed":"false" — what a CSV import produces — silently became the default TRUE, the INVERSE of the authored intent. Both mappers now read it through TryReadBoolStrict (the qos absent/valid/present-but-invalid split) and DescribeRejection names it. The authoring side needed no hand-mirrored rule: MqttTagConfigModel.Validate already consults this same parse through TagConfigParseGate, so the save is blocked with the inspector's wording — pinned by a new AdminUI parity theory pair. -005: the seven [Range] attributes on MqttDriverOptions/MqttPlainOptions never ran — the options are bound by JsonSerializer, and nothing in the four MQTT projects calls Validator.TryValidateObject. Two of them CONTRADICTED the real authority (KeepAlive's [Range(1,…)] forbade the zero Normalize admits as legal MQTT; MaxPayloadBytes' forbade the non-positive value its own remarks document as falling back to 1 MiB), so wiring them up would have broken deliberate behaviour. Deleted, with each property naming where its bound actually lives. The one escape they papered over is closed: an out-of-range defaultQos rode into the SUBSCRIBE verbatim, and MqttSubscriptionManager's constructor now clamps it exactly as the AdminUI driver form already persists. -006: the DateTime write arm cast a negative long to ulong, so 1969-12-31 published a timestamp ~584 million years in the future; it is now refused as ValueNotRepresentable. An Unspecified kind was handed to ToUniversalTime(), which reads it as LOCAL — the same authored instant encoded differently on the two nodes of a redundant pair; it is now read as UTC. -007: Format/FormatState enforced only null/empty, and nothing at all on deviceId, so the type was not round-trip-closed. They now apply the parser's IsValidSegment (plus '/', which only the format side can see). MqttWriteEncoder's own guards stay — TryEncode returns an error rather than throwing, and that contract does not change.
241 lines
11 KiB
C#
241 lines
11 KiB
C#
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);
|
|
|
|
/// <summary>
|
|
/// <see cref="SparkplugTopic.Format"/> must enforce the same per-segment rule
|
|
/// <see cref="SparkplugTopic.TryParse"/> does. It validated only null/empty on
|
|
/// <c>groupId</c>/<c>edgeNodeId</c> and NOTHING at all on <c>deviceId</c>, so the type was not
|
|
/// round-trip-closed: a <c>+</c>/<c>#</c> id produced a topic MQTT-3.3.2-2 forbids in a PUBLISH,
|
|
/// and a <c>/</c> in a <c>deviceId</c> silently added a segment. Every production caller is
|
|
/// already guarded, so the invariant belongs on the type that owns it rather than on the next
|
|
/// caller to arrive.
|
|
/// </summary>
|
|
/// <param name="groupId">The group id to format with.</param>
|
|
/// <param name="edgeNodeId">The edge-node id to format with.</param>
|
|
/// <param name="deviceId">The device id to format with, or <see langword="null"/>.</param>
|
|
[Theory]
|
|
[InlineData("Plant+1", "EdgeA", null)]
|
|
[InlineData("Plant#", "EdgeA", null)]
|
|
[InlineData("Plant1", "Edge+A", null)]
|
|
[InlineData("Plant1", "Edge#A", null)]
|
|
[InlineData("Plant1", "EdgeA", "Fill+er")]
|
|
[InlineData("Plant1", "EdgeA", "Fill#er")]
|
|
[InlineData("Ot/Sim", "EdgeA", null)] // two segments, not one — a DIFFERENT node than named
|
|
[InlineData("Plant1", "EdgeA", "Fill/er")]
|
|
[InlineData("Plant1", "EdgeA", "")] // would emit a trailing '/', which never re-parses
|
|
public void Format_SegmentThatIsNotALegalTopicSegment_Throws(string groupId, string edgeNodeId, string? deviceId)
|
|
=> Should.Throw<ArgumentException>(
|
|
() => SparkplugTopic.Format(groupId, SparkplugMessageType.DCMD, edgeNodeId, deviceId));
|
|
|
|
[Theory]
|
|
[InlineData("host+1")]
|
|
[InlineData("host#")]
|
|
public void FormatState_HostIdThatIsNotALegalTopicSegment_Throws(string hostId)
|
|
=> Should.Throw<ArgumentException>(() => SparkplugTopic.FormatState(hostId));
|
|
|
|
/// <summary>
|
|
/// Falsification control: the rule is the parser's <c>IsValidSegment</c>, not "reject anything
|
|
/// unusual". A guard that rejected every id would satisfy both theories above, and a Sparkplug id
|
|
/// is otherwise an opaque identifier — spaces, dots and dashes are all legitimate.
|
|
/// </summary>
|
|
/// <param name="id">A legal, if unusual, segment.</param>
|
|
[Theory]
|
|
[InlineData("Plant 1")]
|
|
[InlineData("plant.one")]
|
|
[InlineData("plant-one_2")]
|
|
[InlineData("usiné")]
|
|
public void Format_UnusualButLegalSegments_AreStillAccepted(string id)
|
|
=> SparkplugTopic.Parse(SparkplugTopic.Format(id, SparkplugMessageType.DCMD, id, id))
|
|
.ShouldBe(new SparkplugTopic(SparkplugMessageType.DCMD, id, id, id, null));
|
|
|
|
/// <summary>
|
|
/// The closure property the asymmetry broke: anything <see cref="SparkplugTopic.Format"/> emits
|
|
/// must parse back to the instance it was built from. It is asserted over the FORMATTER's own
|
|
/// output rather than over a hand-written topic list, so a future formatting change cannot leave
|
|
/// the corpus describing a shape the type no longer emits.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Format_Output_AlwaysReParsesToTheSameInstance()
|
|
{
|
|
SparkplugMessageType[] deviceScoped =
|
|
[SparkplugMessageType.DBIRTH, SparkplugMessageType.DDATA, SparkplugMessageType.DDEATH, SparkplugMessageType.DCMD];
|
|
|
|
foreach (var type in Enum.GetValues<SparkplugMessageType>())
|
|
{
|
|
if (type == SparkplugMessageType.STATE) continue;
|
|
var deviceId = deviceScoped.Contains(type) ? "Filler 1" : null;
|
|
var topic = SparkplugTopic.Format("Plant 1", type, "Edge.A", deviceId);
|
|
|
|
SparkplugTopic.Parse(topic).ShouldBe(new SparkplugTopic(type, "Plant 1", "Edge.A", deviceId, null));
|
|
}
|
|
|
|
SparkplugTopic.Parse(SparkplugTopic.FormatState("otopcua host 1"))
|
|
.ShouldBe(new SparkplugTopic(SparkplugMessageType.STATE, null, null, null, "otopcua host 1"));
|
|
}
|
|
|
|
[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);
|
|
}
|