feat(mqtt): typed tag editor Sparkplug mode + validation

Task 24 (P2): replaces the P1 Sparkplug Validate() stub (always null) and the
"not available yet" editor placeholder with real groupId/edgeNodeId/deviceId/
metricName authoring, mirroring MqttTagDefinitionFactory.FromSparkplugTagConfig
in both directions -- required ids + optional deviceId, dataType/qos read
strictly but only when present (dataType is optional: the birth certificate
declares it). One rule is deliberately stricter than the factory: group/edge-
node/device ids reject '/', '+', '#' (topic-segment characters a decoded
incoming id can never carry, so an authored one that does can never bind);
metricName is exempt since Sparkplug's own canonical names use '/' (e.g.
"Node Control/Rebirth"). No FullName key is written -- TagConfig carries no
identity under v3, and the plan's snippet asking for one was corrected per the
brief. A SparkplugB tag now survives save/reopen because its descriptor keys
re-infer Mode on load; there was never a 'mode' key to persist.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
This commit is contained in:
Joseph Doherty
2026-07-24 22:43:44 -04:00
parent 6917d8805d
commit cd0157a3b8
3 changed files with 377 additions and 32 deletions
@@ -3,10 +3,11 @@
dispatch uniformity and unused here (MQTT has no address-builder picker — topics are discovered
through the /raw browse tree, not composed from parts).
P1 authors Plain-mode tags only. The shape switch is real (it drives which field group renders and
what Validate() applies) but it is a UI-only choice derived from the blob, never serialised — the
driver takes Plain vs SparkplugB from its DRIVER config, and the tag blob has no 'mode' key.
Task 24 replaces the Sparkplug placeholder below with the real descriptor field group. *@
The shape switch is real (it drives which field group renders and what Validate() applies) but it
is a UI-only choice derived from the blob, never serialised — the driver takes Plain vs SparkplugB
from its DRIVER config, and the tag blob has no 'mode' key. A SparkplugB tag survives a save→reopen
because its descriptor keys (groupId/edgeNodeId/deviceId/metricName) re-infer the mode on load —
there is nothing else that needs to persist. *@
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors
@using ZB.MOM.WW.OtOpcUa.Core.Abstractions
@using ZB.MOM.WW.OtOpcUa.Driver.Mqtt
@@ -78,12 +79,64 @@
}
else
{
<div class="col-12">
<div class="alert alert-info py-2 px-3 mb-0 small" role="alert">
Sparkplug B tag authoring is not available yet — the group / edge-node / device /
metric fields land with Sparkplug ingest. Switch back to <strong>Plain</strong> to author
a topic-bound tag. Any existing Sparkplug keys on this tag are preserved untouched.
</div>
<div class="col-md-4">
<label class="form-label" for="mqtt-sp-group">Group ID</label>
<input id="mqtt-sp-group" type="text" class="form-control form-control-sm mono"
placeholder="Plant1" value="@_m.GroupId"
@onchange="@(e => Update(() => _m.GroupId = e.Value?.ToString() ?? ""))" />
<div class="form-text">The driver subscribes <code>spBv1.0/@(string.IsNullOrEmpty(_m.GroupId) ? "{GroupId}" : _m.GroupId)/#</code>. No <code>/</code>, <code>+</code>, or <code>#</code>.</div>
</div>
<div class="col-md-4">
<label class="form-label" for="mqtt-sp-node">Edge node ID</label>
<input id="mqtt-sp-node" type="text" class="form-control form-control-sm mono"
placeholder="EdgeA" value="@_m.EdgeNodeId"
@onchange="@(e => Update(() => _m.EdgeNodeId = e.Value?.ToString() ?? ""))" />
<div class="form-text">The publishing edge node's id.</div>
</div>
<div class="col-md-4">
<label class="form-label" for="mqtt-sp-device">Device ID <span class="text-muted">(optional)</span></label>
<input id="mqtt-sp-device" type="text" class="form-control form-control-sm mono"
placeholder="(node-level metric)" value="@_m.DeviceId"
@onchange="@(e => Update(() => _m.DeviceId = e.Value?.ToString() ?? ""))" />
<div class="form-text">Leave blank for a metric published by the edge node itself.</div>
</div>
<div class="col-md-6">
<label class="form-label" for="mqtt-sp-metric">Metric name</label>
<input id="mqtt-sp-metric" type="text" class="form-control form-control-sm mono"
placeholder="Node Control/Rebirth" value="@_m.MetricName"
@onchange="@(e => Update(() => _m.MetricName = e.Value?.ToString() ?? ""))" />
<div class="form-text">The metric's stable name from the birth certificate — unlike the ids above, this MAY contain <code>/</code> (e.g. <code>Properties/Hardware Make</code>).</div>
</div>
<div class="col-md-3">
<label class="form-label" for="mqtt-sp-data-type">Data type override</label>
@* Same DriverDataType set as Plain's field — the factory reads a Sparkplug 'dataType' key as
a DriverDataType override, not the raw wire SparkplugDataType. Absent ⇒ take whatever type
the birth certificate declares (the driver's UntilStable discovery). *@
<select id="mqtt-sp-data-type" class="form-select form-select-sm" value="@(_m.MetricDataType?.ToString() ?? "")"
@onchange="@(e => Update(() => _m.MetricDataType = ParseNullableEnum<DriverDataType>(e.Value)))">
<option value="">(from birth certificate)</option>
@foreach (var v in Enum.GetValues<DriverDataType>()) { <option value="@v">@v</option> }
</select>
</div>
<div class="col-md-3">
<label class="form-label" for="mqtt-sp-qos">QoS</label>
<select id="mqtt-sp-qos" class="form-select form-select-sm" value="@(_m.Qos?.ToString() ?? "")"
@onchange="@(e => Update(() => _m.Qos = ParseNullableInt(e.Value)))">
<option value="">(driver default)</option>
<option value="0">0 — at most once</option>
<option value="1">1 — at least once</option>
<option value="2">2 — exactly once</option>
</select>
</div>
<div class="col-md-3">
<label class="form-label" for="mqtt-sp-retain-seed">Retained-message seed</label>
<select id="mqtt-sp-retain-seed" class="form-select form-select-sm"
value="@(_m.RetainSeed is null ? "" : _m.RetainSeed.Value ? "true" : "false")"
@onchange="@(e => Update(() => _m.RetainSeed = ParseNullableBool(e.Value)))">
<option value="">(driver default)</option>
<option value="true">Seed from retained message</option>
<option value="false">Wait for a live publish</option>
</select>
</div>
}
@@ -121,6 +174,10 @@
private static TEnum ParseEnum<TEnum>(object? v, TEnum fallback) where TEnum : struct, Enum
=> Enum.TryParse<TEnum>(v?.ToString(), out var r) ? r : fallback;
// "" (the "(from birth certificate)" sentinel option) ⇒ null ⇒ the key is omitted.
private static TEnum? ParseNullableEnum<TEnum>(object? v) where TEnum : struct, Enum
=> Enum.TryParse<TEnum>(v?.ToString(), out var r) ? r : null;
// "" ⇒ null ⇒ the key is omitted and the driver's own default applies.
private static int? ParseNullableInt(object? v)
=> int.TryParse(v?.ToString(), out var i) ? i : null;
@@ -25,8 +25,10 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
/// <see cref="Mode"/> is a <b>UI-only</b> sub-shape selector, inferred from the blob (the presence
/// of any Sparkplug descriptor key) and never serialised — the driver takes its
/// <c>Plain</c>/<c>SparkplugB</c> mode from the <em>driver</em> config, not from a tag, so
/// persisting a per-tag <c>mode</c> key would add a field the contract does not define. Only the
/// Plain rules are implemented in P1; the Sparkplug branch is a stub Task 24 fills.
/// persisting a per-tag <c>mode</c> key would add a field the contract does not define.
/// Consequently a SparkplugB tag survives a save→reopen purely because the descriptor keys it
/// writes (<see cref="GroupId"/>/<see cref="EdgeNodeId"/>/<see cref="DeviceId"/>/
/// <see cref="MetricName"/>) re-infer the mode on the next load — there is nothing else to persist.
/// </para>
/// </remarks>
public sealed class MqttTagConfigModel
@@ -37,6 +39,10 @@ public sealed class MqttTagConfigModel
private const string DataTypeKey = "dataType";
private const string QosKey = "qos";
private const string RetainSeedKey = "retainSeed";
private const string GroupIdKey = "groupId";
private const string EdgeNodeIdKey = "edgeNodeId";
private const string DeviceIdKey = "deviceId";
private const string MetricNameKey = "metricName";
/// <summary>
/// The JSONPath the driver applies when the blob omits <c>jsonPath</c> — the document root.
@@ -45,15 +51,29 @@ public sealed class MqttTagConfigModel
/// </summary>
private const string RootJsonPath = "$";
/// <summary>The Sparkplug B descriptor keys — authored by Task 24, preserved (never dropped) in P1.</summary>
private static readonly string[] SparkplugKeys = ["groupId", "edgeNodeId", "deviceId", "metricName"];
/// <summary>The Sparkplug B descriptor keys — any non-blank one marks a tag SparkplugB (see <see cref="InferMode"/>).</summary>
private static readonly string[] SparkplugKeys = [GroupIdKey, EdgeNodeIdKey, DeviceIdKey, MetricNameKey];
/// <summary>The MQTT wildcard characters; a tag's subscription topic must be concrete.</summary>
private static readonly char[] TopicWildcards = ['+', '#'];
/// <summary>
/// Characters illegal in a Sparkplug group/edge-node/device id. <c>/</c> is rejected because
/// these ids are literal MQTT topic segments — a decoded incoming id can never contain one
/// (the broker has already split the topic into segments before the driver sees it), so an
/// authored id containing <c>/</c> could never match a real birth: a permanently dead binding,
/// not an ambiguous one. <c>+</c>/<c>#</c> are rejected for the same "no legitimate
/// interpretation" reason the Plain topic wildcard check uses — here doubly so, since
/// <see cref="MqttDriverOptions.GroupId"/>'s only consumer builds the literal subscription
/// filter <c>spBv1.0/{GroupId}/#</c>, and embedding a wildcard character mid-segment is not
/// even legal there. <b>Not enforced by <c>MqttTagDefinitionFactory.FromSparkplugTagConfig</c></b>
/// — this is an editor-side rule stricter than the runtime parser, the same shape as the Plain
/// topic-wildcard rule below.
/// </summary>
private static readonly char[] SparkplugSegmentIllegalChars = ['/', '+', '#'];
/// <summary>
/// Which ingest shape this tag is authored under — inferred from the blob, never serialised.
/// P1 authors <see cref="MqttMode.Plain"/> only.
/// </summary>
public MqttMode Mode { get; set; } = MqttMode.Plain;
@@ -72,21 +92,56 @@ public sealed class MqttTagConfigModel
/// </summary>
public string JsonPath { get; set; } = "";
/// <summary>The tag's declared value type (the driver's <see cref="DriverDataType"/> set).</summary>
/// <summary>The tag's declared value type (Plain mode; always written). The driver's <see cref="DriverDataType"/> set.</summary>
public DriverDataType DataType { get; set; } = DriverDataType.String;
/// <summary>
/// Per-tag subscription QoS (02), or <c>null</c> to omit the key and inherit the driver-level
/// default. An absent key stays absent through a load→save.
/// default. An absent key stays absent through a load→save. Shared by both modes —
/// <c>MqttTagDefinitionFactory</c> reads <c>qos</c> identically for Plain and Sparkplug.
/// </summary>
public int? Qos { get; set; }
/// <summary>
/// Whether the broker's retained message seeds the tag's initial value, or <c>null</c> to omit
/// the key and inherit the driver's default (<c>true</c>). An absent key stays absent.
/// the key and inherit the driver's default (<c>true</c>). An absent key stays absent. Shared by
/// both modes — <c>MqttTagDefinitionFactory</c> reads <c>retainSeed</c> identically for Plain and
/// Sparkplug.
/// </summary>
public bool? RetainSeed { get; set; }
/// <summary>The Sparkplug group id (Sparkplug mode). Required; becomes part of the subscription filter.</summary>
public string GroupId { get; set; } = "";
/// <summary>The Sparkplug edge-node id (Sparkplug mode). Required.</summary>
public string EdgeNodeId { get; set; } = "";
/// <summary>
/// The Sparkplug device id (Sparkplug mode), or blank for a metric published by the edge node
/// itself rather than a device beneath it. Optional.
/// </summary>
public string DeviceId { get; set; } = "";
/// <summary>
/// The Sparkplug metric's stable name (Sparkplug mode). Required. Unlike
/// <see cref="GroupId"/>/<see cref="EdgeNodeId"/>/<see cref="DeviceId"/> this is NOT a topic
/// segment — it is read from the birth/data payload — so it is deliberately unrestricted and MAY
/// contain <c>/</c> (the canonical Sparkplug examples do: <c>Node Control/Rebirth</c>,
/// <c>Properties/Hardware Make</c>).
/// </summary>
public string MetricName { get; set; } = "";
/// <summary>
/// Sparkplug-mode data-type OVERRIDE (Sparkplug mode only), or <c>null</c> to omit the key and
/// let the tag take whatever type the metric's birth certificate declares — the whole point of
/// the driver's <c>UntilStable</c> discovery. Distinct from <see cref="DataType"/>, which is the
/// always-written Plain-mode field; both read/write the same <c>dataType</c> JSON key
/// (<c>MqttTagDefinitionFactory.FromSparkplugTagConfig</c> reads it as a <see cref="DriverDataType"/>
/// override, strictly, when present — the same enum Plain mode uses, not the raw wire
/// <c>SparkplugDataType</c>).
/// </summary>
public DriverDataType? MetricDataType { get; set; }
private JsonObject _bag = new();
/// <summary>
@@ -120,6 +175,13 @@ public sealed class MqttTagConfigModel
DataType = TagConfigJson.GetEnum(o, DataTypeKey, DriverDataType.String),
Qos = GetIntNullable(o, QosKey),
RetainSeed = TagConfigJson.GetBoolNullable(o, RetainSeedKey),
GroupId = TagConfigJson.GetString(o, GroupIdKey) ?? "",
EdgeNodeId = TagConfigJson.GetString(o, EdgeNodeIdKey) ?? "",
DeviceId = TagConfigJson.GetString(o, DeviceIdKey) ?? "",
MetricName = TagConfigJson.GetString(o, MetricNameKey) ?? "",
// Same "dataType" key as Plain's DataType above, read as an optional override — see the
// MetricDataType doc comment for why there are two typed fields over one JSON key.
MetricDataType = GetEnumNullable<DriverDataType>(o, DataTypeKey),
_bag = o,
};
}
@@ -130,6 +192,15 @@ public sealed class MqttTagConfigModel
/// rejected outright), and blank/null optionals are written as an absent key so the driver's own
/// defaults apply.
/// </summary>
/// <remarks>
/// <c>dataType</c> is the one key both modes write, from two different typed fields
/// (<see cref="DataType"/> for Plain — always present; <see cref="MetricDataType"/> for
/// Sparkplug — omitted unless the operator set an override): which field wins is decided by
/// <see cref="Mode"/> at write time. Every other key is unconditional — mode-inapplicable keys
/// (e.g. <c>topic</c> while Sparkplug, or <c>groupId</c> while Plain) are written/cleared exactly
/// as their typed field says, which naturally preserves a retyped tag's other-mode leftovers
/// untouched until the operator edits that field too.
/// </remarks>
/// <returns>The serialised TagConfig JSON string.</returns>
public string ToJson()
{
@@ -137,10 +208,18 @@ public sealed class MqttTagConfigModel
// must not leave a stray "topic":"" behind.
TagConfigJson.Set(_bag, TopicKey, string.IsNullOrWhiteSpace(Topic) ? null : Topic.Trim());
TagConfigJson.Set(_bag, PayloadFormatKey, PayloadFormat);
TagConfigJson.Set(_bag, DataTypeKey, DataType);
TagConfigJson.Set(_bag, JsonPathKey, string.IsNullOrWhiteSpace(JsonPath) ? null : JsonPath.Trim());
TagConfigJson.Set(_bag, QosKey, Qos);
TagConfigJson.Set(_bag, RetainSeedKey, RetainSeed);
// Mode-dependent: Plain always writes DataType; Sparkplug writes MetricDataType (null ⇒ omitted,
// "take the birth's declared type").
TagConfigJson.Set(_bag, DataTypeKey, Mode == MqttMode.SparkplugB ? MetricDataType : DataType);
TagConfigJson.Set(_bag, GroupIdKey, string.IsNullOrWhiteSpace(GroupId) ? null : GroupId.Trim());
TagConfigJson.Set(_bag, EdgeNodeIdKey, string.IsNullOrWhiteSpace(EdgeNodeId) ? null : EdgeNodeId.Trim());
TagConfigJson.Set(_bag, DeviceIdKey, string.IsNullOrWhiteSpace(DeviceId) ? null : DeviceId.Trim());
TagConfigJson.Set(_bag, MetricNameKey, string.IsNullOrWhiteSpace(MetricName) ? null : MetricName.Trim());
return TagConfigJson.Serialize(_bag);
}
@@ -169,13 +248,30 @@ public sealed class MqttTagConfigModel
/// blob that never went through this editor (e.g. a CSV import) cannot pass validation while the
/// driver would reject it.
/// </para>
/// <para>
/// <b>Sparkplug rules mirror <c>MqttTagDefinitionFactory.FromSparkplugTagConfig</c></b>:
/// <c>groupId</c>/<c>edgeNodeId</c>/<c>metricName</c> required (blank rejected exactly as the
/// factory hard-rejects them), <c>deviceId</c> optional, <c>dataType</c> and <c>qos</c> read
/// strictly but ONLY when present (matching <c>TryReadEnumStrict</c>'s absent/valid/invalid
/// split — a Sparkplug tag legitimately omits <c>dataType</c> and takes whatever the birth
/// certificate declares). Plain-only fields (<c>topic</c>, <c>payloadFormat</c>, <c>jsonPath</c>)
/// are NOT checked in this mode, matching the factory's "Plain-shape keys are read but not
/// required" stance for a retyped blob's leftovers.
/// </para>
/// <para>
/// ONE Sparkplug rule is likewise stricter than the factory: <c>groupId</c>/<c>edgeNodeId</c>/
/// <c>deviceId</c> reject <c>/</c>, <c>+</c>, and <c>#</c> — see
/// <see cref="SparkplugSegmentIllegalChars"/> for why (a decoded incoming id can never contain
/// one, so an authored id that does is a permanently dead binding, and <c>+</c>/<c>#</c> would
/// corrupt the driver's own subscription filter). <c>metricName</c> carries NO such restriction —
/// it is not a topic segment, and Sparkplug's own canonical metric names use <c>/</c> (e.g.
/// <c>Node Control/Rebirth</c>); rejecting it would block legitimate authoring.
/// </para>
/// </remarks>
/// <returns>An error message describing the validation failure, or <c>null</c> when valid.</returns>
public string? Validate()
{
// P1 stub: Sparkplug-shaped tags are not authored yet — Task 24 fills these rules. Applying the
// Plain rules to them would reject every Sparkplug tag for "missing topic".
if (Mode == MqttMode.SparkplugB) { return null; }
if (Mode == MqttMode.SparkplugB) { return ValidateSparkplug(); }
if (DescribeInvalidEnum<MqttPayloadFormat>(_bag, PayloadFormatKey) is { } pfError) { return pfError; }
if (DescribeInvalidEnum<DriverDataType>(_bag, DataTypeKey) is { } dtError) { return dtError; }
@@ -194,6 +290,45 @@ public sealed class MqttTagConfigModel
return null;
}
/// <summary>Sparkplug-mode half of <see cref="Validate"/> — see its remarks for the full rationale.</summary>
/// <returns>An error message describing the validation failure, or <c>null</c> when valid.</returns>
private string? ValidateSparkplug()
{
// Same DriverDataType enum + same strict absent/valid/invalid split as Plain's DataType check —
// dataType is one JSON key read by both FromTagConfig and FromSparkplugTagConfig identically.
if (DescribeInvalidEnum<DriverDataType>(_bag, DataTypeKey) is { } dtError) { return dtError; }
if (DescribeInvalidQos(_bag) is { } qosError) { return qosError; }
var groupId = GroupId.Trim();
var edgeNodeId = EdgeNodeId.Trim();
var deviceId = DeviceId.Trim();
var metricName = MetricName.Trim();
if (string.IsNullOrEmpty(groupId)) { return "A Sparkplug group ID is required."; }
if (string.IsNullOrEmpty(edgeNodeId)) { return "A Sparkplug edge node ID is required."; }
if (string.IsNullOrEmpty(metricName)) { return "A Sparkplug metric name is required."; }
if (DescribeInvalidSparkplugSegment("group ID", groupId) is { } gErr) { return gErr; }
if (DescribeInvalidSparkplugSegment("edge node ID", edgeNodeId) is { } eErr) { return eErr; }
// deviceId is optional — only validated when the operator actually supplied one.
if (deviceId.Length > 0 && DescribeInvalidSparkplugSegment("device ID", deviceId) is { } dErr) { return dErr; }
return null;
}
/// <summary>
/// Describes an <paramref name="value"/> that is illegal as a Sparkplug group/edge-node/device
/// id segment (see <see cref="SparkplugSegmentIllegalChars"/>), or <c>null</c> when clean.
/// </summary>
/// <param name="fieldLabel">The human-readable field name for the error message.</param>
/// <param name="value">The trimmed, non-blank field value to check.</param>
/// <returns>The error text, or <c>null</c>.</returns>
private static string? DescribeInvalidSparkplugSegment(string fieldLabel, string value)
=> value.IndexOfAny(SparkplugSegmentIllegalChars) >= 0
? $"Sparkplug {fieldLabel} '{value}' contains a character ('/', '+', or '#') that cannot appear " +
"in an MQTT topic segment; a decoded incoming id can never contain one, so this could never bind."
: null;
/// <summary>
/// Infers the editor's sub-shape from the blob: any non-blank Sparkplug descriptor key marks a
/// Sparkplug tag, otherwise Plain.
@@ -212,6 +347,18 @@ public sealed class MqttTagConfigModel
private static int? GetIntNullable(JsonObject o, string name)
=> o.TryGetPropertyValue(name, out var n) && n is JsonValue v && v.TryGetValue<int>(out var i) ? i : null;
/// <summary>
/// Reads an enum by its serialised name, or <c>null</c> when absent/unparseable — the nullable
/// counterpart of <see cref="TagConfigJson.GetEnum{TEnum}"/>, used for <see cref="MetricDataType"/>
/// so "the key is absent" (take the birth's declared type) is distinguishable from any real member.
/// </summary>
/// <typeparam name="TEnum">The enum type to parse.</typeparam>
/// <param name="o">The JSON object to read from.</param>
/// <param name="name">The property name to read.</param>
/// <returns>The parsed enum value, or <c>null</c>.</returns>
private static TEnum? GetEnumNullable<TEnum>(JsonObject o, string name) where TEnum : struct, Enum
=> TagConfigJson.GetString(o, name) is { } s && Enum.TryParse<TEnum>(s, ignoreCase: true, out var v) ? v : null;
/// <summary>
/// Describes a present-but-invalid enum field, or <c>null</c> when it is absent or valid.
/// Mirrors <c>TagConfigJson.TryReadEnum</c>'s absent/valid/invalid split exactly — including its
@@ -7,14 +7,15 @@ using ZB.MOM.WW.OtOpcUa.Driver.Mqtt;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
// Typed AdminUI editor model for the Mqtt driver (P1 = Plain mode). The model is a thin typed view over
// a preserved JsonObject key bag: every key the editor does not expose (history intent, array config,
// alarm objects, and the Sparkplug keys Task 24 will add) must survive a load→save untouched.
// Typed AdminUI editor model for the Mqtt driver Plain (topic-bound) and Sparkplug B (birth/alias
// ingest) modes. The model is a thin typed view over a preserved JsonObject key bag: every key the
// editor does not expose (history intent, array config, alarm objects) must survive a load→save
// untouched.
//
// The authoritative consumer of the produced blob is MqttTagDefinitionFactory.FromTagConfig
// (Driver.Mqtt.Contracts) — the key names + strictness rules asserted here are that factory's, not this
// editor's invention. TagConfig carries NO identity under v3 (the tag's RawPath is its identity), so
// there is deliberately no FullName key.
// The authoritative consumers of the produced blob are MqttTagDefinitionFactory.FromTagConfig (Plain)
// and .FromSparkplugTagConfig (Sparkplug B) (Driver.Mqtt.Contracts) — the key names + strictness rules
// asserted here are those factories', not this editor's invention. TagConfig carries NO identity under
// v3 (the tag's RawPath is its identity), so there is deliberately no FullName key — in EITHER mode.
public sealed class MqttTagConfigModelTests
{
[Theory]
@@ -144,7 +145,7 @@ public sealed class MqttTagConfigModelTests
}
[Fact]
public void FromJson_then_ToJson_preserves_sparkplug_keys_task24_will_own()
public void FromJson_then_ToJson_preserves_sparkplug_descriptor_keys()
{
var json = MqttTagConfigModel.FromJson(
"""{"groupId":"Plant1","edgeNodeId":"E1","deviceId":"D1","metricName":"Temp"}""").ToJson();
@@ -154,6 +155,146 @@ public sealed class MqttTagConfigModelTests
o["edgeNodeId"]!.GetValue<string>().ShouldBe("E1");
o["deviceId"]!.GetValue<string>().ShouldBe("D1");
o["metricName"]!.GetValue<string>().ShouldBe("Temp");
// TagConfig carries no identity under v3 even for a Sparkplug tag — see the corrections in the
// Task 24 brief: FullName is a retired pre-v3 concept, not something this editor re-introduces.
json.ShouldNotContain("FullName", Case.Sensitive);
}
// ── Validate: Sparkplug rules (Task 24) ─────────────────────────────────────────────────────
//
// Mirrors MqttTagDefinitionFactory.FromSparkplugTagConfig exactly: groupId/edgeNodeId/metricName
// required, deviceId optional, dataType/qos read strictly but only when present. See the model's
// Validate() remarks for the one place this editor is deliberately STRICTER than the factory
// (illegal characters in the id segments) and why metricName is exempt from that rule.
[Fact]
public void Validate_SparkplugMissingMetricName_Fails()
=> MqttTagConfigModel.FromJson("""{"groupId":"Plant1","edgeNodeId":"EdgeA"}""")
.Validate().ShouldNotBeNullOrWhiteSpace();
[Fact]
public void Validate_SparkplugMissingGroupId_Fails()
=> MqttTagConfigModel.FromJson("""{"edgeNodeId":"EdgeA","metricName":"Temperature"}""")
.Validate().ShouldNotBeNullOrWhiteSpace();
[Fact]
public void Validate_SparkplugMissingEdgeNodeId_Fails()
=> MqttTagConfigModel.FromJson("""{"groupId":"Plant1","metricName":"Temperature"}""")
.Validate().ShouldNotBeNullOrWhiteSpace();
[Fact]
public void Validate_SparkplugValid_ReturnsNull()
=> MqttTagConfigModel.FromJson(
"""{"groupId":"Plant1","edgeNodeId":"EdgeA","deviceId":"Filler1","metricName":"Temperature","dataType":"Float32"}""")
.Validate().ShouldBeNull();
// deviceId is genuinely optional per the factory (absent ⇒ a node-level metric) — a Sparkplug tag
// must validate cleanly without one.
[Fact]
public void Validate_SparkplugValidWithoutDeviceId_ReturnsNull()
=> MqttTagConfigModel.FromJson(
"""{"groupId":"Plant1","edgeNodeId":"EdgeA","metricName":"Temperature"}""")
.Validate().ShouldBeNull();
// dataType is OPTIONAL in Sparkplug mode (the birth certificate declares it) — unlike Plain mode,
// an absent dataType is not an error.
[Fact]
public void Validate_SparkplugAbsentDataType_ReturnsNull()
=> MqttTagConfigModel.FromJson(
"""{"groupId":"Plant1","edgeNodeId":"EdgeA","metricName":"Temperature"}""")
.Validate().ShouldBeNull();
// ...but a PRESENT, typo'd dataType is still rejected — same strict-when-present rule as Plain,
// and the same DriverDataType vocabulary (not the raw Sparkplug wire enum).
[Fact]
public void Validate_SparkplugTypoedDataType_Fails()
=> MqttTagConfigModel.FromJson(
"""{"groupId":"Plant1","edgeNodeId":"EdgeA","metricName":"Temperature","dataType":"Double"}""")
.Validate().ShouldNotBeNullOrWhiteSpace();
[Theory]
[InlineData("3")]
[InlineData("-1")]
public void Validate_SparkplugInvalidQos_Fails(string qosLiteral)
=> MqttTagConfigModel.FromJson(
$$"""{"groupId":"Plant1","edgeNodeId":"EdgeA","metricName":"Temperature","qos":{{qosLiteral}}}""")
.Validate().ShouldNotBeNullOrWhiteSpace();
// Editor-stricter-than-factory: group/edge-node/device ids are literal MQTT topic segments — a
// decoded incoming id can never contain '/', '+', or '#', so an authored one that does could never
// bind. Covers all three id fields plus both illegal characters.
[Theory]
[InlineData("groupId", "Plant1/A")]
[InlineData("groupId", "Plant+1")]
[InlineData("groupId", "Plant#1")]
[InlineData("edgeNodeId", "Edge/A")]
[InlineData("edgeNodeId", "Edge+A")]
[InlineData("deviceId", "Dev/1")]
[InlineData("deviceId", "Dev#1")]
public void Validate_SparkplugIllegalCharacterInIdSegment_Fails(string field, string value)
{
var fields = new Dictionary<string, string>
{
["groupId"] = "Plant1",
["edgeNodeId"] = "EdgeA",
["deviceId"] = "Filler1",
["metricName"] = "Temperature",
};
fields[field] = value;
var json = System.Text.Json.JsonSerializer.Serialize(fields);
MqttTagConfigModel.FromJson(json).Validate().ShouldNotBeNullOrWhiteSpace();
}
// metricName is explicitly EXEMPT from the id-segment character rule — it is not a topic segment
// (it comes from the payload, not the topic), and Sparkplug's own canonical examples use '/' in
// metric names (e.g. "Node Control/Rebirth"). Over-rejecting here would block legitimate authoring.
[Fact]
public void Validate_SparkplugMetricNameWithSlash_IsAccepted()
=> MqttTagConfigModel.FromJson(
"""{"groupId":"Plant1","edgeNodeId":"EdgeA","metricName":"Node Control/Rebirth"}""")
.Validate().ShouldBeNull();
// ── ToJson: Sparkplug dataType override (Task 24) ───────────────────────────────────────────
[Fact]
public void ToJson_SparkplugWithoutOverride_OmitsDataType()
{
var json = MqttTagConfigModel
.FromJson("""{"groupId":"Plant1","edgeNodeId":"EdgeA","metricName":"Temperature"}""")
.ToJson();
json.ShouldNotContain("dataType");
}
[Fact]
public void ToJson_Sparkplug_RoundTripsDescriptorFieldsAndDataTypeOverride()
{
var json = MqttTagConfigModel.FromJson(
"""{"groupId":"Plant1","edgeNodeId":"EdgeA","deviceId":"Filler1","metricName":"Temperature","dataType":"Float32"}""")
.ToJson();
json.ShouldContain("\"groupId\":\"Plant1\"");
json.ShouldContain("\"edgeNodeId\":\"EdgeA\"");
json.ShouldContain("\"deviceId\":\"Filler1\"");
json.ShouldContain("\"metricName\":\"Temperature\"");
json.ShouldContain("\"dataType\":\"Float32\"");
json.ShouldNotContain("FullName", Case.Sensitive);
}
// A SparkplugB tag has NO 'mode' key to persist — it survives a save→reopen purely because the
// descriptor keys it writes re-infer the mode on the next load.
[Fact]
public void FromJson_then_ToJson_then_FromJson_SparkplugMode_SurvivesReopen()
{
var m = MqttTagConfigModel.FromJson(
"""{"groupId":"Plant1","edgeNodeId":"EdgeA","metricName":"Temperature"}""");
m.Mode.ShouldBe(MqttMode.SparkplugB);
var json = m.ToJson();
json.ShouldNotContain("\"mode\"");
MqttTagConfigModel.FromJson(json).Mode.ShouldBe(MqttMode.SparkplugB);
}
[Fact]
@@ -270,8 +411,8 @@ public sealed class MqttTagConfigModelTests
MqttTagConfigModel.FromJson(repaired).Validate().ShouldBeNull();
}
// P1 stub: Sparkplug-shaped tags are not authored yet (Task 24 fills the rules). The Plain rules
// must NOT be applied to them — a Sparkplug tag has no topic.
// A well-formed Sparkplug tag is NOT subject to the Plain rules — it has no topic, and applying the
// Plain "topic is required" check would reject every Sparkplug tag.
[Fact]
public void Validate_SparkplugMode_IsNotSubjectToPlainRules()
=> MqttTagConfigModel.FromJson("""{"groupId":"P1","edgeNodeId":"E1","metricName":"Temp"}""")