7f53bd96ec
Five MQTT tracker items — four AdminUI/authoring defects plus the .Browser layering exception. No runtime driver behaviour changes. #513 payloadFormat in a Sparkplug blob. MqttTagConfigModel.ToJson wrote the key unconditionally. Sharper than the issue states: PayloadFormat is the one Plain field with a NON-NULLABLE typed backing, so the write did not preserve a retyped tag's leftover — it INVENTED "payloadFormat":"Json" in a Sparkplug tag that never had one. Now Plain-only; preserve-unknown-keys is pinned unchanged. #509 slashed Sparkplug metric names. The mapper already carried metricName verbatim; only the tag Name (a RawPath segment) tripped RawPaths.ValidateSegment, which refused the batch whole — including Node Control/Rebirth, mandatory on every Sparkplug edge node. ToRawSegment coerces the Name and leaves the address untouched. Coercion is not injective, so DescribeNameCollisions refuses a colliding set BY NAME: silently merging two metrics into one tag would be worse than the refusal it replaces. Applies to every driver, not just MQTT — the argument is about what a Name is, not about who produced it. The sanitiser is pinned against RawPaths.ValidateSegment rather than a copied charset. #510 stale rebirth affordance. _canRebirth was probed once at browse-open and sessions are reaped for idleness, so a modal left open offered an action whose token no longer resolved. Re-probed on arm AND on confirm (the reap can land between the two, and confirm is the one path that publishes to a live broker); the affordance retires with an explanation instead of vanishing or erroring. #514 rebirth on a quiet plant. The tree is birth-driven and births are never retained, so a plant that birthed before the window opened showed an empty tree — and the scope came only from a tree selection, so the one action that would fill the tree could not be armed. The session already accepted a bare {group}/{edgeNode} ("the prime rebirth target"); this adds the affordance. Edge-node scope only, not a group broadcast. The rule is extracted to SparkplugRebirthScope so it is testable — this repo has no bUnit — and is advisory: the session re-resolves and refuses before publishing. #512 .Browser -> .Driver layering. TWO extractions were needed, not the one the issue anticipated: it costed this as "the client-options builder", but the browse session decodes births itself, so moving only that would have left the reference exactly where it was. - MqttClientOptionsFactory (TLS, PEM CA-pin validator, credentials, protocol mapping) -> new leaf .Transport project. NOT .Contracts: that is deliberately transport-free and MqttClientOptions is an MQTTnet type, so .Contracts would hand MQTTnet to ControlPlane. This is the fix the .Browser csproj comment had already prescribed, against the issue's suggestion. - The Sparkplug protocol layer (SparkplugTopic/MessageType, SparkplugCodec, RebirthRequester) -> .Contracts, beside the Tahu wire schema it encodes. Still exactly one copy of the TLS/CA-pin logic; MqttBrowserLayeringTests guards the graph both ways. Measured, not assumed: Driver.Mqtt.dll, Polly.Core and Serilog are now absent from the AdminUI's output. Core.dll remains, because Driver.Sql.Browser has the SAME violation, undocumented — the old comment blamed this browser for Core and was wrong. Filed separately rather than fixed in passing. Full suite green apart from the 7 known pre-existing failures (4 AbLegacy integration, compose profiles not up; 3 Host.IntegrationTests). Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo
85 lines
3.0 KiB
C#
85 lines
3.0 KiB
C#
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.AdminUI.Browsing;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Browsing;
|
|
|
|
/// <summary>
|
|
/// Unit tests for <see cref="SparkplugRebirthScope"/> — the operator-typed <c>{group}/{edgeNode}</c>
|
|
/// rebirth scope that breaks the birth-driven-tree deadlock (Gitea #514).
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The rule is deliberately advisory — <c>MqttBrowseSession.ResolveRebirthTargets</c> re-resolves the
|
|
/// scope and refuses an unusable one before publishing — so these tests pin the <em>affordance</em>
|
|
/// behaviour: an operator must not be able to arm a two-click confirm that cannot succeed, and must
|
|
/// not be blocked from a scope the session would accept.
|
|
/// </remarks>
|
|
[Trait("Category", "Unit")]
|
|
public sealed class SparkplugRebirthScopeTests
|
|
{
|
|
[Theory]
|
|
[InlineData("OtOpcUaSim/EdgeA")]
|
|
[InlineData(" OtOpcUaSim/EdgeA ")]
|
|
[InlineData("g/e")]
|
|
[InlineData("Plant 1/Edge Node 2")] // spaces are legal inside a Sparkplug id
|
|
public void Accepts_a_group_and_edge_node(string scope)
|
|
{
|
|
SparkplugRebirthScope.Describe(scope).ShouldBeNull();
|
|
SparkplugRebirthScope.IsUsable(scope).ShouldBeTrue();
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(null)]
|
|
[InlineData("")]
|
|
[InlineData(" ")]
|
|
public void Rejects_a_blank_scope(string? scope)
|
|
=> SparkplugRebirthScope.Describe(scope).ShouldNotBeNullOrWhiteSpace();
|
|
|
|
[Fact]
|
|
public void Rejects_a_group_on_its_own()
|
|
{
|
|
// The session resolves a bare group by enumerating OBSERVED edge nodes, of which an empty tree
|
|
// has none — so accepting it here would arm a confirm step that resolves to nothing, in exactly
|
|
// the situation this input exists for.
|
|
var error = SparkplugRebirthScope.Describe("OtOpcUaSim");
|
|
|
|
error.ShouldNotBeNullOrWhiteSpace();
|
|
error!.ShouldContain("group/edgeNode");
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("g/e/extra")]
|
|
[InlineData("g//e")]
|
|
[InlineData("/EdgeA")]
|
|
[InlineData("OtOpcUaSim/")]
|
|
public void Rejects_a_scope_that_is_not_exactly_two_segments(string scope)
|
|
=> SparkplugRebirthScope.Describe(scope).ShouldNotBeNullOrWhiteSpace();
|
|
|
|
[Theory]
|
|
[InlineData("g/+")]
|
|
[InlineData("#/e")]
|
|
[InlineData("g/Edge#")]
|
|
public void Rejects_an_mqtt_wildcard(string scope)
|
|
{
|
|
// A rebirth NCMD is addressed to one edge node on a literal topic; a wildcard names no single
|
|
// node, and letting one through would build a topic the broker rejects.
|
|
var error = SparkplugRebirthScope.Describe(scope);
|
|
|
|
error.ShouldNotBeNullOrWhiteSpace();
|
|
error!.ShouldContain("wildcard");
|
|
}
|
|
|
|
[Fact]
|
|
public void Split_returns_the_trimmed_pair()
|
|
{
|
|
var (groupId, edgeNodeId) = SparkplugRebirthScope.Split(" OtOpcUaSim/EdgeA ");
|
|
|
|
groupId.ShouldBe("OtOpcUaSim");
|
|
edgeNodeId.ShouldBe("EdgeA");
|
|
}
|
|
|
|
[Fact]
|
|
public void Split_refuses_a_scope_Describe_would_reject()
|
|
=> Should.Throw<ArgumentException>(() => SparkplugRebirthScope.Split("OtOpcUaSim"));
|
|
}
|