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
{
-
-
- Sparkplug B tag authoring is not available yet — the group / edge-node / device /
- metric fields land with Sparkplug ingest. Switch back to Plain to author
- a topic-bound tag. Any existing Sparkplug keys on this tag are preserved untouched.
-
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"}""")