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
526 lines
23 KiB
C#
526 lines
23 KiB
C#
using System.Text.Json;
|
|
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;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
|
|
|
|
/// <summary>
|
|
/// Unit tests for <see cref="RawBrowseCommitMapper"/> — the WP6 browse-leaf → <see cref="RawTagImportRow"/>
|
|
/// mapper. Covers the driver data-type mapping, the per-driver address-field placement in the driver-typed
|
|
/// <c>TagConfig</c>, and the target-group + folder-mirror path combine.
|
|
/// </summary>
|
|
[Trait("Category", "Unit")]
|
|
public sealed class RawBrowseCommitMapperTests
|
|
{
|
|
// ---- MapDataType ----------------------------------------------------------------------------
|
|
|
|
[Theory]
|
|
[InlineData("Boolean", "Boolean")]
|
|
[InlineData("Int16", "Int16")]
|
|
[InlineData("Int32", "Int32")]
|
|
[InlineData("Int64", "Int64")]
|
|
[InlineData("UInt16", "UInt16")]
|
|
[InlineData("UInt32", "UInt32")]
|
|
[InlineData("UInt64", "UInt64")]
|
|
[InlineData("Float32", "Float")]
|
|
[InlineData("Float64", "Double")]
|
|
[InlineData("String", "String")]
|
|
[InlineData("DateTime", "DateTime")]
|
|
[InlineData("Reference", "String")]
|
|
public void MapDataType_maps_every_driver_data_type_to_its_opcua_builtin_name(string driverType, string expected)
|
|
=> RawBrowseCommitMapper.MapDataType(driverType).ShouldBe(expected);
|
|
|
|
[Fact]
|
|
public void MapDataType_is_case_insensitive()
|
|
=> RawBrowseCommitMapper.MapDataType("float32").ShouldBe("Float");
|
|
|
|
[Theory]
|
|
[InlineData(null)]
|
|
[InlineData("")]
|
|
[InlineData(" ")]
|
|
[InlineData("NotAType")]
|
|
public void MapDataType_returns_null_for_blank_or_unknown(string? input)
|
|
=> RawBrowseCommitMapper.MapDataType(input).ShouldBeNull();
|
|
|
|
[Fact]
|
|
public void MapDataType_covers_the_whole_DriverDataType_enum()
|
|
{
|
|
// Guard: if a new DriverDataType is added, this fails until MapDataType handles it.
|
|
foreach (var dt in Enum.GetValues<DriverDataType>())
|
|
RawBrowseCommitMapper.MapDataType(dt.ToString()).ShouldNotBeNull(
|
|
$"DriverDataType.{dt} has no OPC-UA type mapping.");
|
|
}
|
|
|
|
// ---- BuildTagConfig (address field per driver) ----------------------------------------------
|
|
|
|
[Theory]
|
|
[InlineData("OpcUaClient", "nodeId", "ns=2;s=Channel1.Device1.Tag1")]
|
|
[InlineData("AbCip", "tagPath", "Program:Main.Motor.Speed")]
|
|
[InlineData("AbLegacy", "address", "N7:0")]
|
|
[InlineData("TwinCAT", "symbolPath", "MAIN.bStart")]
|
|
[InlineData("FOCAS", "address", "R100")]
|
|
[InlineData("S7", "address", "DB1.DBW0")]
|
|
[InlineData("GalaxyMxGateway", "attributeRef", "Tank_001.Level")]
|
|
public void BuildTagConfig_writes_the_reference_into_the_drivers_address_field(
|
|
string driverType, string expectedKey, string reference)
|
|
{
|
|
var json = RawBrowseCommitMapper.BuildTagConfig(driverType, reference);
|
|
|
|
var o = JsonNode.Parse(json)!.AsObject();
|
|
o[expectedKey]!.GetValue<string>().ShouldBe(reference);
|
|
}
|
|
|
|
[Fact]
|
|
public void BuildTagConfig_is_case_insensitive_on_driver_type()
|
|
{
|
|
var json = RawBrowseCommitMapper.BuildTagConfig("opcuaclient", "ns=2;s=X");
|
|
JsonNode.Parse(json)!.AsObject()["nodeId"]!.GetValue<string>().ShouldBe("ns=2;s=X");
|
|
}
|
|
|
|
[Fact]
|
|
public void BuildTagConfig_does_not_write_any_identity_key()
|
|
{
|
|
// v3: identity is the RawPath — TagConfig must NOT carry a FullName identity key.
|
|
var json = RawBrowseCommitMapper.BuildTagConfig("OpcUaClient", "ns=2;s=X");
|
|
JsonNode.Parse(json)!.AsObject().ContainsKey("FullName").ShouldBeFalse();
|
|
}
|
|
|
|
[Fact]
|
|
public void BuildTagConfig_unknown_driver_falls_back_to_a_generic_address_key()
|
|
{
|
|
var json = RawBrowseCommitMapper.BuildTagConfig("Modbus", "40001");
|
|
JsonNode.Parse(json)!.AsObject()["address"]!.GetValue<string>().ShouldBe("40001");
|
|
}
|
|
|
|
// ---- BuildTagConfig: MQTT (the address is a DESCRIPTOR, not a single reference string) --------
|
|
|
|
/// <summary>The Sparkplug address a browse session states for a metric under a device.</summary>
|
|
/// <param name="metricName">The metric name to state.</param>
|
|
/// <returns>The stated address fields.</returns>
|
|
private static Dictionary<string, string> SparkplugFields(string metricName = "Temperature") => new()
|
|
{
|
|
[MqttTagConfigKeys.GroupId] = "OtOpcUaSim",
|
|
[MqttTagConfigKeys.EdgeNodeId] = "EdgeA",
|
|
[MqttTagConfigKeys.DeviceId] = "Filler1",
|
|
[MqttTagConfigKeys.MetricName] = metricName,
|
|
};
|
|
|
|
[Fact]
|
|
public void BuildTagConfig_Mqtt_plain_writes_the_topic_key_the_factory_actually_reads()
|
|
{
|
|
// The generic "address" fallback produced a blob MqttTagDefinitionFactory cannot read at all —
|
|
// a browse-committed tag that deploys clean and then reports BadNodeIdUnknown forever.
|
|
var json = RawBrowseCommitMapper.BuildTagConfig(
|
|
"Mqtt",
|
|
"otopcua/fixture/oven/temp",
|
|
new Dictionary<string, string> { [MqttTagConfigKeys.Topic] = "otopcua/fixture/oven/temp" });
|
|
|
|
JsonNode.Parse(json)!.AsObject()["topic"]!.GetValue<string>().ShouldBe("otopcua/fixture/oven/temp");
|
|
}
|
|
|
|
[Fact]
|
|
public void BuildTagConfig_Mqtt_plain_falls_back_to_the_node_id_when_no_field_was_stated()
|
|
{
|
|
// A Plain browse node id IS the topic, so the fallback is exact — unlike Sparkplug, where the
|
|
// tuple is unrecoverable and DescribeUncommittableLeaf refuses instead.
|
|
var json = RawBrowseCommitMapper.BuildTagConfig("Mqtt", "otopcua/fixture/oven/temp");
|
|
|
|
JsonNode.Parse(json)!.AsObject()["topic"]!.GetValue<string>().ShouldBe("otopcua/fixture/oven/temp");
|
|
}
|
|
|
|
[Fact]
|
|
public void BuildTagConfig_Mqtt_sparkplug_writes_the_binding_tuple_from_the_stated_fields()
|
|
{
|
|
var json = RawBrowseCommitMapper.BuildTagConfig(
|
|
"Mqtt", "OtOpcUaSim/EdgeA/Filler1::Temperature", SparkplugFields());
|
|
|
|
var o = JsonNode.Parse(json)!.AsObject();
|
|
o["groupId"]!.GetValue<string>().ShouldBe("OtOpcUaSim");
|
|
o["edgeNodeId"]!.GetValue<string>().ShouldBe("EdgeA");
|
|
o["deviceId"]!.GetValue<string>().ShouldBe("Filler1");
|
|
o["metricName"]!.GetValue<string>().ShouldBe("Temperature");
|
|
o.ContainsKey("topic").ShouldBeFalse(); // a Sparkplug tag has no per-tag topic
|
|
o.ContainsKey("address").ShouldBeFalse(); // and never the generic fallback key
|
|
}
|
|
|
|
[Fact]
|
|
public void BuildTagConfig_Mqtt_sparkplug_node_level_metric_omits_deviceId_entirely()
|
|
{
|
|
var fields = SparkplugFields();
|
|
fields.Remove(MqttTagConfigKeys.DeviceId);
|
|
|
|
var json = RawBrowseCommitMapper.BuildTagConfig("Mqtt", "OtOpcUaSim/EdgeA::Temperature", fields);
|
|
|
|
JsonNode.Parse(json)!.AsObject().ContainsKey("deviceId").ShouldBeFalse();
|
|
}
|
|
|
|
[Fact]
|
|
public void BuildTagConfig_Mqtt_sparkplug_takes_the_metric_name_from_the_fields_not_the_node_id()
|
|
{
|
|
// THE case the whole seam exists for: a metric name containing '/' makes the node id
|
|
// '{group}/{edge}/{device}::{metric}' un-splittable — any parse would bind the wrong metric.
|
|
var json = RawBrowseCommitMapper.BuildTagConfig(
|
|
"Mqtt",
|
|
"OtOpcUaSim/EdgeA/Filler1::Node Control/Rebirth",
|
|
SparkplugFields("Node Control/Rebirth"));
|
|
|
|
var o = JsonNode.Parse(json)!.AsObject();
|
|
o["metricName"]!.GetValue<string>().ShouldBe("Node Control/Rebirth");
|
|
o["deviceId"]!.GetValue<string>().ShouldBe("Filler1");
|
|
}
|
|
|
|
[Fact]
|
|
public void BuildTagConfig_Mqtt_sparkplug_survives_a_group_id_containing_the_metric_separator()
|
|
{
|
|
// A group id is an arbitrary MQTT topic segment and MAY contain '::'. Splitting the node id on
|
|
// the first '::' would read the group as 'odd' and the metric as 'group/EdgeA::Temperature'.
|
|
var fields = SparkplugFields();
|
|
fields[MqttTagConfigKeys.GroupId] = "odd::group";
|
|
|
|
var json = RawBrowseCommitMapper.BuildTagConfig(
|
|
"Mqtt", "odd::group/EdgeA/Filler1::Temperature", fields);
|
|
|
|
var o = JsonNode.Parse(json)!.AsObject();
|
|
o["groupId"]!.GetValue<string>().ShouldBe("odd::group");
|
|
o["metricName"]!.GetValue<string>().ShouldBe("Temperature");
|
|
}
|
|
|
|
[Fact]
|
|
public void BuildTagConfig_Mqtt_sparkplug_blob_deserializes_through_the_REAL_driver_factory()
|
|
{
|
|
// The round trip that would have caught the defect: the committed blob is fed to the factory the
|
|
// deployed driver actually uses. Anything else is a test of the mapper agreeing with itself.
|
|
var json = RawBrowseCommitMapper.BuildTagConfig(
|
|
"Mqtt",
|
|
"OtOpcUaSim/EdgeA/Filler1::Node Control/Rebirth",
|
|
SparkplugFields("Node Control/Rebirth"));
|
|
|
|
MqttTagDefinitionFactory
|
|
.FromSparkplugTagConfig(json, "Plant/Mqtt/dev1/Rebirth", out var def)
|
|
.ShouldBeTrue();
|
|
|
|
def.Name.ShouldBe("Plant/Mqtt/dev1/Rebirth"); // v3: identity is the RawPath
|
|
def.GroupId.ShouldBe("OtOpcUaSim");
|
|
def.EdgeNodeId.ShouldBe("EdgeA");
|
|
def.DeviceId.ShouldBe("Filler1");
|
|
def.MetricName.ShouldBe("Node Control/Rebirth");
|
|
def.DataTypeAuthored.ShouldBeFalse(); // no dataType key ⇒ take the birth's declared type
|
|
}
|
|
|
|
[Fact]
|
|
public void BuildTagConfig_Mqtt_plain_blob_deserializes_through_the_REAL_driver_factory()
|
|
{
|
|
var json = RawBrowseCommitMapper.BuildTagConfig(
|
|
"Mqtt",
|
|
"otopcua/fixture/oven/temp",
|
|
new Dictionary<string, string> { [MqttTagConfigKeys.Topic] = "otopcua/fixture/oven/temp" });
|
|
|
|
MqttTagDefinitionFactory.FromTagConfig(json, "Plant/Mqtt/dev1/Temp", out var def).ShouldBeTrue();
|
|
def.Topic.ShouldBe("otopcua/fixture/oven/temp");
|
|
def.Name.ShouldBe("Plant/Mqtt/dev1/Temp");
|
|
}
|
|
|
|
[Fact]
|
|
public void The_round_trip_test_is_falsifiable_a_wrong_key_name_is_rejected_by_the_factory()
|
|
{
|
|
// Control: mutate the emitted key name (what the pre-fix mapper effectively did, writing
|
|
// "address") and the factory must REFUSE it. Without this, the round-trip assertions above could
|
|
// pass off any blob the factory happened to tolerate.
|
|
var wrong = new JsonObject
|
|
{
|
|
["group"] = "OtOpcUaSim", // not "groupId"
|
|
["edgeNodeId"] = "EdgeA",
|
|
["metricName"] = "Temperature",
|
|
}.ToJsonString();
|
|
|
|
MqttTagDefinitionFactory.FromSparkplugTagConfig(wrong, "Plant/Mqtt/dev1/T", out _).ShouldBeFalse();
|
|
|
|
// And the exact blob the pre-fix generic fallback produced.
|
|
var legacy = new JsonObject { ["address"] = "OtOpcUaSim/EdgeA/Filler1::Temperature" }.ToJsonString();
|
|
MqttTagDefinitionFactory.FromSparkplugTagConfig(legacy, "Plant/Mqtt/dev1/T", out _).ShouldBeFalse();
|
|
MqttTagDefinitionFactory.FromTagConfig(legacy, "Plant/Mqtt/dev1/T", out _).ShouldBeFalse();
|
|
}
|
|
|
|
[Fact]
|
|
public void DescribeUncommittableLeaf_refuses_an_mqtt_leaf_whose_address_was_never_stated()
|
|
{
|
|
// No stated address ⇒ no bindable tag. Failing here, in words, is the whole point: the old path
|
|
// committed it silently and the operator learned about it as a runtime BadNodeIdUnknown.
|
|
var error = RawBrowseCommitMapper.DescribeUncommittableLeaf("Mqtt", "Temperature", addressFields: null);
|
|
|
|
error.ShouldNotBeNull();
|
|
error!.ShouldContain("Temperature");
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(MqttTagConfigKeys.Topic, "otopcua/fixture/oven/temp")]
|
|
[InlineData(MqttTagConfigKeys.MetricName, "Temperature")]
|
|
public void DescribeUncommittableLeaf_accepts_either_stated_mqtt_shape(string key, string value)
|
|
=> RawBrowseCommitMapper
|
|
.DescribeUncommittableLeaf("Mqtt", "leaf", new Dictionary<string, string> { [key] = value })
|
|
.ShouldBeNull();
|
|
|
|
[Fact]
|
|
public void DescribeUncommittableLeaf_never_blocks_a_single_reference_driver()
|
|
{
|
|
// Every other driver's address IS the node id — they state no fields and must stay committable.
|
|
foreach (var driver in new[] { "OpcUaClient", "AbCip", "TwinCAT", "S7", "GalaxyMxGateway", "Modbus" })
|
|
RawBrowseCommitMapper.DescribeUncommittableLeaf(driver, "leaf", addressFields: null).ShouldBeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public void MapLeaf_flows_the_stated_address_fields_into_the_committed_row()
|
|
{
|
|
var row = RawBrowseCommitMapper.MapLeaf(
|
|
driverType: "Mqtt",
|
|
fullName: "OtOpcUaSim/EdgeA/Filler1::Temperature",
|
|
browseName: "Temperature",
|
|
driverDataType: "Float32",
|
|
defaultDataType: "Double",
|
|
groupPrefix: null,
|
|
folderPath: new[] { "OtOpcUaSim", "EdgeA", "Filler1" },
|
|
createGroups: false,
|
|
addressFields: SparkplugFields());
|
|
|
|
row.Tag.Name.ShouldBe("Temperature");
|
|
row.Tag.DataType.ShouldBe("Float");
|
|
MqttTagDefinitionFactory
|
|
.FromSparkplugTagConfig(row.Tag.TagConfig, "Plant/Mqtt/dev1/Temperature", out var def)
|
|
.ShouldBeTrue();
|
|
def.MetricName.ShouldBe("Temperature");
|
|
}
|
|
|
|
// ---- CombineGroupPath -----------------------------------------------------------------------
|
|
|
|
[Theory]
|
|
[InlineData(null, null, null)]
|
|
[InlineData("Grp", null, "Grp")]
|
|
[InlineData(null, "Sub", "Sub")]
|
|
[InlineData("Grp", "Sub", "Grp/Sub")]
|
|
[InlineData("Grp/Deep", "Sub/Leaf", "Grp/Deep/Sub/Leaf")]
|
|
[InlineData(" ", "Sub", "Sub")]
|
|
public void CombineGroupPath_joins_prefix_and_suffix(string? prefix, string? suffix, string? expected)
|
|
=> RawBrowseCommitMapper.CombineGroupPath(prefix, suffix).ShouldBe(expected);
|
|
|
|
// ---- MapLeaf (end to end) -------------------------------------------------------------------
|
|
|
|
[Fact]
|
|
public void MapLeaf_builds_a_read_row_with_typed_config_and_no_group_at_device_root()
|
|
{
|
|
var row = RawBrowseCommitMapper.MapLeaf(
|
|
driverType: "TwinCAT",
|
|
fullName: "MAIN.rSpeed",
|
|
browseName: "rSpeed",
|
|
driverDataType: "Float32",
|
|
defaultDataType: "Double",
|
|
groupPrefix: null,
|
|
folderPath: new[] { "MAIN" },
|
|
createGroups: false);
|
|
|
|
row.TagGroupPath.ShouldBeNull(); // mirror OFF, no target group
|
|
row.Tag.Name.ShouldBe("rSpeed");
|
|
row.Tag.DataType.ShouldBe("Float"); // Float32 -> Float
|
|
row.Tag.AccessLevel.ShouldBe(TagAccessLevel.Read);
|
|
row.Tag.WriteIdempotent.ShouldBeFalse();
|
|
row.Tag.PollGroupId.ShouldBeNull();
|
|
JsonNode.Parse(row.Tag.TagConfig)!.AsObject()["symbolPath"]!.GetValue<string>().ShouldBe("MAIN.rSpeed");
|
|
}
|
|
|
|
[Fact]
|
|
public void MapLeaf_mirrors_folder_path_onto_group_when_create_groups_on()
|
|
{
|
|
var row = RawBrowseCommitMapper.MapLeaf(
|
|
driverType: "AbCip",
|
|
fullName: "Program:Main.Motor.Speed",
|
|
browseName: "Speed",
|
|
driverDataType: "Int32",
|
|
defaultDataType: "Double",
|
|
groupPrefix: null,
|
|
folderPath: new[] { "Program:Main", "Motor" },
|
|
createGroups: true);
|
|
|
|
row.TagGroupPath.ShouldBe("Program:Main/Motor");
|
|
}
|
|
|
|
[Fact]
|
|
public void MapLeaf_prepends_target_group_prefix_and_mirror()
|
|
{
|
|
var row = RawBrowseCommitMapper.MapLeaf(
|
|
driverType: "AbCip",
|
|
fullName: "Program:Main.Motor.Speed",
|
|
browseName: "Speed",
|
|
driverDataType: "Int32",
|
|
defaultDataType: "Double",
|
|
groupPrefix: "Imported",
|
|
folderPath: new[] { "Motor" },
|
|
createGroups: true);
|
|
|
|
row.TagGroupPath.ShouldBe("Imported/Motor");
|
|
}
|
|
|
|
[Fact]
|
|
public void MapLeaf_uses_target_group_prefix_only_when_mirror_off()
|
|
{
|
|
var row = RawBrowseCommitMapper.MapLeaf(
|
|
driverType: "AbCip",
|
|
fullName: "Program:Main.Motor.Speed",
|
|
browseName: "Speed",
|
|
driverDataType: "Int32",
|
|
defaultDataType: "Double",
|
|
groupPrefix: "Imported",
|
|
folderPath: new[] { "Motor" },
|
|
createGroups: false);
|
|
|
|
row.TagGroupPath.ShouldBe("Imported");
|
|
}
|
|
|
|
[Fact]
|
|
public void MapLeaf_falls_back_to_default_data_type_when_browser_reports_none()
|
|
{
|
|
// OPC UA Client browse reports no attribute type — the default fills in.
|
|
var row = RawBrowseCommitMapper.MapLeaf(
|
|
driverType: "OpcUaClient",
|
|
fullName: "ns=2;s=Tag1",
|
|
browseName: "Tag1",
|
|
driverDataType: null,
|
|
defaultDataType: "Double",
|
|
groupPrefix: null,
|
|
folderPath: null,
|
|
createGroups: true); // mirror ON but null folderPath -> still device root
|
|
|
|
row.TagGroupPath.ShouldBeNull();
|
|
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();
|
|
}
|