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
This commit is contained in:
@@ -410,7 +410,7 @@ public sealed class MqttBrowseSessionTests
|
||||
a.Length.ShouldBe("-browse-".Length + 8);
|
||||
|
||||
var opts = new MqttDriverOptions { ClientId = "otopcua-node1" };
|
||||
MqttConnection.BuildClientOptions(opts, a).ClientId.ShouldBe("otopcua-node1" + a);
|
||||
MqttClientOptionsFactory.BuildClientOptions(opts, a).ClientId.ShouldBe("otopcua-node1" + a);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
using System.Reflection;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Pins the MQTT project layering that Gitea #512 restored: <c>.Browser</c> depends on the shared
|
||||
/// <c>.Contracts</c> + <c>.Transport</c> leaves, never on the runtime <c>.Driver</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Why this is worth a test.</b> <c>.Browser</c> used to reference the runtime driver, and the
|
||||
/// AdminUI references <c>.Browser</c> — so the AdminUI's build/deploy graph transitively contained
|
||||
/// the whole MQTT driver runtime plus Core, Polly.Core and Serilog. Nothing failed; the cost was
|
||||
/// invisible. A reference re-added out of convenience would be invisible in exactly the same way,
|
||||
/// which is precisely what a guard is for.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>What it can and cannot catch.</b> It asserts on emitted assembly references, so it fires when
|
||||
/// the browser actually <em>uses</em> a driver-runtime type — the case that matters. A
|
||||
/// <c>ProjectReference</c> re-added but never used would not trip it (the compiler emits no
|
||||
/// reference), so the csproj comment remains the primary record of the decision.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class MqttBrowserLayeringTests
|
||||
{
|
||||
private const string BrowserAssembly = "ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser";
|
||||
private const string DriverRuntimeAssembly = "ZB.MOM.WW.OtOpcUa.Driver.Mqtt";
|
||||
private const string ContractsAssembly = "ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts";
|
||||
private const string TransportAssembly = "ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Transport";
|
||||
|
||||
private static string[] BrowserReferences()
|
||||
{
|
||||
var browser = Assembly.Load(BrowserAssembly);
|
||||
return [.. browser.GetReferencedAssemblies().Select(a => a.Name!)];
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Browser_does_not_reference_the_runtime_driver()
|
||||
=> BrowserReferences().ShouldNotContain(
|
||||
DriverRuntimeAssembly,
|
||||
"the MQTT browser is back to referencing the runtime driver — every other bespoke browser " +
|
||||
"in this repo (OpcUaClient, Galaxy) depends on its .Contracts leaf only, and this reference " +
|
||||
"reaches the AdminUI. If shared connection policy is needed, put it in .Transport; if shared " +
|
||||
"protocol/codec is needed, put it in .Contracts. Do NOT copy the TLS/CA-pin logic.");
|
||||
|
||||
[Fact]
|
||||
public void Browser_reaches_the_shared_leaves_instead()
|
||||
{
|
||||
// The positive control. Asserting only the absence above would also pass if the browser stopped
|
||||
// referencing anything MQTT at all — e.g. if this test loaded the wrong assembly, or the type
|
||||
// names drifted — so pin what it SHOULD depend on in the same breath.
|
||||
var references = BrowserReferences();
|
||||
|
||||
references.ShouldContain(ContractsAssembly);
|
||||
references.ShouldContain(TransportAssembly);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Transport_stays_a_leaf_that_does_not_reach_back_into_the_runtime_driver()
|
||||
{
|
||||
// .Transport holds connection POLICY only. If it ever referenced the runtime driver, routing the
|
||||
// browser through it would restore the coupling this whole change removed, one hop further out.
|
||||
var references = Assembly.Load(TransportAssembly)
|
||||
.GetReferencedAssemblies()
|
||||
.Select(a => a.Name!)
|
||||
.ToArray();
|
||||
|
||||
references.ShouldNotContain(DriverRuntimeAssembly);
|
||||
references.ShouldNotContain(BrowserAssembly);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Contracts_stays_transport_free()
|
||||
=> Assembly.Load(ContractsAssembly)
|
||||
.GetReferencedAssemblies()
|
||||
.Select(a => a.Name!)
|
||||
.ShouldNotContain(
|
||||
"MQTTnet",
|
||||
"Driver.Mqtt.Contracts is deliberately transport-free — it carries the Sparkplug wire " +
|
||||
"schema and codec, and is referenced by ControlPlane, which has no business with a " +
|
||||
"broker client. MQTTnet-typed connection policy belongs in .Transport.");
|
||||
}
|
||||
@@ -13,7 +13,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Covers <see cref="MqttConnection"/>'s two Task-3 responsibilities: the pure
|
||||
/// <see cref="MqttConnection.BuildClientOptions"/> options assembly (TLS / CA pin /
|
||||
/// <see cref="MqttClientOptionsFactory.BuildClientOptions"/> options assembly (TLS / CA pin /
|
||||
/// credentials / protocol knobs) and the bounded <see cref="MqttConnection.ConnectAsync"/>
|
||||
/// deadline.
|
||||
/// </summary>
|
||||
@@ -189,7 +189,7 @@ public sealed class MqttConnectionTests
|
||||
|
||||
var opts = new MqttDriverOptions { Host = Host, Port = 8883, UseTls = true, CaCertificatePath = missing };
|
||||
|
||||
var built = MqttConnection.BuildClientOptions(opts, clientIdSuffix: null);
|
||||
var built = MqttClientOptionsFactory.BuildClientOptions(opts, clientIdSuffix: null);
|
||||
var tls = built.ChannelOptions.ShouldBeOfType<MqttClientTcpOptions>().TlsOptions;
|
||||
|
||||
tls.CertificateValidationHandler.ShouldNotBeNull();
|
||||
@@ -206,7 +206,7 @@ public sealed class MqttConnectionTests
|
||||
try
|
||||
{
|
||||
var opts = new MqttDriverOptions { Host = Host, Port = 8883, UseTls = true, CaCertificatePath = caPath };
|
||||
var built = MqttConnection.BuildClientOptions(opts, clientIdSuffix: null);
|
||||
var built = MqttClientOptionsFactory.BuildClientOptions(opts, clientIdSuffix: null);
|
||||
var tls = built.ChannelOptions.ShouldBeOfType<MqttClientTcpOptions>().TlsOptions;
|
||||
|
||||
using var stranger = SelfSignedLeaf(Host);
|
||||
@@ -232,7 +232,7 @@ public sealed class MqttConnectionTests
|
||||
try
|
||||
{
|
||||
var opts = new MqttDriverOptions { Host = Host, Port = 8883, UseTls = true, CaCertificatePath = caPath };
|
||||
var built = MqttConnection.BuildClientOptions(opts, clientIdSuffix: null);
|
||||
var built = MqttClientOptionsFactory.BuildClientOptions(opts, clientIdSuffix: null);
|
||||
var tls = built.ChannelOptions.ShouldBeOfType<MqttClientTcpOptions>().TlsOptions;
|
||||
|
||||
tls.CertificateValidationHandler!(ValidationArgs(built, leaf)).ShouldBeTrue();
|
||||
@@ -259,7 +259,7 @@ public sealed class MqttConnectionTests
|
||||
try
|
||||
{
|
||||
var opts = new MqttDriverOptions { Host = Host, Port = 8883, UseTls = true, CaCertificatePath = caPath };
|
||||
var built = MqttConnection.BuildClientOptions(opts, clientIdSuffix: null);
|
||||
var built = MqttClientOptionsFactory.BuildClientOptions(opts, clientIdSuffix: null);
|
||||
var tls = built.ChannelOptions.ShouldBeOfType<MqttClientTcpOptions>().TlsOptions;
|
||||
|
||||
tls.CertificateValidationHandler!(ValidationArgs(built, leaf, intermediate)).ShouldBeTrue();
|
||||
@@ -330,7 +330,7 @@ public sealed class MqttConnectionTests
|
||||
ConnectTimeoutSeconds = 7,
|
||||
};
|
||||
|
||||
var built = MqttConnection.BuildClientOptions(opts, clientIdSuffix: null);
|
||||
var built = MqttClientOptionsFactory.BuildClientOptions(opts, clientIdSuffix: null);
|
||||
|
||||
built.ProtocolVersion.ShouldBe(MQTTnet.Formatter.MqttProtocolVersion.V311);
|
||||
built.ClientId.ShouldBe("otopcua-node-1");
|
||||
@@ -347,12 +347,12 @@ public sealed class MqttConnectionTests
|
||||
{
|
||||
var opts = new MqttDriverOptions { Host = Host, ClientId = "otopcua" };
|
||||
|
||||
MqttConnection.BuildClientOptions(opts, clientIdSuffix: "-browse").ClientId.ShouldBe("otopcua-browse");
|
||||
MqttClientOptionsFactory.BuildClientOptions(opts, clientIdSuffix: "-browse").ClientId.ShouldBe("otopcua-browse");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildClientOptions_NoUsername_SendsNoCredentials()
|
||||
=> MqttConnection.BuildClientOptions(new MqttDriverOptions { Host = Host }, clientIdSuffix: null)
|
||||
=> MqttClientOptionsFactory.BuildClientOptions(new MqttDriverOptions { Host = Host }, clientIdSuffix: null)
|
||||
.Credentials.ShouldBeNull();
|
||||
|
||||
[Fact]
|
||||
@@ -360,7 +360,7 @@ public sealed class MqttConnectionTests
|
||||
{
|
||||
var opts = new MqttDriverOptions { Host = Host, Username = "svc", Password = "pw" };
|
||||
|
||||
var built = MqttConnection.BuildClientOptions(opts, clientIdSuffix: null);
|
||||
var built = MqttClientOptionsFactory.BuildClientOptions(opts, clientIdSuffix: null);
|
||||
|
||||
built.Credentials.ShouldNotBeNull();
|
||||
built.Credentials.GetUserName(built).ShouldBe("svc");
|
||||
@@ -1166,7 +1166,7 @@ public sealed class MqttConnectionTests
|
||||
}
|
||||
|
||||
private static MqttClientTlsOptions BuildTls(MqttDriverOptions opts)
|
||||
=> MqttConnection.BuildClientOptions(opts, clientIdSuffix: null)
|
||||
=> MqttClientOptionsFactory.BuildClientOptions(opts, clientIdSuffix: null)
|
||||
.ChannelOptions.ShouldBeOfType<MqttClientTcpOptions>().TlsOptions;
|
||||
|
||||
private static readonly DateTimeOffset NotBefore = DateTimeOffset.UtcNow.AddDays(-1);
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
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"));
|
||||
}
|
||||
@@ -160,6 +160,58 @@ public sealed class MqttTagConfigModelTests
|
||||
json.ShouldNotContain("FullName", Case.Sensitive);
|
||||
}
|
||||
|
||||
// ── payloadFormat is Plain-only (Gitea #513) ────────────────────────────────────────────────
|
||||
//
|
||||
// payloadFormat (Json/Raw/Scalar) selects a PLAIN-mode decode path. MqttTagDefinitionFactory routes
|
||||
// a Sparkplug tag on its descriptor keys and never reads it, so a payloadFormat in a Sparkplug blob
|
||||
// is not mis-binding anything — it is a key that looks like it selects a decode path and does not,
|
||||
// in a blob the operator reads (the raw-JSON textarea, a config export). It was written because
|
||||
// PayloadFormat is the one non-nullable Plain typed field, so "write what the field says" INVENTED
|
||||
// the key rather than preserving one.
|
||||
|
||||
[Fact]
|
||||
public void ToJson_omits_payloadFormat_for_a_sparkplug_tag()
|
||||
{
|
||||
var json = MqttTagConfigModel.FromJson(
|
||||
"""{"groupId":"Plant1","edgeNodeId":"EdgeA","metricName":"Node Control/Rebirth"}""").ToJson();
|
||||
|
||||
JsonNode.Parse(json)!.AsObject().ContainsKey("payloadFormat").ShouldBeFalse(
|
||||
"payloadFormat is a Plain-mode decode selector; a Sparkplug tag that never carried one must " +
|
||||
"not gain it on save. Actual blob: " + json);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToJson_still_writes_payloadFormat_for_a_plain_tag()
|
||||
{
|
||||
// The negative control for the test above: the omission must be mode-conditional, not a blanket
|
||||
// removal. A Plain tag's decode path is genuinely selected by this key.
|
||||
var json = new MqttTagConfigModel { Mode = MqttMode.Plain, Topic = "a/b", PayloadFormat = MqttPayloadFormat.Raw }
|
||||
.ToJson();
|
||||
|
||||
JsonNode.Parse(json)!.AsObject()["payloadFormat"]!.GetValue<string>().ShouldBe("Raw");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToJson_clears_a_stale_payloadFormat_when_a_plain_tag_is_retyped_to_sparkplug()
|
||||
{
|
||||
// The retype path is how a real blob acquires the stale key. Clearing it is legitimate precisely
|
||||
// because payloadFormat is a key this editor OWNS — the unknown-key assertions below pin that the
|
||||
// clearing stays surgical and does not start dropping foreign keys (the issue's explicit warning).
|
||||
var json = MqttTagConfigModel.FromJson(
|
||||
"""
|
||||
{"topic":"a/b","payloadFormat":"Raw","groupId":"Plant1","edgeNodeId":"EdgeA","metricName":"Temp",
|
||||
"isHistorized":true,"someFutureScalar":"keep me"}
|
||||
""").ToJson();
|
||||
|
||||
var o = JsonNode.Parse(json)!.AsObject();
|
||||
o.ContainsKey("payloadFormat").ShouldBeFalse();
|
||||
|
||||
// Descriptor keys intact, and the keys this editor does not own are untouched.
|
||||
o["metricName"]!.GetValue<string>().ShouldBe("Temp");
|
||||
o["isHistorized"]!.GetValue<bool>().ShouldBeTrue();
|
||||
o["someFutureScalar"]!.GetValue<string>().ShouldBe("keep me");
|
||||
}
|
||||
|
||||
// ── Validate: Sparkplug rules (Task 24) ─────────────────────────────────────────────────────
|
||||
//
|
||||
// Mirrors MqttTagDefinitionFactory.FromSparkplugTagConfig exactly: groupId/edgeNodeId/metricName
|
||||
|
||||
@@ -3,6 +3,7 @@ using System.Text.Json.Nodes;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Mqtt;
|
||||
@@ -397,4 +398,128 @@ public sealed class RawBrowseCommitMapperTests
|
||||
row.Tag.DataType.ShouldBe("Double");
|
||||
JsonNode.Parse(row.Tag.TagConfig)!.AsObject()["nodeId"]!.GetValue<string>().ShouldBe("ns=2;s=Tag1");
|
||||
}
|
||||
|
||||
// ---- ToRawSegment / name sanitisation (Gitea #509) --------------------------------------------
|
||||
//
|
||||
// A tag Name is a RawPath segment and may not contain '/'; a Sparkplug metric name legitimately
|
||||
// may, and the spec-MANDATORY 'Node Control/Rebirth' does. Committing used to be refused whole.
|
||||
// The address is a separate field and must survive the coercion untouched.
|
||||
|
||||
[Theory]
|
||||
[InlineData("Node Control/Rebirth", "Node Control_Rebirth")]
|
||||
[InlineData("Properties/Hardware Make", "Properties_Hardware Make")]
|
||||
[InlineData("a/b/c", "a_b_c")]
|
||||
[InlineData(" padded ", "padded")]
|
||||
[InlineData("plain", "plain")]
|
||||
public void ToRawSegment_coerces_a_browse_name_into_a_legal_segment(string browseName, string expected)
|
||||
=> RawBrowseCommitMapper.ToRawSegment(browseName).ShouldBe(expected);
|
||||
|
||||
[Theory]
|
||||
[InlineData("Node Control/Rebirth")]
|
||||
[InlineData("/leading")]
|
||||
[InlineData("trailing/")]
|
||||
[InlineData(" /spaced/ ")]
|
||||
[InlineData("a/b/c/d/e")]
|
||||
[InlineData("no-change-needed")]
|
||||
public void ToRawSegment_output_satisfies_the_RawPaths_segment_rule(string browseName)
|
||||
{
|
||||
// Pinned against the AUTHORITY, not against a charset copied into this test. RawPaths.ValidateSegment
|
||||
// is the rule the import will apply; if it gains a rule the sanitiser does not implement, this fails
|
||||
// here rather than letting an illegal Name reach ImportTagsAsync.
|
||||
var segment = RawBrowseCommitMapper.ToRawSegment(browseName);
|
||||
|
||||
RawPaths.ValidateSegment(segment).ShouldBeNull(
|
||||
$"'{browseName}' sanitised to '{segment}', which RawPaths still rejects");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MapLeaf_commits_a_slashed_sparkplug_metric_with_the_exact_metricName_preserved()
|
||||
{
|
||||
var row = RawBrowseCommitMapper.MapLeaf(
|
||||
driverType: DriverTypeNames.Mqtt,
|
||||
fullName: "OtOpcUaSim/EdgeA::Node Control/Rebirth",
|
||||
browseName: "Node Control/Rebirth",
|
||||
driverDataType: "Boolean",
|
||||
defaultDataType: "String",
|
||||
groupPrefix: null,
|
||||
folderPath: null,
|
||||
createGroups: false,
|
||||
addressFields: new Dictionary<string, string>
|
||||
{
|
||||
[MqttTagConfigKeys.GroupId] = "OtOpcUaSim",
|
||||
[MqttTagConfigKeys.EdgeNodeId] = "EdgeA",
|
||||
[MqttTagConfigKeys.MetricName] = "Node Control/Rebirth",
|
||||
});
|
||||
|
||||
// The Name is coerced...
|
||||
row.Tag.Name.ShouldBe("Node Control_Rebirth");
|
||||
RawPaths.ValidateSegment(row.Tag.Name).ShouldBeNull();
|
||||
|
||||
// ...and the binding address is NOT. This is the whole point: the two fields already existed
|
||||
// separately, and only the Name derivation conflated them.
|
||||
var o = JsonNode.Parse(row.Tag.TagConfig)!.AsObject();
|
||||
o[MqttTagConfigKeys.MetricName]!.GetValue<string>().ShouldBe("Node Control/Rebirth");
|
||||
o[MqttTagConfigKeys.GroupId]!.GetValue<string>().ShouldBe("OtOpcUaSim");
|
||||
o[MqttTagConfigKeys.EdgeNodeId]!.GetValue<string>().ShouldBe("EdgeA");
|
||||
}
|
||||
|
||||
// ---- DescribeNameCollisions ------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void DescribeNameCollisions_refuses_two_metrics_that_sanitise_onto_one_name()
|
||||
{
|
||||
// 'A/B' and 'A_B' are distinct metrics that both land on 'A_B'. Silently merging them would be
|
||||
// worse than the refusal #509 replaced — one tag bound to whichever address survived.
|
||||
var names = new[] { "A/B", "A_B" };
|
||||
var rows = names
|
||||
.Select(n => RawBrowseCommitMapper.MapLeaf(
|
||||
DriverTypeNames.Mqtt, $"g/e::{n}", n, "Float32", "Double", null, null, false,
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
[MqttTagConfigKeys.GroupId] = "g",
|
||||
[MqttTagConfigKeys.EdgeNodeId] = "e",
|
||||
[MqttTagConfigKeys.MetricName] = n,
|
||||
}))
|
||||
.ToList();
|
||||
|
||||
var errors = RawBrowseCommitMapper.DescribeNameCollisions(names, rows);
|
||||
|
||||
errors.Count.ShouldBe(1);
|
||||
// The message must name the specific pair — an anonymous batch failure is what made the original
|
||||
// refusal hard to act on.
|
||||
errors[0].ShouldContain("A_B");
|
||||
errors[0].ShouldContain("'A/B'");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DescribeNameCollisions_allows_the_same_metric_name_under_two_different_groups()
|
||||
{
|
||||
// Uniqueness is per target group, so this is NOT a collision. Without the scoping, mirroring a
|
||||
// browse tree with a repeated metric name under two devices would be refused wholesale.
|
||||
var rows = new[]
|
||||
{
|
||||
RawBrowseCommitMapper.MapLeaf(
|
||||
DriverTypeNames.OpcUaClient, "ns=2;s=1", "Temp", null, "Double",
|
||||
groupPrefix: "DeviceA", folderPath: null, createGroups: false),
|
||||
RawBrowseCommitMapper.MapLeaf(
|
||||
DriverTypeNames.OpcUaClient, "ns=2;s=2", "Temp", null, "Double",
|
||||
groupPrefix: "DeviceB", folderPath: null, createGroups: false),
|
||||
};
|
||||
|
||||
RawBrowseCommitMapper.DescribeNameCollisions(new[] { "Temp", "Temp" }, rows).ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DescribeNameCollisions_passes_a_clean_selection()
|
||||
=> RawBrowseCommitMapper.DescribeNameCollisions(
|
||||
new[] { "Node Control/Rebirth", "Temperature" },
|
||||
new[]
|
||||
{
|
||||
RawBrowseCommitMapper.MapLeaf(
|
||||
DriverTypeNames.OpcUaClient, "ns=2;s=1", "Node Control/Rebirth", null, "Double",
|
||||
null, null, false),
|
||||
RawBrowseCommitMapper.MapLeaf(
|
||||
DriverTypeNames.OpcUaClient, "ns=2;s=2", "Temperature", null, "Double",
|
||||
null, null, false),
|
||||
}).ShouldBeEmpty();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user