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:
Joseph Doherty
2026-07-24 22:43:44 -04:00
parent 6917d8805d
commit cd0157a3b8
3 changed files with 377 additions and 32 deletions
@@ -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"}""")