feat(mqtt): plain tag parser + strict-enum descriptor

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
This commit is contained in:
Joseph Doherty
2026-07-24 14:07:11 -04:00
parent a5a8710af5
commit e3fea6e409
3 changed files with 372 additions and 0 deletions
@@ -0,0 +1,143 @@
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 1526) 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 02 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();
}