diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttEquipmentTagParser.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttEquipmentTagParser.cs deleted file mode 100644 index 24e33600..00000000 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttEquipmentTagParser.cs +++ /dev/null @@ -1,164 +0,0 @@ -using System.Diagnostics.CodeAnalysis; -using System.Text.Json; -using ZB.MOM.WW.OtOpcUa.Core.Abstractions; - -namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt; - -/// -/// Turns an authored equipment tag's opaque wire reference — its TagConfig JSON blob -/// — into a typed . Mirrors ModbusTagDefinitionFactory: a -/// leading { marks a TagConfig blob, enum fields are read STRICTLY, and the two entry -/// points carry deliberately different strictness contracts. -/// -/// -/// -/// is the runtime path. It never throws; anything -/// malformed returns so the driver surfaces -/// BadNodeIdUnknown rather than a misleading Good off a wrong-typed -/// default. -/// -/// -/// -/// -/// is the deploy-time path. It returns human-readable -/// warnings so a bad tag config surfaces at deploy instead of silently going dark at -/// runtime. -/// -/// -/// -/// -/// Sparkplug-descriptor parsing (groupId / edgeNodeId / deviceId / -/// metricName) is a deliberate P1 stub — the fields exist on -/// but are never populated here until the P2 tasks fill them. -/// -/// -public static class MqttEquipmentTagParser -{ - /// The JSONPath applied when a Json-format blob omits jsonPath: the document root. - private const string RootJsonPath = "$"; - - /// The MQTT wildcard characters — illegal in a tag's (concrete) subscription topic. - private static readonly char[] TopicWildcards = ['+', '#']; - - /// - /// Parses a driver wire into a typed definition. Returns - /// — never throws — for a null/blank reference, a reference that is not - /// a TagConfig blob (no leading {), unparseable JSON, a missing or blank topic, a - /// qos outside 0–2, or a present-but-invalid (typo'd) payloadFormat / - /// dataType. The produced definition's is - /// itself, which is the key published values are routed on. - /// - /// A wildcard topic (+ / #) is deliberately NOT rejected here: it is - /// ambiguous rather than unparseable, and is the operator-visible - /// surface for it. Rejecting it at runtime would turn an authoring mistake into a silent - /// BadNodeIdUnknown with no stated cause. - /// - /// - /// The tag's wire reference (its authored TagConfig JSON). - /// The parsed definition when this returns ; otherwise . - /// when is a valid MQTT tag config. - public static bool TryParse(string reference, [NotNullWhen(true)] out MqttTagDefinition? def) - { - def = null; - if (string.IsNullOrWhiteSpace(reference) || reference[0] != '{') return false; - try - { - using var doc = JsonDocument.Parse(reference); - var root = doc.RootElement; - if (root.ValueKind != JsonValueKind.Object) return false; - - // topic is the whole point of a Plain-mode tag — without one there is nothing to subscribe - // to, so an absent/blank value is a hard reject rather than a defaulted empty subscription. - var topic = ReadString(root, "topic"); - if (string.IsNullOrWhiteSpace(topic)) return false; - - // Strict enum reads: a present-but-invalid (typo'd) value rejects the whole tag - // (→ BadNodeIdUnknown) instead of silently defaulting to a wrong-typed Good. - if (!TagConfigJson.TryReadEnumStrict(root, "payloadFormat", MqttPayloadFormat.Json, out var payloadFormat)) - return false; - if (!TagConfigJson.TryReadEnumStrict(root, "dataType", DriverDataType.String, out var dataType)) - return false; - - // qos is an MQTT protocol field with exactly three legal values; anything else would be - // rejected by the broker at SUBSCRIBE time, so reject it here where the cause is visible. - var qos = ReadOptionalInt(root, "qos"); - if (qos is { } q && q is < 0 or > 2) return false; - - var jsonPath = ReadString(root, "jsonPath"); - if (string.IsNullOrEmpty(jsonPath)) jsonPath = RootJsonPath; - - def = new MqttTagDefinition( - Name: reference, - Topic: topic, - PayloadFormat: payloadFormat, - JsonPath: jsonPath, - DataType: dataType, - Qos: qos, - RetainSeed: ReadBoolOrDefault(root, "retainSeed", defaultValue: true)); - return true; - } - catch (JsonException) { return false; } - catch (FormatException) { return false; } - catch (InvalidOperationException) { return false; } - } - - /// - /// Deploy-time inspection of an equipment-tag TagConfig blob. Returns human-readable - /// warnings for a structurally unparseable blob (which the runtime turns into a silent - /// BadNodeIdUnknown), for present-but-invalid payloadFormat / dataType - /// enum values, and for a wildcard tag topic (a tag bound to + / # would - /// receive values from many topics — ambiguous, and almost never what the operator meant). - /// Empty when the blob is clean or is not a TagConfig object at all. Never throws. - /// - /// The equipment tag's TagConfig JSON. - /// The warnings; empty when clean. - public static IReadOnlyList Inspect(string reference) - { - var warnings = new List(); - if (string.IsNullOrWhiteSpace(reference) || reference[0] != '{') return warnings; - try - { - using var doc = JsonDocument.Parse(reference); - var root = doc.RootElement; - if (root.ValueKind != JsonValueKind.Object) - { - warnings.Add("Mqtt TagConfig root is not a JSON object — the tag will not resolve (BadNodeIdUnknown)."); - return warnings; - } - foreach (var w in new[] - { - TagConfigJson.DescribeInvalidEnum(root, "payloadFormat"), - TagConfigJson.DescribeInvalidEnum(root, "dataType"), - }) - { - if (w is not null) warnings.Add(w); - } - var topic = ReadString(root, "topic"); - if (topic.IndexOfAny(TopicWildcards) >= 0) - { - warnings.Add( - $"topic '{topic}' contains an MQTT wildcard (+ or #); a tag's topic must be concrete, " + - "or the tag will be fed by every matching topic."); - } - } - catch (JsonException) - { - warnings.Add("Mqtt TagConfig is not valid JSON — the tag will not resolve (BadNodeIdUnknown)."); - } - return warnings; - } - - private static string ReadString(JsonElement o, string name) - => o.TryGetProperty(name, out var e) && e.ValueKind == JsonValueKind.String - ? e.GetString() ?? "" - : ""; - - private static int? ReadOptionalInt(JsonElement o, string name) - => o.TryGetProperty(name, out var e) && e.ValueKind == JsonValueKind.Number - && e.TryGetInt32(out var v) ? v : null; - - private static bool ReadBoolOrDefault(JsonElement o, string name, bool defaultValue) - => o.TryGetProperty(name, out var e) && e.ValueKind is JsonValueKind.True or JsonValueKind.False - ? e.GetBoolean() - : defaultValue; -} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttTagDefinition.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttTagDefinition.cs index 0d139be5..d4f6663b 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttTagDefinition.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttTagDefinition.cs @@ -3,10 +3,10 @@ using ZB.MOM.WW.OtOpcUa.Core.Abstractions; namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt; /// -/// The driver's internal per-tag descriptor — the parsed form of an authored equipment tag's -/// TagConfig JSON, produced by and looked up -/// through the shared exactly as Modbus does. See -/// docs/plans/2026-07-15-mqtt-sparkplug-driver-design.md §5.2 / §5.3. +/// The driver's internal per-tag descriptor — the parsed form of an authored raw tag's +/// TagConfig JSON, produced by and +/// looked up through the shared exactly as Modbus does. +/// See docs/plans/2026-07-15-mqtt-sparkplug-driver-design.md §5.2 / §5.3. /// /// The record carries both ingest shapes: the Plain-MQTT fields /// (), populated in P1, and the Sparkplug B @@ -18,9 +18,12 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt; /// /// /// -/// The definition's identity: the exact wire reference string the driver was handed -/// (the tag's authored TagConfig blob). Published values key the forward router on this, -/// so it must round-trip byte-for-byte. +/// The definition's identity: the tag's RawPath — the cluster-scoped slash path that is the +/// v3 driver wire reference. This is the key resolves +/// on, the key OnDataChange must publish under, and the key DriverHostActor fans out +/// to the raw + UNS NodeIds. It is emphatically not the authored TagConfig blob: the +/// pre-v3 blob-as-reference shape is retired, and publishing under a blob key would silently miss +/// the RawPath-keyed fan-out while every unit test still passed. /// /// The concrete MQTT topic this tag subscribes to (Plain mode). /// How the received payload is decoded (Plain mode). diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttTagDefinitionFactory.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttTagDefinitionFactory.cs new file mode 100644 index 00000000..697256b1 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttTagDefinitionFactory.cs @@ -0,0 +1,198 @@ +using System.Text.Json; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt; + +/// +/// v3 pure mapper: turns an authored raw tag's TagConfig JSON (the shape produced by the +/// AdminUI MqttTagConfigModel) into an . Under v3 a tag's +/// identity is its RawPath (a cluster-scoped slash path), not the address blob — so the +/// produced definition's is the RawPath the driver was handed, +/// which is exactly the wire reference the driver's RawPath → def resolver keys on. The driver +/// builds that table by mapping each the deploy artifact delivers through +/// . +/// +/// Two entry points carry deliberately different strictness contracts. +/// is the runtime path: it never throws, and anything +/// malformed returns so the driver surfaces BadNodeIdUnknown +/// rather than a misleading Good off a wrong-typed default. is the +/// deploy-time path: it returns human-readable warnings so a bad tag config surfaces at +/// deploy instead of silently going dark at runtime. +/// +/// +/// Sparkplug-descriptor parsing (groupId / edgeNodeId / deviceId / +/// metricName) is a deliberate P1 stub — the fields exist on +/// but are never populated here until the P2 tasks fill them. +/// +/// +/// No ToTagConfig inverse — a deliberate YAGNI call. The six sibling factories +/// each carry one solely to serve their Driver.<X>.Cli project, which synthesises +/// blobs from operator flags; the MQTT plan defines no +/// Mqtt.Cli. The AdminUI typed editor does not need one either — the +/// <Driver>TagConfigModel template serialises through its own preserved +/// JsonObject key bag and references no driver factory (verified: no +/// TagDefinitionFactory reference exists anywhere under the AdminUI project). Add the +/// inverse when a real caller appears, not before. +/// +/// +public static class MqttTagDefinitionFactory +{ + /// The JSONPath applied when a Json-format blob omits jsonPath: the document root. + private const string RootJsonPath = "$"; + + /// The MQTT wildcard characters — illegal in a tag's (concrete) subscription topic. + private static readonly char[] TopicWildcards = ['+', '#']; + + /// + /// Maps an authored TagConfig object to a typed definition keyed by . + /// The input is always an authored TagConfig JSON object (there is no name-vs-blob heuristic in v3). + /// Enum fields (payloadFormat / dataType) and qos are read STRICTLY — a + /// present-but-invalid (typo'd) value rejects the whole tag (returns ⇒ the + /// driver surfaces BadNodeIdUnknown) rather than silently defaulting to a wrong-typed Good or + /// a downgraded delivery guarantee. Never throws. + /// + /// A wildcard topic (+ / #) is deliberately NOT rejected here: it is + /// ambiguous rather than unparseable, and is the operator-visible surface + /// for it. Rejecting it at runtime would turn an authoring mistake into a silent + /// BadNodeIdUnknown with no stated cause. + /// + /// + /// The authored equipment-tag TagConfig JSON. + /// The tag's RawPath — becomes the definition's identity (Name). + /// The mapped definition when this returns . + /// when is a valid MQTT tag-config object. + public static bool FromTagConfig(string tagConfig, string rawPath, out MqttTagDefinition def) + { + def = null!; + if (string.IsNullOrWhiteSpace(tagConfig) || tagConfig[0] != '{') return false; + try + { + using var doc = JsonDocument.Parse(tagConfig); + var root = doc.RootElement; + if (root.ValueKind != JsonValueKind.Object) return false; + + // topic is the whole point of a Plain-mode tag — without one there is nothing to subscribe + // to, so an absent/blank value is a hard reject rather than a defaulted empty subscription. + var topic = ReadString(root, "topic"); + if (string.IsNullOrWhiteSpace(topic)) return false; + + // Strict enum reads: a present-but-invalid (typo'd) value rejects the whole tag + // (→ BadNodeIdUnknown) instead of silently defaulting to a wrong-typed Good. + if (!TagConfigJson.TryReadEnumStrict(root, "payloadFormat", MqttPayloadFormat.Json, out var payloadFormat)) + return false; + if (!TagConfigJson.TryReadEnumStrict(root, "dataType", DriverDataType.String, out var dataType)) + return false; + + // qos is read with the same strictness, for the same reason: silently absorbing a malformed + // "qos":"high" / "qos":1.5 / "qos":5 into the driver-level default would hand the operator a + // WEAKER delivery guarantee than the one they authored, with nothing to show for it. + if (!TryReadQosStrict(root, out var qos)) return false; + + var jsonPath = ReadString(root, "jsonPath"); + if (string.IsNullOrEmpty(jsonPath)) jsonPath = RootJsonPath; + + def = new MqttTagDefinition( + Name: rawPath, + Topic: topic, + PayloadFormat: payloadFormat, + JsonPath: jsonPath, + DataType: dataType, + Qos: qos, + RetainSeed: ReadBoolOrDefault(root, "retainSeed", defaultValue: true)); + return true; + } + catch (JsonException) { return false; } + catch (FormatException) { return false; } + catch (InvalidOperationException) { return false; } + } + + /// + /// Deploy-time inspection of an equipment-tag TagConfig blob. Returns human-readable + /// warnings for a structurally unparseable blob (which the runtime turns into a silent + /// BadNodeIdUnknown), for present-but-invalid payloadFormat / dataType / + /// qos values, and for a wildcard tag topic (a tag bound to + / # + /// would receive values from many topics — ambiguous, and almost never what the operator meant). + /// Empty when the blob is clean or is not an equipment-tag TagConfig object. Never throws. + /// + /// The equipment tag's TagConfig JSON. + /// The warnings; empty when clean. + public static IReadOnlyList Inspect(string reference) + { + var warnings = new List(); + if (string.IsNullOrWhiteSpace(reference) || reference[0] != '{') return warnings; + try + { + using var doc = JsonDocument.Parse(reference); + var root = doc.RootElement; + if (root.ValueKind != JsonValueKind.Object) + { + warnings.Add("Mqtt TagConfig root is not a JSON object — the tag will not resolve (BadNodeIdUnknown)."); + return warnings; + } + foreach (var w in new[] + { + TagConfigJson.DescribeInvalidEnum(root, "payloadFormat"), + TagConfigJson.DescribeInvalidEnum(root, "dataType"), + DescribeInvalidQos(root), + }) + { + if (w is not null) warnings.Add(w); + } + var topic = ReadString(root, "topic"); + if (topic.IndexOfAny(TopicWildcards) >= 0) + { + warnings.Add( + $"value '{topic}' for 'topic' contains an MQTT wildcard (+ or #); a tag's topic must be " + + "concrete, or the tag will be fed by every matching topic."); + } + } + catch (JsonException) + { + warnings.Add("Mqtt TagConfig is not valid JSON — the tag will not resolve (BadNodeIdUnknown)."); + } + return warnings; + } + + /// + /// Strict qos read, mirroring 's + /// absent / valid / present-but-invalid split: absent ⇒ (the driver-level + /// wins); a JSON integer in 0–2 ⇒ that value; anything + /// else present (non-number, non-integer, or out of range) ⇒ the read FAILS. + /// + /// The TagConfig root object. + /// The parsed QoS, or when the field is absent. + /// only when qos is present but not a legal MQTT QoS. + private static bool TryReadQosStrict(JsonElement o, out int? qos) + { + qos = null; + if (!o.TryGetProperty("qos", out var e)) return true; // absent + if (e.ValueKind != JsonValueKind.Number || !e.TryGetInt32(out var v)) return false; + if (v is < 0 or > 2) return false; + qos = v; + return true; + } + + /// + /// A human-readable warning for a present-but-invalid qos field, or + /// when it is absent or valid — the qos counterpart of + /// . + /// + /// The TagConfig root object. + /// The warning text, or . + private static string? DescribeInvalidQos(JsonElement o) + { + if (!o.TryGetProperty("qos", out var e)) return null; + if (e.ValueKind == JsonValueKind.Number && e.TryGetInt32(out var v) && v is >= 0 and <= 2) return null; + return $"value '{e.GetRawText()}' for 'qos' is not a valid MQTT QoS; valid: 0, 1, 2"; + } + + private static string ReadString(JsonElement o, string name) + => o.TryGetProperty(name, out var e) && e.ValueKind == JsonValueKind.String + ? e.GetString() ?? "" + : ""; + + private static bool ReadBoolOrDefault(JsonElement o, string name, bool defaultValue) + => o.TryGetProperty(name, out var e) && e.ValueKind is JsonValueKind.True or JsonValueKind.False + ? e.GetBoolean() + : defaultValue; +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttEquipmentTagParserTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttEquipmentTagParserTests.cs deleted file mode 100644 index 037f7b2a..00000000 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttEquipmentTagParserTests.cs +++ /dev/null @@ -1,143 +0,0 @@ -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.Core.Abstractions; -using ZB.MOM.WW.OtOpcUa.Driver.Mqtt; - -namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests; - -/// -/// Covers the two entry points of and their deliberately -/// different strictness contracts: TryParse is the runtime path (never throws, a -/// malformed blob is a hard reject that upstream turns into BadNodeIdUnknown) and -/// Inspect is the deploy-time path (human-readable warnings so a bad tag surfaces at -/// deploy instead of going dark at runtime). -/// -public sealed class MqttEquipmentTagParserTests -{ - [Fact] - public void TryParse_PlainJsonBlob_PopulatesTopicAndPath() - { - // NOTE: the plan's sample blob wrote "dataType":"Double"; the authoritative type is - // DriverDataType (design §6.1), whose 64-bit float member is Float64. "Double" is - // therefore a typo the strict read rejects — pinned by TryParse_TypoedDataType_RejectsStrict. - const string r = """{"topic":"factory/oven/temp","payloadFormat":"Json","jsonPath":"$.value","dataType":"Float64","qos":1}"""; - MqttEquipmentTagParser.TryParse(r, out var def).ShouldBeTrue(); - def!.Topic.ShouldBe("factory/oven/temp"); - def.PayloadFormat.ShouldBe(MqttPayloadFormat.Json); - def.JsonPath.ShouldBe("$.value"); - def.DataType.ShouldBe(DriverDataType.Float64); - def.Qos.ShouldBe(1); - def.Name.ShouldBe(r); // the def Name == reference string (forward-router key) - } - - [Fact] - public void TryParse_TypoedPayloadFormat_RejectsStrict() - => MqttEquipmentTagParser.TryParse( - """{"topic":"a/b","payloadFormat":"Jason","dataType":"Float64"}""", out _).ShouldBeFalse(); - - [Fact] - public void TryParse_TypoedDataType_RejectsStrict() - => MqttEquipmentTagParser.TryParse( - """{"topic":"a/b","payloadFormat":"Json","dataType":"Double"}""", out _).ShouldBeFalse(); - - [Fact] - public void Inspect_WildcardTopic_ReturnsWarning() - { - // The blob is otherwise clean (payloadFormat parses, dataType absent), so the wildcard check - // is the ONLY thing that can produce a warning here — the assertion cannot pass vacuously. - var warnings = MqttEquipmentTagParser.Inspect("""{"topic":"a/+/c","payloadFormat":"Raw"}"""); - warnings.ShouldNotBeEmpty(); - warnings.ShouldHaveSingleItem().ShouldContain("wildcard", Case.Insensitive); - } - - [Theory] - [InlineData("a/#")] - [InlineData("+/b/c")] - [InlineData("a/+/c")] - public void Inspect_EachWildcardForm_Warns(string topic) - => MqttEquipmentTagParser.Inspect($$"""{"topic":"{{topic}}","payloadFormat":"Raw"}""").ShouldNotBeEmpty(); - - [Fact] - public void TryParse_WildcardTopic_StillParses_WarningIsDeployTimeOnly() - { - // A wildcard tag topic is ambiguous, not unparseable: the deploy-time Inspect pass is the - // designed surface for it. Rejecting it at runtime would turn an authoring mistake into a - // silent BadNodeIdUnknown with no operator-visible cause. - MqttEquipmentTagParser.TryParse("""{"topic":"a/+/c","payloadFormat":"Raw"}""", out var def).ShouldBeTrue(); - def!.Topic.ShouldBe("a/+/c"); - } - - [Fact] - public void TryParse_RawFormat_AppliesDefaults() - { - const string r = """{"topic":"factory/oven/blob","payloadFormat":"Raw"}"""; - MqttEquipmentTagParser.TryParse(r, out var def).ShouldBeTrue(); - def!.PayloadFormat.ShouldBe(MqttPayloadFormat.Raw); - def.JsonPath.ShouldBe("$"); // absent ⇒ the root default - def.Qos.ShouldBeNull(); // absent ⇒ the driver-level DefaultQos wins - def.RetainSeed.ShouldBeTrue(); // absent ⇒ seed from the retained message - def.DataType.ShouldBe(DriverDataType.String); // absent ⇒ the documented fallback - } - - [Fact] - public void TryParse_ExplicitRetainSeedFalse_IsHonoured() - { - MqttEquipmentTagParser.TryParse( - """{"topic":"a/b","payloadFormat":"Raw","retainSeed":false}""", out var def).ShouldBeTrue(); - def!.RetainSeed.ShouldBeFalse(); - } - - [Fact] - public void TryParse_PlainBlob_LeavesSparkplugDescriptorFieldsNull() - { - // P2 (Tasks 15–26) fills these; a plain-mode blob must leave them unset so a future - // mode discriminator cannot mistake a plain tag for a Sparkplug one. - MqttEquipmentTagParser.TryParse( - """{"topic":"a/b","payloadFormat":"Raw"}""", out var def).ShouldBeTrue(); - def!.GroupId.ShouldBeNull(); - def.EdgeNodeId.ShouldBeNull(); - def.DeviceId.ShouldBeNull(); - def.MetricName.ShouldBeNull(); - } - - [Theory] - [InlineData(null)] - [InlineData("")] - [InlineData(" ")] - [InlineData("factory/oven/temp")] // a bare reference, not a TagConfig blob (no leading '{') - [InlineData("[1,2,3]")] // valid JSON, wrong root kind - [InlineData("{ not json at all")] // unparseable — must not throw - [InlineData("""{"payloadFormat":"Raw"}""")] // no topic ⇒ nothing to subscribe to - [InlineData("""{"topic":"","payloadFormat":"Raw"}""")] // blank topic - [InlineData("""{"topic":"a/b","qos":5}""")] // QoS outside 0–2 is an illegal subscription - public void TryParse_MalformedReference_ReturnsFalseAndNeverThrows(string? reference) - => MqttEquipmentTagParser.TryParse(reference!, out _).ShouldBeFalse(); - - [Fact] - public void Inspect_CleanBlob_ReturnsEmpty() - => MqttEquipmentTagParser.Inspect( - """{"topic":"a/b","payloadFormat":"Json","jsonPath":"$.v","dataType":"Float64"}""").ShouldBeEmpty(); - - [Fact] - public void Inspect_TypoedEnums_WarnsPerField() - { - var warnings = MqttEquipmentTagParser.Inspect( - """{"topic":"a/b","payloadFormat":"Jason","dataType":"Double"}"""); - warnings.Count.ShouldBe(2); - warnings.ShouldContain(w => w.Contains("payloadFormat", StringComparison.Ordinal)); - warnings.ShouldContain(w => w.Contains("dataType", StringComparison.Ordinal)); - } - - [Fact] - public void Inspect_UnparseableBlob_Warns() - => MqttEquipmentTagParser.Inspect("{ not json at all").ShouldNotBeEmpty(); - - [Theory] - [InlineData(null)] - [InlineData("")] - [InlineData(" ")] - [InlineData("factory/oven/temp")] - [InlineData("[1,2,3]")] // no leading '{' ⇒ not a TagConfig blob at all (mirrors Modbus) - public void Inspect_NotATagConfigBlob_ReturnsEmpty(string? reference) - => MqttEquipmentTagParser.Inspect(reference!).ShouldBeEmpty(); -} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttTagDefinitionFactoryTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttTagDefinitionFactoryTests.cs new file mode 100644 index 00000000..6bd57140 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttTagDefinitionFactoryTests.cs @@ -0,0 +1,224 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.Mqtt; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests; + +/// +/// Covers the two entry points of and their deliberately +/// different strictness contracts: FromTagConfig is the runtime path (never throws, a +/// malformed blob is a hard reject that upstream turns into BadNodeIdUnknown) and +/// Inspect is the deploy-time path (human-readable warnings so a bad tag surfaces at +/// deploy instead of going dark at runtime). +/// +public sealed class MqttTagDefinitionFactoryTests +{ + private const string RawPath = "Plant/Mqtt/oven/Temp"; + + [Fact] + public void FromTagConfig_PlainJsonBlob_PopulatesTopicAndPath() + { + // NOTE: the plan's sample blob wrote "dataType":"Double"; the authoritative type is + // DriverDataType (design §6.1), whose 64-bit float member is Float64. "Double" is therefore a + // typo the strict read rejects — pinned by FromTagConfig_TypoedDataType_RejectsStrict. + const string blob = """{"topic":"factory/oven/temp","payloadFormat":"Json","jsonPath":"$.value","dataType":"Float64","qos":1}"""; + MqttTagDefinitionFactory.FromTagConfig(blob, RawPath, out var def).ShouldBeTrue(); + def.Topic.ShouldBe("factory/oven/temp"); + def.PayloadFormat.ShouldBe(MqttPayloadFormat.Json); + def.JsonPath.ShouldBe("$.value"); + def.DataType.ShouldBe(DriverDataType.Float64); + def.Qos.ShouldBe(1); + } + + /// + /// The v3 identity contract, stated explicitly so a regression back to blob-keying goes red. + /// MUST be the RawPath the driver was handed — it is the key + /// EquipmentTagRefResolver looks up, the key OnDataChange publishes under, and the + /// key DriverHostActor fans out to the raw + UNS NodeIds. Keying it by the TagConfig blob + /// (the pre-v3, now-retired shape) would leave the driver silently dead in production while every + /// other test here still passed. + /// + [Fact] + public void FromTagConfig_DefinitionIdentity_IsTheRawPath_NotTheBlob() + { + const string blob = """{"topic":"factory/oven/temp","payloadFormat":"Raw"}"""; + MqttTagDefinitionFactory.FromTagConfig(blob, RawPath, out var def).ShouldBeTrue(); + def.Name.ShouldBe(RawPath); + def.Name.ShouldNotBe(blob); + } + + /// + /// The identity is whatever RawPath the caller supplies — the factory must never re-derive it + /// from the blob's contents (e.g. from topic), or two tags sharing a topic would collide. + /// + [Theory] + [InlineData("Plant/Mqtt/oven/Temp")] + [InlineData("SiteA/Mqtt/line3/Oven/Setpoint")] + public void FromTagConfig_UsesSuppliedRawPathVerbatim(string rawPath) + { + MqttTagDefinitionFactory.FromTagConfig( + """{"topic":"shared/topic","payloadFormat":"Raw"}""", rawPath, out var def).ShouldBeTrue(); + def.Name.ShouldBe(rawPath); + } + + [Fact] + public void FromTagConfig_TypoedPayloadFormat_RejectsStrict() + => MqttTagDefinitionFactory.FromTagConfig( + """{"topic":"a/b","payloadFormat":"Jason","dataType":"Float64"}""", RawPath, out _).ShouldBeFalse(); + + [Fact] + public void FromTagConfig_TypoedDataType_RejectsStrict() + => MqttTagDefinitionFactory.FromTagConfig( + """{"topic":"a/b","payloadFormat":"Json","dataType":"Double"}""", RawPath, out _).ShouldBeFalse(); + + [Fact] + public void Inspect_WildcardTopic_ReturnsWarning() + { + // The blob is otherwise clean (payloadFormat parses, dataType + qos absent), so the wildcard + // check is the ONLY thing that can produce a warning — this cannot pass vacuously. + var warnings = MqttTagDefinitionFactory.Inspect("""{"topic":"a/+/c","payloadFormat":"Raw"}"""); + warnings.ShouldNotBeEmpty(); + warnings.ShouldHaveSingleItem().ShouldContain("wildcard", Case.Insensitive); + } + + [Theory] + [InlineData("a/#")] + [InlineData("+/b/c")] + [InlineData("a/+/c")] + public void Inspect_EachWildcardForm_Warns(string topic) + => MqttTagDefinitionFactory.Inspect($$"""{"topic":"{{topic}}","payloadFormat":"Raw"}""").ShouldNotBeEmpty(); + + [Fact] + public void FromTagConfig_WildcardTopic_StillParses_WarningIsDeployTimeOnly() + { + // A wildcard tag topic is ambiguous, not unparseable: the deploy-time Inspect pass is the + // designed surface for it. Rejecting it at runtime would turn an authoring mistake into a + // silent BadNodeIdUnknown with no operator-visible cause. + MqttTagDefinitionFactory.FromTagConfig( + """{"topic":"a/+/c","payloadFormat":"Raw"}""", RawPath, out var def).ShouldBeTrue(); + def.Topic.ShouldBe("a/+/c"); + } + + [Fact] + public void FromTagConfig_RawFormat_AppliesDefaults() + { + MqttTagDefinitionFactory.FromTagConfig( + """{"topic":"factory/oven/blob","payloadFormat":"Raw"}""", RawPath, out var def).ShouldBeTrue(); + def.PayloadFormat.ShouldBe(MqttPayloadFormat.Raw); + def.JsonPath.ShouldBe("$"); // absent ⇒ the root default + def.Qos.ShouldBeNull(); // absent ⇒ the driver-level DefaultQos wins + def.RetainSeed.ShouldBeTrue(); // absent ⇒ seed from the retained message + def.DataType.ShouldBe(DriverDataType.String); // absent ⇒ the documented fallback + } + + [Fact] + public void FromTagConfig_ExplicitRetainSeedFalse_IsHonoured() + { + MqttTagDefinitionFactory.FromTagConfig( + """{"topic":"a/b","payloadFormat":"Raw","retainSeed":false}""", RawPath, out var def).ShouldBeTrue(); + def.RetainSeed.ShouldBeFalse(); + } + + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(2)] + public void FromTagConfig_EachLegalQos_IsAccepted(int qos) + { + MqttTagDefinitionFactory.FromTagConfig( + $$"""{"topic":"a/b","payloadFormat":"Raw","qos":{{qos}}}""", RawPath, out var def).ShouldBeTrue(); + def.Qos.ShouldBe(qos); + } + + [Fact] + public void FromTagConfig_PlainBlob_LeavesSparkplugDescriptorFieldsNull() + { + // P2 (Tasks 15–26) fills these; a plain-mode blob must leave them unset so a future + // mode discriminator cannot mistake a plain tag for a Sparkplug one. + MqttTagDefinitionFactory.FromTagConfig( + """{"topic":"a/b","payloadFormat":"Raw"}""", RawPath, out var def).ShouldBeTrue(); + def.GroupId.ShouldBeNull(); + def.EdgeNodeId.ShouldBeNull(); + def.DeviceId.ShouldBeNull(); + def.MetricName.ShouldBeNull(); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("factory/oven/temp")] // a bare reference, not a TagConfig blob (no leading '{') + [InlineData("[1,2,3]")] // valid JSON, wrong root kind + [InlineData("{ not json at all")] // unparseable — must not throw + [InlineData("""{"payloadFormat":"Raw"}""")] // no topic ⇒ nothing to subscribe to + [InlineData("""{"topic":"","payloadFormat":"Raw"}""")] // blank topic + [InlineData("""{"topic":" ","payloadFormat":"Raw"}""")] // whitespace-only topic + public void FromTagConfig_MalformedBlob_ReturnsFalseAndNeverThrows(string? tagConfig) + => MqttTagDefinitionFactory.FromTagConfig(tagConfig!, RawPath, out _).ShouldBeFalse(); + + /// + /// A present-but-invalid qos is rejected in every malformed shape, not just the + /// out-of-range numeric one. Silently absorbing "high" / 1.5 into the driver-level + /// default would hand the operator a WEAKER delivery guarantee than the one they authored. + /// + [Theory] + [InlineData("5")] // out of range + [InlineData("-1")] // out of range + [InlineData("\"high\"")] // wrong JSON type + [InlineData("\"1\"")] // stringly-typed number + [InlineData("1.5")] // non-integer + [InlineData("true")] // wrong JSON type + [InlineData("null")] // an explicit null is not "absent" + public void FromTagConfig_MalformedQos_RejectsStrict(string qosToken) + => MqttTagDefinitionFactory.FromTagConfig( + $$"""{"topic":"a/b","payloadFormat":"Raw","qos":{{qosToken}}}""", RawPath, out _).ShouldBeFalse(); + + [Fact] + public void Inspect_CleanBlob_ReturnsEmpty() + => MqttTagDefinitionFactory.Inspect( + """{"topic":"a/b","payloadFormat":"Json","jsonPath":"$.v","dataType":"Float64","qos":2}""").ShouldBeEmpty(); + + [Fact] + public void Inspect_TypoedEnums_WarnsPerField() + { + var warnings = MqttTagDefinitionFactory.Inspect( + """{"topic":"a/b","payloadFormat":"Jason","dataType":"Double"}"""); + warnings.Count.ShouldBe(2); + warnings.ShouldContain(w => w.Contains("payloadFormat", StringComparison.Ordinal)); + warnings.ShouldContain(w => w.Contains("dataType", StringComparison.Ordinal)); + } + + [Theory] + [InlineData("5")] + [InlineData("\"high\"")] + [InlineData("1.5")] + public void Inspect_MalformedQos_Warns(string qosToken) + { + var warnings = MqttTagDefinitionFactory.Inspect( + $$"""{"topic":"a/b","payloadFormat":"Raw","qos":{{qosToken}}}"""); + warnings.ShouldHaveSingleItem().ShouldContain("qos", Case.Sensitive); + } + + /// Every warning source fires together — the pass reports all of them, not just the first. + [Fact] + public void Inspect_MultipleProblems_ReportsAll() + { + var warnings = MqttTagDefinitionFactory.Inspect( + """{"topic":"a/+/c","payloadFormat":"Jason","dataType":"Double","qos":9}"""); + warnings.Count.ShouldBe(4); + } + + [Fact] + public void Inspect_UnparseableBlob_Warns() + => MqttTagDefinitionFactory.Inspect("{ not json at all").ShouldNotBeEmpty(); + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("factory/oven/temp")] + [InlineData("[1,2,3]")] // no leading '{' ⇒ not a TagConfig blob at all (mirrors Modbus) + public void Inspect_NotATagConfigBlob_ReturnsEmpty(string? reference) + => MqttTagDefinitionFactory.Inspect(reference!).ShouldBeEmpty(); +}