feat(mqtt): plain tag parser + strict-enum descriptor
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
This commit is contained in:
@@ -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 15–26) 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 0–2 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();
|
||||
}
|
||||
Reference in New Issue
Block a user