feat(mqtt): typed tag editor + validator (plain mode)

Thin typed model over a preserved JsonObject key bag, mirroring the sibling
<Driver>TagConfigModel template. Key names and strictness rules mirror
MqttTagDefinitionFactory rather than inventing an editor-side schema; enums
round-trip as NAMES (the factory reads them strictly by name).

No FullName key: under v3 a tag is identified by its RawPath, which the factory
keys the definition's Name off — a composed identity key here would be dead
weight nothing reads.

Mode is a UI-only sub-shape selector inferred from the blob (any Sparkplug
descriptor key present) and never serialised — the driver takes Plain vs
SparkplugB from its DRIVER config. Only Plain Validate() is implemented; the
Sparkplug branch is a stub Task 24 fills, and its keys are preserved untouched.

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