From 36abd871a16f6c5701fca1b04db71cf6c913a772 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 24 Jul 2026 17:17:37 -0400 Subject: [PATCH] feat(mqtt): typed tag editor + validator (plain mode) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Thin typed model over a preserved JsonObject key bag, mirroring the sibling TagConfigModel template. Key names and strictness rules mirror MqttTagDefinitionFactory rather than inventing an editor-side schema; enums round-trip as NAMES (the factory reads them strictly by name). No FullName key: under v3 a tag is identified by its RawPath, which the factory keys the definition's Name off — a composed identity key here would be dead weight nothing reads. Mode is a UI-only sub-shape selector inferred from the blob (any Sparkplug descriptor key present) and never serialised — the driver takes Plain vs SparkplugB from its DRIVER config. Only Plain Validate() is implemented; the Sparkplug branch is a stub Task 24 fills, and its keys are preserved untouched. Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW --- .../Uns/TagEditors/MqttTagConfigEditor.razor | 141 ++++++++++ .../Uns/TagEditors/MqttTagConfigModel.cs | 216 +++++++++++++++ .../Uns/TagEditors/TagConfigEditorMap.cs | 1 + .../Uns/TagEditors/TagConfigValidator.cs | 1 + .../Uns/MqttTagConfigModelTests.cs | 254 ++++++++++++++++++ 5 files changed, 613 insertions(+) create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/MqttTagConfigEditor.razor create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/MqttTagConfigModel.cs create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/MqttTagConfigModelTests.cs 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 + +
+
+ + +
+ + @if (_m.Mode == MqttMode.Plain) + { +
+ + +
Concrete topic — MQTT wildcards (+ / #) are not valid for a tag.
+
+
+ + +
+ @if (_m.PayloadFormat == MqttPayloadFormat.Json) + { +
+ + +
Use $ for the whole document.
+
+ } +
+ + @* 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 + { +
+ +
+ } + + @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(); +}