From cd0157a3b8942f4f068fca334a9b350c0c32fed6 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 24 Jul 2026 22:43:44 -0400 Subject: [PATCH] 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 --- .../Uns/TagEditors/MqttTagConfigEditor.razor | 77 +++++++- .../Uns/TagEditors/MqttTagConfigModel.cs | 171 ++++++++++++++++-- .../Uns/MqttTagConfigModelTests.cs | 161 ++++++++++++++++- 3 files changed, 377 insertions(+), 32 deletions(-) diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/MqttTagConfigEditor.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/MqttTagConfigEditor.razor index 416e58ed..456c1626 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/MqttTagConfigEditor.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/MqttTagConfigEditor.razor @@ -3,10 +3,11 @@ dispatch uniformity and unused here (MQTT has no address-builder picker — topics are discovered through the /raw browse tree, not composed from parts). - P1 authors Plain-mode tags only. The shape switch is real (it drives which field group renders and - what Validate() applies) but it is a UI-only choice derived from the blob, never serialised — the - driver takes Plain vs SparkplugB from its DRIVER config, and the tag blob has no 'mode' key. - Task 24 replaces the Sparkplug placeholder below with the real descriptor field group. *@ + The shape switch is real (it drives which field group renders and what Validate() applies) but it + is a UI-only choice derived from the blob, never serialised — the driver takes Plain vs SparkplugB + from its DRIVER config, and the tag blob has no 'mode' key. A SparkplugB tag survives a save→reopen + because its descriptor keys (groupId/edgeNodeId/deviceId/metricName) re-infer the mode on load — + there is nothing else that needs to persist. *@ @using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors @using ZB.MOM.WW.OtOpcUa.Core.Abstractions @using ZB.MOM.WW.OtOpcUa.Driver.Mqtt @@ -78,12 +79,64 @@ } else { -
- +
+ + +
The driver subscribes spBv1.0/@(string.IsNullOrEmpty(_m.GroupId) ? "{GroupId}" : _m.GroupId)/#. No /, +, or #.
+
+
+ + +
The publishing edge node's id.
+
+
+ + +
Leave blank for a metric published by the edge node itself.
+
+
+ + +
The metric's stable name from the birth certificate — unlike the ids above, this MAY contain / (e.g. Properties/Hardware Make).
+
+
+ + @* Same DriverDataType set as Plain's field — the factory reads a Sparkplug 'dataType' key as + a DriverDataType override, not the raw wire SparkplugDataType. Absent ⇒ take whatever type + the birth certificate declares (the driver's UntilStable discovery). *@ + +
+
+ + +
+
+ +
} @@ -121,6 +174,10 @@ private static TEnum ParseEnum(object? v, TEnum fallback) where TEnum : struct, Enum => Enum.TryParse(v?.ToString(), out var r) ? r : fallback; + // "" (the "(from birth certificate)" sentinel option) ⇒ null ⇒ the key is omitted. + private static TEnum? ParseNullableEnum(object? v) where TEnum : struct, Enum + => Enum.TryParse(v?.ToString(), out var r) ? r : null; + // "" ⇒ null ⇒ the key is omitted and the driver's own default applies. private static int? ParseNullableInt(object? v) => int.TryParse(v?.ToString(), out var i) ? i : null; diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/MqttTagConfigModel.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/MqttTagConfigModel.cs index 6b9e3b4c..68587153 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/MqttTagConfigModel.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/MqttTagConfigModel.cs @@ -25,8 +25,10 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors; /// is a UI-only sub-shape selector, inferred from the blob (the presence /// of any Sparkplug descriptor key) and never serialised — the driver takes its /// Plain/SparkplugB mode from the driver config, not from a tag, so -/// persisting a per-tag mode key would add a field the contract does not define. Only the -/// Plain rules are implemented in P1; the Sparkplug branch is a stub Task 24 fills. +/// persisting a per-tag mode key would add a field the contract does not define. +/// Consequently a SparkplugB tag survives a save→reopen purely because the descriptor keys it +/// writes (/// +/// ) re-infer the mode on the next load — there is nothing else to persist. /// /// public sealed class MqttTagConfigModel @@ -37,6 +39,10 @@ public sealed class MqttTagConfigModel private const string DataTypeKey = "dataType"; private const string QosKey = "qos"; private const string RetainSeedKey = "retainSeed"; + private const string GroupIdKey = "groupId"; + private const string EdgeNodeIdKey = "edgeNodeId"; + private const string DeviceIdKey = "deviceId"; + private const string MetricNameKey = "metricName"; /// /// The JSONPath the driver applies when the blob omits jsonPath — the document root. @@ -45,15 +51,29 @@ public sealed class MqttTagConfigModel /// private const string RootJsonPath = "$"; - /// The Sparkplug B descriptor keys — authored by Task 24, preserved (never dropped) in P1. - private static readonly string[] SparkplugKeys = ["groupId", "edgeNodeId", "deviceId", "metricName"]; + /// The Sparkplug B descriptor keys — any non-blank one marks a tag SparkplugB (see ). + private static readonly string[] SparkplugKeys = [GroupIdKey, EdgeNodeIdKey, DeviceIdKey, MetricNameKey]; /// The MQTT wildcard characters; a tag's subscription topic must be concrete. private static readonly char[] TopicWildcards = ['+', '#']; + /// + /// Characters illegal in a Sparkplug group/edge-node/device id. / is rejected because + /// these ids are literal MQTT topic segments — a decoded incoming id can never contain one + /// (the broker has already split the topic into segments before the driver sees it), so an + /// authored id containing / could never match a real birth: a permanently dead binding, + /// not an ambiguous one. +/# are rejected for the same "no legitimate + /// interpretation" reason the Plain topic wildcard check uses — here doubly so, since + /// 's only consumer builds the literal subscription + /// filter spBv1.0/{GroupId}/#, and embedding a wildcard character mid-segment is not + /// even legal there. Not enforced by MqttTagDefinitionFactory.FromSparkplugTagConfig + /// — this is an editor-side rule stricter than the runtime parser, the same shape as the Plain + /// topic-wildcard rule below. + /// + private static readonly char[] SparkplugSegmentIllegalChars = ['/', '+', '#']; + /// /// Which ingest shape this tag is authored under — inferred from the blob, never serialised. - /// P1 authors only. /// public MqttMode Mode { get; set; } = MqttMode.Plain; @@ -72,21 +92,56 @@ public sealed class MqttTagConfigModel /// public string JsonPath { get; set; } = ""; - /// The tag's declared value type (the driver's set). + /// The tag's declared value type (Plain mode; always written). The driver's set. public DriverDataType DataType { get; set; } = DriverDataType.String; /// /// Per-tag subscription QoS (0–2), or null to omit the key and inherit the driver-level - /// default. An absent key stays absent through a load→save. + /// default. An absent key stays absent through a load→save. Shared by both modes — + /// MqttTagDefinitionFactory reads qos identically for Plain and Sparkplug. /// public int? Qos { get; set; } /// /// Whether the broker's retained message seeds the tag's initial value, or null to omit - /// the key and inherit the driver's default (true). An absent key stays absent. + /// the key and inherit the driver's default (true). An absent key stays absent. Shared by + /// both modes — MqttTagDefinitionFactory reads retainSeed identically for Plain and + /// Sparkplug. /// public bool? RetainSeed { get; set; } + /// The Sparkplug group id (Sparkplug mode). Required; becomes part of the subscription filter. + public string GroupId { get; set; } = ""; + + /// The Sparkplug edge-node id (Sparkplug mode). Required. + public string EdgeNodeId { get; set; } = ""; + + /// + /// The Sparkplug device id (Sparkplug mode), or blank for a metric published by the edge node + /// itself rather than a device beneath it. Optional. + /// + public string DeviceId { get; set; } = ""; + + /// + /// The Sparkplug metric's stable name (Sparkplug mode). Required. Unlike + /// // this is NOT a topic + /// segment — it is read from the birth/data payload — so it is deliberately unrestricted and MAY + /// contain / (the canonical Sparkplug examples do: Node Control/Rebirth, + /// Properties/Hardware Make). + /// + public string MetricName { get; set; } = ""; + + /// + /// Sparkplug-mode data-type OVERRIDE (Sparkplug mode only), or null to omit the key and + /// let the tag take whatever type the metric's birth certificate declares — the whole point of + /// the driver's UntilStable discovery. Distinct from , which is the + /// always-written Plain-mode field; both read/write the same dataType JSON key + /// (MqttTagDefinitionFactory.FromSparkplugTagConfig reads it as a + /// override, strictly, when present — the same enum Plain mode uses, not the raw wire + /// SparkplugDataType). + /// + public DriverDataType? MetricDataType { get; set; } + private JsonObject _bag = new(); /// @@ -120,6 +175,13 @@ public sealed class MqttTagConfigModel DataType = TagConfigJson.GetEnum(o, DataTypeKey, DriverDataType.String), Qos = GetIntNullable(o, QosKey), RetainSeed = TagConfigJson.GetBoolNullable(o, RetainSeedKey), + GroupId = TagConfigJson.GetString(o, GroupIdKey) ?? "", + EdgeNodeId = TagConfigJson.GetString(o, EdgeNodeIdKey) ?? "", + DeviceId = TagConfigJson.GetString(o, DeviceIdKey) ?? "", + MetricName = TagConfigJson.GetString(o, MetricNameKey) ?? "", + // Same "dataType" key as Plain's DataType above, read as an optional override — see the + // MetricDataType doc comment for why there are two typed fields over one JSON key. + MetricDataType = GetEnumNullable(o, DataTypeKey), _bag = o, }; } @@ -130,6 +192,15 @@ public sealed class MqttTagConfigModel /// rejected outright), and blank/null optionals are written as an absent key so the driver's own /// defaults apply. /// + /// + /// dataType is the one key both modes write, from two different typed fields + /// ( for Plain — always present; for + /// Sparkplug — omitted unless the operator set an override): which field wins is decided by + /// at write time. Every other key is unconditional — mode-inapplicable keys + /// (e.g. topic while Sparkplug, or groupId while Plain) are written/cleared exactly + /// as their typed field says, which naturally preserves a retyped tag's other-mode leftovers + /// untouched until the operator edits that field too. + /// /// The serialised TagConfig JSON string. public string ToJson() { @@ -137,10 +208,18 @@ public sealed class MqttTagConfigModel // must not leave a stray "topic":"" behind. TagConfigJson.Set(_bag, TopicKey, string.IsNullOrWhiteSpace(Topic) ? null : Topic.Trim()); TagConfigJson.Set(_bag, PayloadFormatKey, PayloadFormat); - TagConfigJson.Set(_bag, DataTypeKey, DataType); TagConfigJson.Set(_bag, JsonPathKey, string.IsNullOrWhiteSpace(JsonPath) ? null : JsonPath.Trim()); TagConfigJson.Set(_bag, QosKey, Qos); TagConfigJson.Set(_bag, RetainSeedKey, RetainSeed); + + // Mode-dependent: Plain always writes DataType; Sparkplug writes MetricDataType (null ⇒ omitted, + // "take the birth's declared type"). + TagConfigJson.Set(_bag, DataTypeKey, Mode == MqttMode.SparkplugB ? MetricDataType : DataType); + + TagConfigJson.Set(_bag, GroupIdKey, string.IsNullOrWhiteSpace(GroupId) ? null : GroupId.Trim()); + TagConfigJson.Set(_bag, EdgeNodeIdKey, string.IsNullOrWhiteSpace(EdgeNodeId) ? null : EdgeNodeId.Trim()); + TagConfigJson.Set(_bag, DeviceIdKey, string.IsNullOrWhiteSpace(DeviceId) ? null : DeviceId.Trim()); + TagConfigJson.Set(_bag, MetricNameKey, string.IsNullOrWhiteSpace(MetricName) ? null : MetricName.Trim()); return TagConfigJson.Serialize(_bag); } @@ -169,13 +248,30 @@ public sealed class MqttTagConfigModel /// blob that never went through this editor (e.g. a CSV import) cannot pass validation while the /// driver would reject it. /// + /// + /// Sparkplug rules mirror MqttTagDefinitionFactory.FromSparkplugTagConfig: + /// groupId/edgeNodeId/metricName required (blank rejected exactly as the + /// factory hard-rejects them), deviceId optional, dataType and qos read + /// strictly but ONLY when present (matching TryReadEnumStrict's absent/valid/invalid + /// split — a Sparkplug tag legitimately omits dataType and takes whatever the birth + /// certificate declares). Plain-only fields (topic, payloadFormat, jsonPath) + /// are NOT checked in this mode, matching the factory's "Plain-shape keys are read but not + /// required" stance for a retyped blob's leftovers. + /// + /// + /// ONE Sparkplug rule is likewise stricter than the factory: groupId/edgeNodeId/ + /// deviceId reject /, +, and # — see + /// for why (a decoded incoming id can never contain + /// one, so an authored id that does is a permanently dead binding, and +/# would + /// corrupt the driver's own subscription filter). metricName carries NO such restriction — + /// it is not a topic segment, and Sparkplug's own canonical metric names use / (e.g. + /// Node Control/Rebirth); rejecting it would block legitimate authoring. + /// /// /// An error message describing the validation failure, or null when valid. public string? Validate() { - // P1 stub: Sparkplug-shaped tags are not authored yet — Task 24 fills these rules. Applying the - // Plain rules to them would reject every Sparkplug tag for "missing topic". - if (Mode == MqttMode.SparkplugB) { return null; } + if (Mode == MqttMode.SparkplugB) { return ValidateSparkplug(); } if (DescribeInvalidEnum(_bag, PayloadFormatKey) is { } pfError) { return pfError; } if (DescribeInvalidEnum(_bag, DataTypeKey) is { } dtError) { return dtError; } @@ -194,6 +290,45 @@ public sealed class MqttTagConfigModel return null; } + /// Sparkplug-mode half of — see its remarks for the full rationale. + /// An error message describing the validation failure, or null when valid. + private string? ValidateSparkplug() + { + // Same DriverDataType enum + same strict absent/valid/invalid split as Plain's DataType check — + // dataType is one JSON key read by both FromTagConfig and FromSparkplugTagConfig identically. + if (DescribeInvalidEnum(_bag, DataTypeKey) is { } dtError) { return dtError; } + if (DescribeInvalidQos(_bag) is { } qosError) { return qosError; } + + var groupId = GroupId.Trim(); + var edgeNodeId = EdgeNodeId.Trim(); + var deviceId = DeviceId.Trim(); + var metricName = MetricName.Trim(); + + if (string.IsNullOrEmpty(groupId)) { return "A Sparkplug group ID is required."; } + if (string.IsNullOrEmpty(edgeNodeId)) { return "A Sparkplug edge node ID is required."; } + if (string.IsNullOrEmpty(metricName)) { return "A Sparkplug metric name is required."; } + + if (DescribeInvalidSparkplugSegment("group ID", groupId) is { } gErr) { return gErr; } + if (DescribeInvalidSparkplugSegment("edge node ID", edgeNodeId) is { } eErr) { return eErr; } + // deviceId is optional — only validated when the operator actually supplied one. + if (deviceId.Length > 0 && DescribeInvalidSparkplugSegment("device ID", deviceId) is { } dErr) { return dErr; } + + return null; + } + + /// + /// Describes an that is illegal as a Sparkplug group/edge-node/device + /// id segment (see ), or null when clean. + /// + /// The human-readable field name for the error message. + /// The trimmed, non-blank field value to check. + /// The error text, or null. + private static string? DescribeInvalidSparkplugSegment(string fieldLabel, string value) + => value.IndexOfAny(SparkplugSegmentIllegalChars) >= 0 + ? $"Sparkplug {fieldLabel} '{value}' contains a character ('/', '+', or '#') that cannot appear " + + "in an MQTT topic segment; a decoded incoming id can never contain one, so this could never bind." + : null; + /// /// Infers the editor's sub-shape from the blob: any non-blank Sparkplug descriptor key marks a /// Sparkplug tag, otherwise Plain. @@ -212,6 +347,18 @@ public sealed class MqttTagConfigModel private static int? GetIntNullable(JsonObject o, string name) => o.TryGetPropertyValue(name, out var n) && n is JsonValue v && v.TryGetValue(out var i) ? i : null; + /// + /// Reads an enum by its serialised name, or null when absent/unparseable — the nullable + /// counterpart of , used for + /// so "the key is absent" (take the birth's declared type) is distinguishable from any real member. + /// + /// The enum type to parse. + /// The JSON object to read from. + /// The property name to read. + /// The parsed enum value, or null. + private static TEnum? GetEnumNullable(JsonObject o, string name) where TEnum : struct, Enum + => TagConfigJson.GetString(o, name) is { } s && Enum.TryParse(s, ignoreCase: true, out var v) ? v : null; + /// /// Describes a present-but-invalid enum field, or null when it is absent or valid. /// Mirrors TagConfigJson.TryReadEnum's absent/valid/invalid split exactly — including its diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/MqttTagConfigModelTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/MqttTagConfigModelTests.cs index b803fbd4..5ad88baf 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/MqttTagConfigModelTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/MqttTagConfigModelTests.cs @@ -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().ShouldBe("E1"); o["deviceId"]!.GetValue().ShouldBe("D1"); o["metricName"]!.GetValue().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 + { + ["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"}""")