feat(mqtt): typed tag editor Sparkplug mode + validation
Task 24 (P2): replaces the P1 Sparkplug Validate() stub (always null) and the "not available yet" editor placeholder with real groupId/edgeNodeId/deviceId/ metricName authoring, mirroring MqttTagDefinitionFactory.FromSparkplugTagConfig in both directions -- required ids + optional deviceId, dataType/qos read strictly but only when present (dataType is optional: the birth certificate declares it). One rule is deliberately stricter than the factory: group/edge- node/device ids reject '/', '+', '#' (topic-segment characters a decoded incoming id can never carry, so an authored one that does can never bind); metricName is exempt since Sparkplug's own canonical names use '/' (e.g. "Node Control/Rebirth"). No FullName key is written -- TagConfig carries no identity under v3, and the plan's snippet asking for one was corrected per the brief. A SparkplugB tag now survives save/reopen because its descriptor keys re-infer Mode on load; there was never a 'mode' key to persist. Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
This commit is contained in:
@@ -7,14 +7,15 @@ using ZB.MOM.WW.OtOpcUa.Driver.Mqtt;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
|
||||
|
||||
// Typed AdminUI editor model for the Mqtt driver (P1 = Plain mode). The model is a thin typed view over
|
||||
// a preserved JsonObject key bag: every key the editor does not expose (history intent, array config,
|
||||
// alarm objects, and the Sparkplug keys Task 24 will add) must survive a load→save untouched.
|
||||
// Typed AdminUI editor model for the Mqtt driver — Plain (topic-bound) and Sparkplug B (birth/alias
|
||||
// ingest) modes. The model is a thin typed view over a preserved JsonObject key bag: every key the
|
||||
// editor does not expose (history intent, array config, alarm objects) must survive a load→save
|
||||
// untouched.
|
||||
//
|
||||
// The authoritative consumer of the produced blob is MqttTagDefinitionFactory.FromTagConfig
|
||||
// (Driver.Mqtt.Contracts) — the key names + strictness rules asserted here are that factory's, not this
|
||||
// editor's invention. TagConfig carries NO identity under v3 (the tag's RawPath is its identity), so
|
||||
// there is deliberately no FullName key.
|
||||
// The authoritative consumers of the produced blob are MqttTagDefinitionFactory.FromTagConfig (Plain)
|
||||
// and .FromSparkplugTagConfig (Sparkplug B) (Driver.Mqtt.Contracts) — the key names + strictness rules
|
||||
// asserted here are those factories', not this editor's invention. TagConfig carries NO identity under
|
||||
// v3 (the tag's RawPath is its identity), so there is deliberately no FullName key — in EITHER mode.
|
||||
public sealed class MqttTagConfigModelTests
|
||||
{
|
||||
[Theory]
|
||||
@@ -144,7 +145,7 @@ public sealed class MqttTagConfigModelTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromJson_then_ToJson_preserves_sparkplug_keys_task24_will_own()
|
||||
public void FromJson_then_ToJson_preserves_sparkplug_descriptor_keys()
|
||||
{
|
||||
var json = MqttTagConfigModel.FromJson(
|
||||
"""{"groupId":"Plant1","edgeNodeId":"E1","deviceId":"D1","metricName":"Temp"}""").ToJson();
|
||||
@@ -154,6 +155,146 @@ public sealed class MqttTagConfigModelTests
|
||||
o["edgeNodeId"]!.GetValue<string>().ShouldBe("E1");
|
||||
o["deviceId"]!.GetValue<string>().ShouldBe("D1");
|
||||
o["metricName"]!.GetValue<string>().ShouldBe("Temp");
|
||||
// TagConfig carries no identity under v3 even for a Sparkplug tag — see the corrections in the
|
||||
// Task 24 brief: FullName is a retired pre-v3 concept, not something this editor re-introduces.
|
||||
json.ShouldNotContain("FullName", Case.Sensitive);
|
||||
}
|
||||
|
||||
// ── Validate: Sparkplug rules (Task 24) ─────────────────────────────────────────────────────
|
||||
//
|
||||
// Mirrors MqttTagDefinitionFactory.FromSparkplugTagConfig exactly: groupId/edgeNodeId/metricName
|
||||
// required, deviceId optional, dataType/qos read strictly but only when present. See the model's
|
||||
// Validate() remarks for the one place this editor is deliberately STRICTER than the factory
|
||||
// (illegal characters in the id segments) and why metricName is exempt from that rule.
|
||||
|
||||
[Fact]
|
||||
public void Validate_SparkplugMissingMetricName_Fails()
|
||||
=> MqttTagConfigModel.FromJson("""{"groupId":"Plant1","edgeNodeId":"EdgeA"}""")
|
||||
.Validate().ShouldNotBeNullOrWhiteSpace();
|
||||
|
||||
[Fact]
|
||||
public void Validate_SparkplugMissingGroupId_Fails()
|
||||
=> MqttTagConfigModel.FromJson("""{"edgeNodeId":"EdgeA","metricName":"Temperature"}""")
|
||||
.Validate().ShouldNotBeNullOrWhiteSpace();
|
||||
|
||||
[Fact]
|
||||
public void Validate_SparkplugMissingEdgeNodeId_Fails()
|
||||
=> MqttTagConfigModel.FromJson("""{"groupId":"Plant1","metricName":"Temperature"}""")
|
||||
.Validate().ShouldNotBeNullOrWhiteSpace();
|
||||
|
||||
[Fact]
|
||||
public void Validate_SparkplugValid_ReturnsNull()
|
||||
=> MqttTagConfigModel.FromJson(
|
||||
"""{"groupId":"Plant1","edgeNodeId":"EdgeA","deviceId":"Filler1","metricName":"Temperature","dataType":"Float32"}""")
|
||||
.Validate().ShouldBeNull();
|
||||
|
||||
// deviceId is genuinely optional per the factory (absent ⇒ a node-level metric) — a Sparkplug tag
|
||||
// must validate cleanly without one.
|
||||
[Fact]
|
||||
public void Validate_SparkplugValidWithoutDeviceId_ReturnsNull()
|
||||
=> MqttTagConfigModel.FromJson(
|
||||
"""{"groupId":"Plant1","edgeNodeId":"EdgeA","metricName":"Temperature"}""")
|
||||
.Validate().ShouldBeNull();
|
||||
|
||||
// dataType is OPTIONAL in Sparkplug mode (the birth certificate declares it) — unlike Plain mode,
|
||||
// an absent dataType is not an error.
|
||||
[Fact]
|
||||
public void Validate_SparkplugAbsentDataType_ReturnsNull()
|
||||
=> MqttTagConfigModel.FromJson(
|
||||
"""{"groupId":"Plant1","edgeNodeId":"EdgeA","metricName":"Temperature"}""")
|
||||
.Validate().ShouldBeNull();
|
||||
|
||||
// ...but a PRESENT, typo'd dataType is still rejected — same strict-when-present rule as Plain,
|
||||
// and the same DriverDataType vocabulary (not the raw Sparkplug wire enum).
|
||||
[Fact]
|
||||
public void Validate_SparkplugTypoedDataType_Fails()
|
||||
=> MqttTagConfigModel.FromJson(
|
||||
"""{"groupId":"Plant1","edgeNodeId":"EdgeA","metricName":"Temperature","dataType":"Double"}""")
|
||||
.Validate().ShouldNotBeNullOrWhiteSpace();
|
||||
|
||||
[Theory]
|
||||
[InlineData("3")]
|
||||
[InlineData("-1")]
|
||||
public void Validate_SparkplugInvalidQos_Fails(string qosLiteral)
|
||||
=> MqttTagConfigModel.FromJson(
|
||||
$$"""{"groupId":"Plant1","edgeNodeId":"EdgeA","metricName":"Temperature","qos":{{qosLiteral}}}""")
|
||||
.Validate().ShouldNotBeNullOrWhiteSpace();
|
||||
|
||||
// Editor-stricter-than-factory: group/edge-node/device ids are literal MQTT topic segments — a
|
||||
// decoded incoming id can never contain '/', '+', or '#', so an authored one that does could never
|
||||
// bind. Covers all three id fields plus both illegal characters.
|
||||
[Theory]
|
||||
[InlineData("groupId", "Plant1/A")]
|
||||
[InlineData("groupId", "Plant+1")]
|
||||
[InlineData("groupId", "Plant#1")]
|
||||
[InlineData("edgeNodeId", "Edge/A")]
|
||||
[InlineData("edgeNodeId", "Edge+A")]
|
||||
[InlineData("deviceId", "Dev/1")]
|
||||
[InlineData("deviceId", "Dev#1")]
|
||||
public void Validate_SparkplugIllegalCharacterInIdSegment_Fails(string field, string value)
|
||||
{
|
||||
var fields = new Dictionary<string, string>
|
||||
{
|
||||
["groupId"] = "Plant1",
|
||||
["edgeNodeId"] = "EdgeA",
|
||||
["deviceId"] = "Filler1",
|
||||
["metricName"] = "Temperature",
|
||||
};
|
||||
fields[field] = value;
|
||||
var json = System.Text.Json.JsonSerializer.Serialize(fields);
|
||||
|
||||
MqttTagConfigModel.FromJson(json).Validate().ShouldNotBeNullOrWhiteSpace();
|
||||
}
|
||||
|
||||
// metricName is explicitly EXEMPT from the id-segment character rule — it is not a topic segment
|
||||
// (it comes from the payload, not the topic), and Sparkplug's own canonical examples use '/' in
|
||||
// metric names (e.g. "Node Control/Rebirth"). Over-rejecting here would block legitimate authoring.
|
||||
[Fact]
|
||||
public void Validate_SparkplugMetricNameWithSlash_IsAccepted()
|
||||
=> MqttTagConfigModel.FromJson(
|
||||
"""{"groupId":"Plant1","edgeNodeId":"EdgeA","metricName":"Node Control/Rebirth"}""")
|
||||
.Validate().ShouldBeNull();
|
||||
|
||||
// ── ToJson: Sparkplug dataType override (Task 24) ───────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void ToJson_SparkplugWithoutOverride_OmitsDataType()
|
||||
{
|
||||
var json = MqttTagConfigModel
|
||||
.FromJson("""{"groupId":"Plant1","edgeNodeId":"EdgeA","metricName":"Temperature"}""")
|
||||
.ToJson();
|
||||
|
||||
json.ShouldNotContain("dataType");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToJson_Sparkplug_RoundTripsDescriptorFieldsAndDataTypeOverride()
|
||||
{
|
||||
var json = MqttTagConfigModel.FromJson(
|
||||
"""{"groupId":"Plant1","edgeNodeId":"EdgeA","deviceId":"Filler1","metricName":"Temperature","dataType":"Float32"}""")
|
||||
.ToJson();
|
||||
|
||||
json.ShouldContain("\"groupId\":\"Plant1\"");
|
||||
json.ShouldContain("\"edgeNodeId\":\"EdgeA\"");
|
||||
json.ShouldContain("\"deviceId\":\"Filler1\"");
|
||||
json.ShouldContain("\"metricName\":\"Temperature\"");
|
||||
json.ShouldContain("\"dataType\":\"Float32\"");
|
||||
json.ShouldNotContain("FullName", Case.Sensitive);
|
||||
}
|
||||
|
||||
// A SparkplugB tag has NO 'mode' key to persist — it survives a save→reopen purely because the
|
||||
// descriptor keys it writes re-infer the mode on the next load.
|
||||
[Fact]
|
||||
public void FromJson_then_ToJson_then_FromJson_SparkplugMode_SurvivesReopen()
|
||||
{
|
||||
var m = MqttTagConfigModel.FromJson(
|
||||
"""{"groupId":"Plant1","edgeNodeId":"EdgeA","metricName":"Temperature"}""");
|
||||
m.Mode.ShouldBe(MqttMode.SparkplugB);
|
||||
|
||||
var json = m.ToJson();
|
||||
json.ShouldNotContain("\"mode\"");
|
||||
|
||||
MqttTagConfigModel.FromJson(json).Mode.ShouldBe(MqttMode.SparkplugB);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -270,8 +411,8 @@ public sealed class MqttTagConfigModelTests
|
||||
MqttTagConfigModel.FromJson(repaired).Validate().ShouldBeNull();
|
||||
}
|
||||
|
||||
// P1 stub: Sparkplug-shaped tags are not authored yet (Task 24 fills the rules). The Plain rules
|
||||
// must NOT be applied to them — a Sparkplug tag has no topic.
|
||||
// A well-formed Sparkplug tag is NOT subject to the Plain rules — it has no topic, and applying the
|
||||
// Plain "topic is required" check would reject every Sparkplug tag.
|
||||
[Fact]
|
||||
public void Validate_SparkplugMode_IsNotSubjectToPlainRules()
|
||||
=> MqttTagConfigModel.FromJson("""{"groupId":"P1","edgeNodeId":"E1","metricName":"Temp"}""")
|
||||
|
||||
Reference in New Issue
Block a user