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,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();
}