fix(mqtt): key tag definitions by RawPath, not the TagConfig blob
Task 2 review follow-up. The plan specified MqttEquipmentTagParser.TryParse(reference)
with def.Name = the TagConfig blob, and told us to mirror a type named
ModbusEquipmentTagParser. Both are plan defects:
- EquipmentTagRefResolver documents that a v3 driver reference "is now always a
RawPath" and that the blob-parse fallback is retired. Keying Name by the blob
would make OnDataChange publish under a reference that never matches the
RawPath-keyed fan-out in DriverHostActor - silently dead in production with
every unit test still green.
- ModbusEquipmentTagParser does not exist. The six sibling drivers all use
<Driver>TagDefinitionFactory.FromTagConfig(tagConfig, rawPath, out def).
Changes:
- Rename MqttEquipmentTagParser -> MqttTagDefinitionFactory; TryParse(reference,
out def) -> FromTagConfig(tagConfig, rawPath, out def) setting Name: rawPath,
matching ModbusTagDefinitionFactory's structure, param docs and guard order.
- Pin the identity contract with a dedicated test so a regression to blob-keying
goes red.
- Read qos with the same strictness as the enums: a present-but-invalid qos
("high" / 1.5 / 5 / null) now rejects the tag and is warned by Inspect,
instead of being silently absorbed into the driver-level default and handing
the operator a weaker delivery guarantee than the one they authored.
- No ToTagConfig inverse: the siblings carry one solely for their Driver.<X>.Cli
project, the MQTT plan defines none, and the AdminUI editor template
references no driver factory. Recorded as an explicit YAGNI call in the type
doc rather than added speculatively.
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
This commit is contained in:
@@ -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;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Turns an authored equipment tag's opaque wire <c>reference</c> — its <c>TagConfig</c> JSON blob
|
|
||||||
/// — into a typed <see cref="MqttTagDefinition"/>. Mirrors <c>ModbusTagDefinitionFactory</c>: a
|
|
||||||
/// leading <c>{</c> marks a TagConfig blob, enum fields are read STRICTLY, and the two entry
|
|
||||||
/// points carry deliberately different strictness contracts.
|
|
||||||
/// <list type="bullet">
|
|
||||||
/// <item>
|
|
||||||
/// <description>
|
|
||||||
/// <see cref="TryParse"/> is the <b>runtime</b> path. It never throws; anything
|
|
||||||
/// malformed returns <see langword="false"/> so the driver surfaces
|
|
||||||
/// <c>BadNodeIdUnknown</c> rather than a misleading <c>Good</c> off a wrong-typed
|
|
||||||
/// default.
|
|
||||||
/// </description>
|
|
||||||
/// </item>
|
|
||||||
/// <item>
|
|
||||||
/// <description>
|
|
||||||
/// <see cref="Inspect"/> is the <b>deploy-time</b> path. It returns human-readable
|
|
||||||
/// warnings so a bad tag config surfaces at deploy instead of silently going dark at
|
|
||||||
/// runtime.
|
|
||||||
/// </description>
|
|
||||||
/// </item>
|
|
||||||
/// </list>
|
|
||||||
/// <para>
|
|
||||||
/// Sparkplug-descriptor parsing (<c>groupId</c> / <c>edgeNodeId</c> / <c>deviceId</c> /
|
|
||||||
/// <c>metricName</c>) is a deliberate P1 stub — the fields exist on
|
|
||||||
/// <see cref="MqttTagDefinition"/> but are never populated here until the P2 tasks fill them.
|
|
||||||
/// </para>
|
|
||||||
/// </summary>
|
|
||||||
public static class MqttEquipmentTagParser
|
|
||||||
{
|
|
||||||
/// <summary>The JSONPath applied when a Json-format blob omits <c>jsonPath</c>: the document root.</summary>
|
|
||||||
private const string RootJsonPath = "$";
|
|
||||||
|
|
||||||
/// <summary>The MQTT wildcard characters — illegal in a <em>tag's</em> (concrete) subscription topic.</summary>
|
|
||||||
private static readonly char[] TopicWildcards = ['+', '#'];
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Parses a driver wire <paramref name="reference"/> into a typed definition. Returns
|
|
||||||
/// <see langword="false"/> — never throws — for a null/blank reference, a reference that is not
|
|
||||||
/// a TagConfig blob (no leading <c>{</c>), unparseable JSON, a missing or blank <c>topic</c>, a
|
|
||||||
/// <c>qos</c> outside 0–2, or a present-but-invalid (typo'd) <c>payloadFormat</c> /
|
|
||||||
/// <c>dataType</c>. The produced definition's <see cref="MqttTagDefinition.Name"/> is
|
|
||||||
/// <paramref name="reference"/> itself, which is the key published values are routed on.
|
|
||||||
/// <para>
|
|
||||||
/// A <b>wildcard</b> topic (<c>+</c> / <c>#</c>) is deliberately NOT rejected here: it is
|
|
||||||
/// ambiguous rather than unparseable, and <see cref="Inspect"/> is the operator-visible
|
|
||||||
/// surface for it. Rejecting it at runtime would turn an authoring mistake into a silent
|
|
||||||
/// <c>BadNodeIdUnknown</c> with no stated cause.
|
|
||||||
/// </para>
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="reference">The tag's wire reference (its authored <c>TagConfig</c> JSON).</param>
|
|
||||||
/// <param name="def">The parsed definition when this returns <see langword="true"/>; otherwise <see langword="null"/>.</param>
|
|
||||||
/// <returns><see langword="true"/> when <paramref name="reference"/> is a valid MQTT tag config.</returns>
|
|
||||||
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; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Deploy-time inspection of an equipment-tag <c>TagConfig</c> blob. Returns human-readable
|
|
||||||
/// warnings for a structurally unparseable blob (which the runtime turns into a silent
|
|
||||||
/// <c>BadNodeIdUnknown</c>), for present-but-invalid <c>payloadFormat</c> / <c>dataType</c>
|
|
||||||
/// enum values, and for a <b>wildcard</b> tag topic (a tag bound to <c>+</c> / <c>#</c> 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.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="reference">The equipment tag's TagConfig JSON.</param>
|
|
||||||
/// <returns>The warnings; empty when clean.</returns>
|
|
||||||
public static IReadOnlyList<string> Inspect(string reference)
|
|
||||||
{
|
|
||||||
var warnings = new List<string>();
|
|
||||||
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<MqttPayloadFormat>(root, "payloadFormat"),
|
|
||||||
TagConfigJson.DescribeInvalidEnum<DriverDataType>(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;
|
|
||||||
}
|
|
||||||
@@ -3,10 +3,10 @@ using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|||||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt;
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The driver's internal per-tag descriptor — the parsed form of an authored equipment tag's
|
/// The driver's internal per-tag descriptor — the parsed form of an authored raw tag's
|
||||||
/// <c>TagConfig</c> JSON, produced by <see cref="MqttEquipmentTagParser.TryParse"/> and looked up
|
/// <c>TagConfig</c> JSON, produced by <see cref="MqttTagDefinitionFactory.FromTagConfig"/> and
|
||||||
/// through the shared <see cref="EquipmentTagRefResolver{TDef}"/> exactly as Modbus does. See
|
/// looked up through the shared <see cref="EquipmentTagRefResolver{TDef}"/> exactly as Modbus does.
|
||||||
/// <c>docs/plans/2026-07-15-mqtt-sparkplug-driver-design.md</c> §5.2 / §5.3.
|
/// See <c>docs/plans/2026-07-15-mqtt-sparkplug-driver-design.md</c> §5.2 / §5.3.
|
||||||
/// <para>
|
/// <para>
|
||||||
/// The record carries <b>both</b> ingest shapes: the Plain-MQTT fields
|
/// The record carries <b>both</b> ingest shapes: the Plain-MQTT fields
|
||||||
/// (<see cref="Topic"/> … <see cref="RetainSeed"/>), populated in P1, and the Sparkplug B
|
/// (<see cref="Topic"/> … <see cref="RetainSeed"/>), populated in P1, and the Sparkplug B
|
||||||
@@ -18,9 +18,12 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt;
|
|||||||
/// </para>
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="Name">
|
/// <param name="Name">
|
||||||
/// The definition's identity: the exact wire <c>reference</c> string the driver was handed
|
/// The definition's identity: the tag's <b>RawPath</b> — the cluster-scoped slash path that is the
|
||||||
/// (the tag's authored <c>TagConfig</c> blob). Published values key the forward router on this,
|
/// v3 driver wire reference. This is the key <see cref="EquipmentTagRefResolver{TDef}"/> resolves
|
||||||
/// so it must round-trip byte-for-byte.
|
/// on, the key <c>OnDataChange</c> must publish under, and the key <c>DriverHostActor</c> fans out
|
||||||
|
/// to the raw + UNS NodeIds. It is emphatically <b>not</b> the authored <c>TagConfig</c> 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.
|
||||||
/// </param>
|
/// </param>
|
||||||
/// <param name="Topic">The concrete MQTT topic this tag subscribes to (Plain mode).</param>
|
/// <param name="Topic">The concrete MQTT topic this tag subscribes to (Plain mode).</param>
|
||||||
/// <param name="PayloadFormat">How the received payload is decoded (Plain mode).</param>
|
/// <param name="PayloadFormat">How the received payload is decoded (Plain mode).</param>
|
||||||
|
|||||||
@@ -0,0 +1,198 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// v3 pure mapper: turns an authored raw tag's <c>TagConfig</c> JSON (the shape produced by the
|
||||||
|
/// AdminUI <c>MqttTagConfigModel</c>) into an <see cref="MqttTagDefinition"/>. Under v3 a tag's
|
||||||
|
/// identity is its <b>RawPath</b> (a cluster-scoped slash path), not the address blob — so the
|
||||||
|
/// produced definition's <see cref="MqttTagDefinition.Name"/> is the RawPath the driver was handed,
|
||||||
|
/// which is exactly the wire reference the driver's <c>RawPath → def</c> resolver keys on. The driver
|
||||||
|
/// builds that table by mapping each <see cref="RawTagEntry"/> the deploy artifact delivers through
|
||||||
|
/// <see cref="FromTagConfig"/>.
|
||||||
|
/// <para>
|
||||||
|
/// Two entry points carry deliberately different strictness contracts.
|
||||||
|
/// <see cref="FromTagConfig"/> is the <b>runtime</b> path: it never throws, and anything
|
||||||
|
/// malformed returns <see langword="false"/> so the driver surfaces <c>BadNodeIdUnknown</c>
|
||||||
|
/// rather than a misleading <c>Good</c> off a wrong-typed default. <see cref="Inspect"/> is the
|
||||||
|
/// <b>deploy-time</b> path: it returns human-readable warnings so a bad tag config surfaces at
|
||||||
|
/// deploy instead of silently going dark at runtime.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Sparkplug-descriptor parsing (<c>groupId</c> / <c>edgeNodeId</c> / <c>deviceId</c> /
|
||||||
|
/// <c>metricName</c>) is a deliberate P1 stub — the fields exist on
|
||||||
|
/// <see cref="MqttTagDefinition"/> but are never populated here until the P2 tasks fill them.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>No <c>ToTagConfig</c> inverse — a deliberate YAGNI call.</b> The six sibling factories
|
||||||
|
/// each carry one solely to serve their <c>Driver.<X>.Cli</c> project, which synthesises
|
||||||
|
/// <see cref="RawTagEntry"/> blobs from operator flags; the MQTT plan defines no
|
||||||
|
/// <c>Mqtt.Cli</c>. The AdminUI typed editor does not need one either — the
|
||||||
|
/// <c><Driver>TagConfigModel</c> template serialises through its own preserved
|
||||||
|
/// <c>JsonObject</c> key bag and references no driver factory (verified: no
|
||||||
|
/// <c>TagDefinitionFactory</c> reference exists anywhere under the AdminUI project). Add the
|
||||||
|
/// inverse when a real caller appears, not before.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public static class MqttTagDefinitionFactory
|
||||||
|
{
|
||||||
|
/// <summary>The JSONPath applied when a Json-format blob omits <c>jsonPath</c>: the document root.</summary>
|
||||||
|
private const string RootJsonPath = "$";
|
||||||
|
|
||||||
|
/// <summary>The MQTT wildcard characters — illegal in a <em>tag's</em> (concrete) subscription topic.</summary>
|
||||||
|
private static readonly char[] TopicWildcards = ['+', '#'];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Maps an authored <c>TagConfig</c> object to a typed definition keyed by <paramref name="rawPath"/>.
|
||||||
|
/// The input is always an authored TagConfig JSON object (there is no name-vs-blob heuristic in v3).
|
||||||
|
/// Enum fields (<c>payloadFormat</c> / <c>dataType</c>) and <c>qos</c> are read STRICTLY — a
|
||||||
|
/// present-but-invalid (typo'd) value rejects the whole tag (returns <see langword="false"/> ⇒ the
|
||||||
|
/// driver surfaces <c>BadNodeIdUnknown</c>) rather than silently defaulting to a wrong-typed Good or
|
||||||
|
/// a downgraded delivery guarantee. Never throws.
|
||||||
|
/// <para>
|
||||||
|
/// A <b>wildcard</b> topic (<c>+</c> / <c>#</c>) is deliberately NOT rejected here: it is
|
||||||
|
/// ambiguous rather than unparseable, and <see cref="Inspect"/> is the operator-visible surface
|
||||||
|
/// for it. Rejecting it at runtime would turn an authoring mistake into a silent
|
||||||
|
/// <c>BadNodeIdUnknown</c> with no stated cause.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="tagConfig">The authored equipment-tag TagConfig JSON.</param>
|
||||||
|
/// <param name="rawPath">The tag's RawPath — becomes the definition's identity (<c>Name</c>).</param>
|
||||||
|
/// <param name="def">The mapped definition when this returns <see langword="true"/>.</param>
|
||||||
|
/// <returns><see langword="true"/> when <paramref name="tagConfig"/> is a valid MQTT tag-config object.</returns>
|
||||||
|
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; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Deploy-time inspection of an equipment-tag <c>TagConfig</c> blob. Returns human-readable
|
||||||
|
/// warnings for a structurally unparseable blob (which the runtime turns into a silent
|
||||||
|
/// <c>BadNodeIdUnknown</c>), for present-but-invalid <c>payloadFormat</c> / <c>dataType</c> /
|
||||||
|
/// <c>qos</c> values, and for a <b>wildcard</b> tag topic (a tag bound to <c>+</c> / <c>#</c>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="reference">The equipment tag's TagConfig JSON.</param>
|
||||||
|
/// <returns>The warnings; empty when clean.</returns>
|
||||||
|
public static IReadOnlyList<string> Inspect(string reference)
|
||||||
|
{
|
||||||
|
var warnings = new List<string>();
|
||||||
|
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<MqttPayloadFormat>(root, "payloadFormat"),
|
||||||
|
TagConfigJson.DescribeInvalidEnum<DriverDataType>(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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Strict <c>qos</c> read, mirroring <see cref="TagConfigJson.TryReadEnumStrict{TEnum}"/>'s
|
||||||
|
/// absent / valid / present-but-invalid split: absent ⇒ <see langword="null"/> (the driver-level
|
||||||
|
/// <see cref="MqttPlainOptions.DefaultQos"/> wins); a JSON integer in 0–2 ⇒ that value; anything
|
||||||
|
/// else present (non-number, non-integer, or out of range) ⇒ the read FAILS.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="o">The TagConfig root object.</param>
|
||||||
|
/// <param name="qos">The parsed QoS, or <see langword="null"/> when the field is absent.</param>
|
||||||
|
/// <returns><see langword="false"/> only when <c>qos</c> is present but not a legal MQTT QoS.</returns>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A human-readable warning for a present-but-invalid <c>qos</c> field, or <see langword="null"/>
|
||||||
|
/// when it is absent or valid — the <c>qos</c> counterpart of
|
||||||
|
/// <see cref="TagConfigJson.DescribeInvalidEnum{TEnum}"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="o">The TagConfig root object.</param>
|
||||||
|
/// <returns>The warning text, or <see langword="null"/>.</returns>
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Covers the two entry points of <see cref="MqttEquipmentTagParser"/> and their deliberately
|
|
||||||
/// different strictness contracts: <c>TryParse</c> is the runtime path (never throws, a
|
|
||||||
/// malformed blob is a hard reject that upstream turns into <c>BadNodeIdUnknown</c>) and
|
|
||||||
/// <c>Inspect</c> is the deploy-time path (human-readable warnings so a bad tag surfaces at
|
|
||||||
/// deploy instead of going dark at runtime).
|
|
||||||
/// </summary>
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Covers the two entry points of <see cref="MqttTagDefinitionFactory"/> and their deliberately
|
||||||
|
/// different strictness contracts: <c>FromTagConfig</c> is the runtime path (never throws, a
|
||||||
|
/// malformed blob is a hard reject that upstream turns into <c>BadNodeIdUnknown</c>) and
|
||||||
|
/// <c>Inspect</c> is the deploy-time path (human-readable warnings so a bad tag surfaces at
|
||||||
|
/// deploy instead of going dark at runtime).
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The v3 identity contract, stated explicitly so a regression back to blob-keying goes red.
|
||||||
|
/// <see cref="MqttTagDefinition.Name"/> MUST be the RawPath the driver was handed — it is the key
|
||||||
|
/// <c>EquipmentTagRefResolver</c> looks up, the key <c>OnDataChange</c> publishes under, and the
|
||||||
|
/// key <c>DriverHostActor</c> 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.
|
||||||
|
/// </summary>
|
||||||
|
[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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The identity is whatever RawPath the caller supplies — the factory must never re-derive it
|
||||||
|
/// from the blob's contents (e.g. from <c>topic</c>), or two tags sharing a topic would collide.
|
||||||
|
/// </summary>
|
||||||
|
[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();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A present-but-invalid <c>qos</c> is rejected in every malformed shape, not just the
|
||||||
|
/// out-of-range numeric one. Silently absorbing <c>"high"</c> / <c>1.5</c> into the driver-level
|
||||||
|
/// default would hand the operator a WEAKER delivery guarantee than the one they authored.
|
||||||
|
/// </summary>
|
||||||
|
[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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Every warning source fires together — the pass reports all of them, not just the first.</summary>
|
||||||
|
[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();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user