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
new file mode 100644
index 00000000..810fe331
--- /dev/null
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/MqttTagConfigEditor.razor
@@ -0,0 +1,141 @@
+@* Typed TagConfig editor for the Mqtt driver. Same (ConfigJson/ConfigJsonChanged/DriverType/
+ GetDriverConfigJson) parameter shape every typed editor takes; the last two are accepted for
+ 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. *@
+@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors
+@using ZB.MOM.WW.OtOpcUa.Core.Abstractions
+@using ZB.MOM.WW.OtOpcUa.Driver.Mqtt
+
+
+
+ @* The driver's own DriverDataType members — authoring anything outside this set produces a
+ blob the driver's strict parser rejects at deploy (there is no 'Double'; it is Float64). *@
+
+
+
+
+
+
+
+
+
+
+ }
+ 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.
+
+
+ }
+
+ @if (_validationError is not null)
+ {
+
@_validationError
+ }
+
+
+@code {
+ /// The tag's TagConfig JSON, owned by the host modal.
+ [Parameter] public string? ConfigJson { get; set; }
+ /// Raised with the re-serialised TagConfig JSON after every field edit.
+ [Parameter] public EventCallback ConfigJsonChanged { get; set; }
+ /// DriverType of the owning driver — accepted for dispatch uniformity; unused here.
+ [Parameter] public string DriverType { get; set; } = "";
+ /// Live accessor for the owning driver's DriverConfig JSON — accepted for dispatch uniformity; unused here.
+ [Parameter] public Func GetDriverConfigJson { get; set; } = () => "{}";
+
+ private MqttTagConfigModel _m = new();
+ private string? _lastConfigJson;
+ private string? _validationError;
+
+ // Re-parse only when the incoming JSON actually changes, so an unrelated parent re-render
+ // (Blazor Server live-status pushes do this) can't reset the user's in-progress edits.
+ protected override void OnParametersSet()
+ {
+ if (ConfigJson == _lastConfigJson) { return; }
+ _lastConfigJson = ConfigJson;
+ _m = MqttTagConfigModel.FromJson(ConfigJson);
+ _validationError = _m.Validate();
+ }
+
+ // TryParse so a bad/empty change value can never throw into the Blazor circuit — it falls back.
+ private static TEnum ParseEnum(object? v, TEnum fallback) where TEnum : struct, Enum
+ => Enum.TryParse(v?.ToString(), out var r) ? r : fallback;
+
+ // "" ⇒ 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;
+
+ private static bool? ParseNullableBool(object? v)
+ => bool.TryParse(v?.ToString(), out var b) ? b : null;
+
+ private async Task Update(Action apply)
+ {
+ apply();
+ _validationError = _m.Validate();
+ var json = _m.ToJson();
+ // Keep the guard in OnParametersSet in step with what we just emitted, so the host echoing the
+ // new JSON back as a parameter cannot re-parse and clobber an in-progress edit.
+ _lastConfigJson = json;
+ await ConfigJsonChanged.InvokeAsync(json);
+ }
+}
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
new file mode 100644
index 00000000..6d52fbac
--- /dev/null
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/MqttTagConfigModel.cs
@@ -0,0 +1,216 @@
+using System.Text.Json.Nodes;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+using ZB.MOM.WW.OtOpcUa.Driver.Mqtt;
+
+namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
+
+///
+/// Typed working model for an MQTT tag's TagConfig JSON — the driver-specific binding fields
+/// (name / access level / writability live on the Tag entity). Preserves unrecognised JSON keys
+/// across a load→save.
+///
+///
+///
+/// The authoritative consumer of the produced blob is
+/// MqttTagDefinitionFactory.FromTagConfig (Driver.Mqtt.Contracts); every key name and
+/// strictness rule here mirrors that factory rather than inventing an editor-side schema.
+///
+///
+/// There is deliberately no FullName (or any other identity) key. Under the v3
+/// identity contract a tag is identified by its RawPath — the factory keys the produced
+/// definition's Name off the RawPath it is handed, and the TagConfig is a pure address blob.
+/// Writing a composed identity key here would be dead weight nothing reads.
+///
+///
+/// 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.
+///
+///
+public sealed class MqttTagConfigModel
+{
+ private const string TopicKey = "topic";
+ private const string PayloadFormatKey = "payloadFormat";
+ private const string JsonPathKey = "jsonPath";
+ private const string DataTypeKey = "dataType";
+ private const string QosKey = "qos";
+ private const string RetainSeedKey = "retainSeed";
+
+ /// The Sparkplug B descriptor keys — authored by Task 24, preserved (never dropped) in P1.
+ private static readonly string[] SparkplugKeys = ["groupId", "edgeNodeId", "deviceId", "metricName"];
+
+ /// The MQTT wildcard characters; a tag's subscription topic must be concrete.
+ private static readonly char[] TopicWildcards = ['+', '#'];
+
+ ///
+ /// Which ingest shape this tag is authored under — inferred from the blob, never serialised.
+ /// P1 authors only.
+ ///
+ public MqttMode Mode { get; set; } = MqttMode.Plain;
+
+ /// The concrete MQTT topic the tag subscribes to (Plain mode). Required; no wildcards.
+ public string Topic { get; set; } = "";
+
+ /// How the received payload is decoded (Plain mode).
+ public MqttPayloadFormat PayloadFormat { get; set; } = MqttPayloadFormat.Json;
+
+ ///
+ /// JSONPath selecting the value inside a payload. Blank ⇒
+ /// the key is omitted and the driver applies the document root ($).
+ ///
+ public string JsonPath { get; set; } = "";
+
+ /// The tag's declared value type (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.
+ ///
+ 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.
+ ///
+ public bool? RetainSeed { get; set; }
+
+ private JsonObject _bag = new();
+
+ ///
+ /// Loads a model from a TagConfig JSON string, defaulting any absent field and retaining every
+ /// original key (so fields this editor doesn't expose — history intent, array/alarm objects, and
+ /// the Sparkplug descriptor keys — survive a load→save).
+ ///
+ /// The raw TagConfig JSON string, or null for a new/empty config.
+ /// The populated .
+ public static MqttTagConfigModel FromJson(string? json)
+ {
+ var o = TagConfigJson.ParseOrNew(json);
+ return new MqttTagConfigModel
+ {
+ Mode = InferMode(o),
+ Topic = TagConfigJson.GetString(o, TopicKey) ?? "",
+ PayloadFormat = TagConfigJson.GetEnum(o, PayloadFormatKey, MqttPayloadFormat.Json),
+ JsonPath = TagConfigJson.GetString(o, JsonPathKey) ?? "",
+ DataType = TagConfigJson.GetEnum(o, DataTypeKey, DriverDataType.String),
+ Qos = GetIntNullable(o, QosKey),
+ RetainSeed = TagConfigJson.GetBoolNullable(o, RetainSeedKey),
+ _bag = o,
+ };
+ }
+
+ ///
+ /// Serialises this model back to a TagConfig JSON string over the preserved key bag. Enums are
+ /// written as their names (the driver reads them strictly by name; an ordinal would be
+ /// rejected outright), and blank/null optionals are written as an absent key so the driver's own
+ /// defaults apply.
+ ///
+ /// The serialised TagConfig JSON string.
+ public string ToJson()
+ {
+ TagConfigJson.Set(_bag, TopicKey, 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);
+ return TagConfigJson.Serialize(_bag);
+ }
+
+ ///
+ /// Client-side validation run by before a tag is saved.
+ /// Returns the first error, or null when the config is valid.
+ ///
+ ///
+ /// Two rules are deliberately stricter than the runtime parser, because this is an
+ /// authoring surface and the cheapest place to stop the mistake: a wildcard topic (the runtime
+ /// accepts it and Inspect only warns at deploy) and a Json payload with no
+ /// jsonPath (the runtime defaults to the document root). Everything else — required
+ /// topic, strict enums, QoS 0–2 — matches MqttTagDefinitionFactory exactly. The strict
+ /// enum/QoS checks read the ORIGINAL key bag, not the defaulted typed fields, so a blob that
+ /// never went through this editor (e.g. a CSV import) cannot pass validation while the driver
+ /// would reject it.
+ ///
+ /// 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 (DescribeInvalidEnum(_bag, PayloadFormatKey) is { } pfError) { return pfError; }
+ if (DescribeInvalidEnum(_bag, DataTypeKey) is { } dtError) { return dtError; }
+ if (DescribeInvalidQos(_bag) is { } qosError) { return qosError; }
+
+ var topic = Topic.Trim();
+ if (string.IsNullOrEmpty(topic)) { return "A topic is required."; }
+ if (topic.IndexOfAny(TopicWildcards) >= 0)
+ {
+ return $"Topic '{topic}' contains an MQTT wildcard (+ or #); a tag's topic must be concrete, "
+ + "or the tag would be fed by every matching topic.";
+ }
+
+ if (PayloadFormat == MqttPayloadFormat.Json && string.IsNullOrWhiteSpace(JsonPath))
+ {
+ return "A jsonPath is required when the payload format is Json (use \"$\" for the whole document).";
+ }
+
+ return null;
+ }
+
+ ///
+ /// Infers the editor's sub-shape from the blob: any non-blank Sparkplug descriptor key marks a
+ /// Sparkplug tag, otherwise Plain.
+ ///
+ /// The parsed TagConfig key bag.
+ /// The inferred mode.
+ private static MqttMode InferMode(JsonObject o)
+ => SparkplugKeys.Any(k => !string.IsNullOrWhiteSpace(TagConfigJson.GetString(o, k)))
+ ? MqttMode.SparkplugB
+ : MqttMode.Plain;
+
+ /// Reads an int value, or null when absent/null/not an integer (so an absent key stays absent).
+ /// The JSON object to read from.
+ /// The property name to read.
+ /// The int value, or null.
+ 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;
+
+ ///
+ /// 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
+ /// treatment of a present-but-non-string value as ABSENT — so the editor never blocks a blob the
+ /// driver would happily default.
+ ///
+ /// The enum type expected.
+ /// The TagConfig key bag.
+ /// The property name to describe.
+ /// The error text, or null.
+ private static string? DescribeInvalidEnum(JsonObject o, string name) where TEnum : struct, Enum
+ {
+ if (!o.TryGetPropertyValue(name, out var n) || n is not JsonValue v || !v.TryGetValue(out var raw))
+ {
+ return null;
+ }
+ return Enum.TryParse(raw, ignoreCase: true, out _)
+ ? null
+ : $"'{raw}' is not a valid {name}; valid: {string.Join(", ", Enum.GetNames())}.";
+ }
+
+ ///
+ /// Describes a present-but-invalid qos field, or null when it is absent or a legal
+ /// MQTT QoS. Matches the factory's strict read: absent ⇒ fine; a JSON integer 0–2 ⇒ fine;
+ /// anything else present (non-number, non-integer, out of range) ⇒ rejected.
+ ///
+ /// The TagConfig key bag.
+ /// The error text, or null.
+ private static string? DescribeInvalidQos(JsonObject o)
+ {
+ if (!o.TryGetPropertyValue(QosKey, out var n) || n is null) { return null; }
+ if (n is JsonValue v && v.TryGetValue(out var i) && i is >= 0 and <= 2) { return null; }
+ return $"'{n.ToJsonString()}' is not a valid QoS; valid: 0, 1, 2.";
+ }
+}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigEditorMap.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigEditorMap.cs
index 0ec4501b..8f53b287 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigEditorMap.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigEditorMap.cs
@@ -21,6 +21,7 @@ public static class TagConfigEditorMap
[DriverTypeNames.FOCAS] = typeof(Components.Shared.Uns.TagEditors.FocasTagConfigEditor),
[DriverTypeNames.OpcUaClient] = typeof(Components.Shared.Uns.TagEditors.OpcUaClientTagConfigEditor),
[DriverTypeNames.Calculation] = typeof(Components.Shared.Uns.TagEditors.CalculationTagConfigEditor),
+ [DriverTypeNames.Mqtt] = typeof(Components.Shared.Uns.TagEditors.MqttTagConfigEditor),
};
/// Returns the editor component type for a driver type, or null if none is registered.
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigValidator.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigValidator.cs
index f73e69d4..a3c9a6b5 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigValidator.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigValidator.cs
@@ -23,6 +23,7 @@ public static class TagConfigValidator
[DriverTypeNames.FOCAS] = j => FocasTagConfigModel.FromJson(j).Validate(),
[DriverTypeNames.OpcUaClient] = j => OpcUaClientTagConfigModel.FromJson(j).Validate(),
[DriverTypeNames.Calculation] = j => CalculationTagConfigModel.FromJson(j).Validate(),
+ [DriverTypeNames.Mqtt] = j => MqttTagConfigModel.FromJson(j).Validate(),
};
///
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
new file mode 100644
index 00000000..3dfb3fbb
--- /dev/null
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/MqttTagConfigModelTests.cs
@@ -0,0 +1,254 @@
+using System.Text.Json.Nodes;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+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.
+//
+// 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.
+public sealed class MqttTagConfigModelTests
+{
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData(" ")]
+ [InlineData("{}")]
+ [InlineData("not json at all")]
+ public void FromJson_returns_defaults_for_empty_input(string? json)
+ {
+ var m = MqttTagConfigModel.FromJson(json);
+
+ m.Mode.ShouldBe(MqttMode.Plain);
+ m.Topic.ShouldBe("");
+ m.PayloadFormat.ShouldBe(MqttPayloadFormat.Json);
+ m.JsonPath.ShouldBe("");
+ m.DataType.ShouldBe(DriverDataType.String);
+ m.Qos.ShouldBeNull();
+ m.RetainSeed.ShouldBeNull();
+ }
+
+ [Fact]
+ public void FromJson_reads_every_plain_field()
+ {
+ var m = MqttTagConfigModel.FromJson(
+ """{"topic":"factory/oven/temp","payloadFormat":"Json","jsonPath":"$.value","dataType":"Float64","qos":1,"retainSeed":false}""");
+
+ m.Mode.ShouldBe(MqttMode.Plain);
+ m.Topic.ShouldBe("factory/oven/temp");
+ m.PayloadFormat.ShouldBe(MqttPayloadFormat.Json);
+ m.JsonPath.ShouldBe("$.value");
+ m.DataType.ShouldBe(DriverDataType.Float64);
+ m.Qos.ShouldBe(1);
+ m.RetainSeed.ShouldBe(false);
+ }
+
+ [Fact]
+ public void Round_trip_preserves_every_plain_field()
+ {
+ var m = new MqttTagConfigModel
+ {
+ Topic = "line3/press",
+ PayloadFormat = MqttPayloadFormat.Scalar,
+ JsonPath = "$.a.b",
+ DataType = DriverDataType.Int32,
+ Qos = 2,
+ RetainSeed = true,
+ };
+
+ var m2 = MqttTagConfigModel.FromJson(m.ToJson());
+
+ m2.Topic.ShouldBe("line3/press");
+ m2.PayloadFormat.ShouldBe(MqttPayloadFormat.Scalar);
+ m2.JsonPath.ShouldBe("$.a.b");
+ m2.DataType.ShouldBe(DriverDataType.Int32);
+ m2.Qos.ShouldBe(2);
+ m2.RetainSeed.ShouldBe(true);
+ }
+
+ [Fact]
+ public void ToJson_emits_camelCase_keys_with_enum_names_and_no_identity_key()
+ {
+ var json = new MqttTagConfigModel
+ {
+ Topic = "a/b",
+ PayloadFormat = MqttPayloadFormat.Raw,
+ DataType = DriverDataType.Boolean,
+ }.ToJson();
+
+ json.ShouldContain("\"topic\":\"a/b\"");
+ // Enums MUST serialize as names — the driver factory reads them strictly by name, and an
+ // ordinal would be rejected outright (the systemic enum-serialization trap).
+ json.ShouldContain("\"payloadFormat\":\"Raw\"");
+ json.ShouldContain("\"dataType\":\"Boolean\"");
+ json.ShouldNotContain("\"payloadFormat\":1");
+ json.ShouldNotContain("\"dataType\":0");
+ // TagConfig carries no identity under v3 — the tag's RawPath is the driver-side key.
+ json.ShouldNotContain("FullName", Case.Sensitive);
+ }
+
+ [Fact]
+ public void ToJson_omits_absent_optional_keys()
+ {
+ var json = new MqttTagConfigModel { Topic = "a/b", PayloadFormat = MqttPayloadFormat.Raw }.ToJson();
+
+ // Absent stays absent so the driver's own defaults (qos ⇒ DefaultQos, retainSeed ⇒ true,
+ // jsonPath ⇒ "$") apply, rather than this editor freezing them into the blob.
+ json.ShouldNotContain("qos");
+ json.ShouldNotContain("retainSeed");
+ json.ShouldNotContain("jsonPath");
+ }
+
+ [Fact]
+ public void FromJson_then_ToJson_preserves_unknown_keys()
+ {
+ var json = MqttTagConfigModel.FromJson(
+ """
+ {"topic":"a/b","payloadFormat":"Raw","dataType":"String",
+ "isHistorized":true,"historianTagname":"Plant.A.B",
+ "array":{"valueRank":1,"dimensions":[4]},
+ "alarm":{"type":"Hi","limit":90.5},
+ "someFutureScalar":"keep me"}
+ """).ToJson();
+
+ var o = JsonNode.Parse(json)!.AsObject();
+
+ // History intent is authored by a different seam (TagHistorizeConfig) — dropping it here would
+ // silently un-historize the tag on the next edit.
+ o["isHistorized"]!.GetValue().ShouldBeTrue();
+ o["historianTagname"]!.GetValue().ShouldBe("Plant.A.B");
+ // Nested objects/arrays must survive structurally, not just as scalars.
+ o["array"]!["valueRank"]!.GetValue().ShouldBe(1);
+ o["array"]!["dimensions"]!.AsArray()[0]!.GetValue().ShouldBe(4);
+ o["alarm"]!["limit"]!.GetValue().ShouldBe(90.5);
+ o["someFutureScalar"]!.GetValue().ShouldBe("keep me");
+ }
+
+ [Fact]
+ public void FromJson_then_ToJson_preserves_sparkplug_keys_task24_will_own()
+ {
+ var json = MqttTagConfigModel.FromJson(
+ """{"groupId":"Plant1","edgeNodeId":"E1","deviceId":"D1","metricName":"Temp"}""").ToJson();
+
+ var o = JsonNode.Parse(json)!.AsObject();
+ o["groupId"]!.GetValue().ShouldBe("Plant1");
+ o["edgeNodeId"]!.GetValue().ShouldBe("E1");
+ o["deviceId"]!.GetValue().ShouldBe("D1");
+ o["metricName"]!.GetValue().ShouldBe("Temp");
+ }
+
+ [Fact]
+ public void FromJson_infers_SparkplugB_mode_from_the_descriptor_keys()
+ => MqttTagConfigModel
+ .FromJson("""{"groupId":"Plant1","edgeNodeId":"E1","metricName":"Temp"}""")
+ .Mode.ShouldBe(MqttMode.SparkplugB);
+
+ // ── Validate: Plain rules ────────────────────────────────────────────────────────────────────
+
+ [Fact]
+ public void Validate_ValidPlain_ReturnsNull()
+ => MqttTagConfigModel.FromJson(
+ """{"topic":"a/b","payloadFormat":"Raw","dataType":"String"}""")
+ .Validate().ShouldBeNull();
+
+ [Fact]
+ public void Validate_MissingTopic_Fails()
+ => MqttTagConfigModel.FromJson("""{"payloadFormat":"Raw","dataType":"String"}""")
+ .Validate().ShouldNotBeNullOrWhiteSpace();
+
+ [Theory]
+ [InlineData("a/+/c")]
+ [InlineData("a/#")]
+ public void Validate_PlainWildcardTopic_Fails(string topic)
+ => MqttTagConfigModel.FromJson($$"""{"topic":"{{topic}}","payloadFormat":"Raw","dataType":"String"}""")
+ .Validate().ShouldNotBeNullOrWhiteSpace();
+
+ [Fact]
+ public void Validate_JsonWithoutPath_Fails()
+ => MqttTagConfigModel.FromJson("""{"topic":"a/b","payloadFormat":"Json","dataType":"Float64"}""")
+ .Validate().ShouldNotBeNullOrWhiteSpace();
+
+ // Falsifiability control for the test above: the SAME blob with a jsonPath must validate clean, so
+ // the failure above is provably the missing jsonPath and not the topic or the dataType.
+ [Fact]
+ public void Validate_JsonWithPath_ReturnsNull()
+ => MqttTagConfigModel.FromJson("""{"topic":"a/b","payloadFormat":"Json","jsonPath":"$","dataType":"Float64"}""")
+ .Validate().ShouldBeNull();
+
+ [Fact]
+ public void Validate_NonJsonFormat_DoesNotRequireJsonPath()
+ => MqttTagConfigModel.FromJson("""{"topic":"a/b","payloadFormat":"Scalar","dataType":"Float64"}""")
+ .Validate().ShouldBeNull();
+
+ [Theory]
+ [InlineData("3")]
+ [InlineData("-1")]
+ [InlineData("\"high\"")]
+ [InlineData("1.5")]
+ public void Validate_InvalidQos_Fails(string qosLiteral)
+ => MqttTagConfigModel.FromJson($$"""{"topic":"a/b","payloadFormat":"Raw","dataType":"String","qos":{{qosLiteral}}}""")
+ .Validate().ShouldNotBeNullOrWhiteSpace();
+
+ [Theory]
+ [InlineData(0)]
+ [InlineData(1)]
+ [InlineData(2)]
+ public void Validate_LegalQos_ReturnsNull(int qos)
+ => MqttTagConfigModel.FromJson($$"""{"topic":"a/b","payloadFormat":"Raw","dataType":"String","qos":{{qos}}}""")
+ .Validate().ShouldBeNull();
+
+ // The driver factory reads payloadFormat/dataType STRICTLY — a typo is rejected outright
+ // (BadNodeIdUnknown), never defaulted. The editor must not let such a blob past save while
+ // silently retyping it, so Validate reads the ORIGINAL key bag, not the defaulted typed field.
+ [Fact]
+ public void Validate_TypoedPayloadFormat_Fails()
+ => MqttTagConfigModel.FromJson("""{"topic":"a/b","payloadFormat":"Jason","dataType":"String"}""")
+ .Validate().ShouldNotBeNullOrWhiteSpace();
+
+ [Fact]
+ public void Validate_TypoedDataType_Fails()
+ => MqttTagConfigModel.FromJson("""{"topic":"a/b","payloadFormat":"Raw","dataType":"Double"}""")
+ .Validate().ShouldNotBeNullOrWhiteSpace();
+
+ // Once the editor has round-tripped the blob the typo is gone (ToJson rewrites the key with the
+ // typed field's name), so the strict check above can never wedge a tag the editor itself produced.
+ // NB a payloadFormat typo repairs to the Json default, which then legitimately demands a jsonPath —
+ // so the typo-repair claim is isolated here on dataType, whose default carries no follow-on rule.
+ [Fact]
+ public void Validate_AfterEditorRoundTrip_ClearsATypoedEnum()
+ {
+ var typoed = """{"topic":"a/b","payloadFormat":"Raw","dataType":"Double"}""";
+ MqttTagConfigModel.FromJson(typoed).Validate().ShouldNotBeNullOrWhiteSpace(); // pre-condition
+
+ var repaired = MqttTagConfigModel.FromJson(typoed).ToJson();
+
+ repaired.ShouldContain("\"dataType\":\"String\"");
+ 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.
+ [Fact]
+ public void Validate_SparkplugMode_IsNotSubjectToPlainRules()
+ => MqttTagConfigModel.FromJson("""{"groupId":"P1","edgeNodeId":"E1","metricName":"Temp"}""")
+ .Validate().ShouldBeNull();
+
+ // ── Dispatch registration ────────────────────────────────────────────────────────────────────
+
+ [Fact]
+ public void TagConfigEditorMap_resolves_the_Mqtt_editor()
+ => TagConfigEditorMap.Resolve("Mqtt").ShouldBe(
+ typeof(AdminUI.Components.Shared.Uns.TagEditors.MqttTagConfigEditor));
+
+ [Fact]
+ public void TagConfigValidator_dispatches_Mqtt()
+ => TagConfigValidator.Validate("Mqtt", """{"payloadFormat":"Raw"}""").ShouldNotBeNullOrWhiteSpace();
+}