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:
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user