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.AdminUI.Uns.TagEditors; 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.AbCip; using ZB.MOM.WW.OtOpcUa.Driver.Mqtt; namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns; /// /// Unit tests for — the WP6 browse-leaf → /// mapper. Covers the driver data-type mapping, the per-driver address-field placement in the driver-typed /// TagConfig, and the target-group + folder-mirror path combine. /// [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()) 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().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().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().ShouldBe("40001"); } // ---- BuildTagConfig: the browsed data type (Gitea #536) -------------------------------------- /// The address fields a captured browse leaf states for its driver-native type. /// The driver's own data-type enum name, as the driver stamped it. /// The stated address fields. private static Dictionary NativeTypeFields(string nativeType) => new() { ["dataType"] = nativeType }; /// /// Gitea #540 — the commit path, judged by whether what it produces is authorable rather /// than by which key it lands under. /// /// The mapper is not where that defect lived: it faithfully wrote the reference the FOCAS /// browser handed it, which was {deviceHost}/{path} and parses as nothing. The fix /// was in what the driver emits, so the check that keeps it fixed is that a leaf reference /// survives the commit AND passes the same validation the modal applies — a mapper test /// asserting only o["address"] == reference passed happily throughout. /// /// /// A leaf reference of the kind the FOCAS browser emits. [Theory] [InlineData("FIXED:Identity/SeriesNumber")] [InlineData("FIXED:Axes/X/AbsolutePosition")] [InlineData("FIXED:Spindle/S1/Load")] [InlineData("FIXED:Timers/PowerOnSeconds")] [InlineData("MACRO:3901")] public void A_committed_FOCAS_leaf_is_an_address_the_authoring_gate_accepts(string reference) { var json = RawBrowseCommitMapper.BuildTagConfig(DriverTypeNames.FOCAS, reference); var model = FocasTagConfigModel.FromJson(json); model.Address.ShouldBe(reference); model.Validate().ShouldBeNull($"'{reference}' committed to a tag the driver would skip"); } /// /// The committed dataType is the type the CONTROLLER reported, not the model default. /// AB CIP / TwinCAT / FOCAS decode the wire bytes by this field, so a browsed REAL left at the /// DInt default reads four bytes of a float as an integer and publishes a plausible /// wrong number with no error anywhere. /// /// The owning device's driver type. /// The native type the browse leaf stated. /// The leaf's driver-side reference. [Theory] [InlineData("AbCip", "Real", "Pump_1_Speed")] [InlineData("AbCip", "Bool", "Motor_Run")] [InlineData("AbCip", "String", "Recipe_Name")] [InlineData("TwinCAT", "Bool", "MAIN.bStart")] [InlineData("FOCAS", "Float64", "MACRO100")] public void BuildTagConfig_stamps_the_browsed_data_type( string driverType, string nativeType, string reference) { var json = RawBrowseCommitMapper.BuildTagConfig(driverType, reference, NativeTypeFields(nativeType)); JsonNode.Parse(json)!.AsObject()["dataType"]!.GetValue().ShouldBe(nativeType); } /// /// The narrow integer widths are the reason the native type is CARRIED rather than derived from /// the leaf's driver-agnostic DriverDataType: SInt, Int and DInt all /// leave the driver as Int32, so a reverse map would read a 16-bit INT through a 32-bit /// decoder — two bytes of the next tag's memory included — and still look like a fix. /// /// The Logix integer width the controller reported. [Theory] [InlineData("SInt")] [InlineData("Int")] [InlineData("DInt")] [InlineData("UDInt")] public void BuildTagConfig_keeps_integer_widths_that_the_agnostic_type_collapses(string nativeType) { // That these widths really do collapse on the way out of the driver is pinned next to the map // itself — AbCipDataTypeExtensionsTests.The_integer_widths_collapse_which_is_why_the_native_type_is_carried. var json = RawBrowseCommitMapper.BuildTagConfig("AbCip", "Line1_Count", NativeTypeFields(nativeType)); JsonNode.Parse(json)!.AsObject()["dataType"]!.GetValue().ShouldBe(nativeType); } /// /// A leaf that states no type — a browser that reports none, or a session captured before the /// driver stamped it — keeps the model default rather than guessing. That is the pre-#536 /// behaviour, deliberately retained as the fallback. /// /// The owning device's driver type. /// The model default the commit must fall back to. [Theory] [InlineData("AbCip", "DInt")] [InlineData("TwinCAT", "DInt")] [InlineData("FOCAS", "Int32")] public void BuildTagConfig_falls_back_to_the_model_default_when_no_type_is_stated( string driverType, string expectedDefault) { var noFields = RawBrowseCommitMapper.BuildTagConfig(driverType, "X"); var unparseable = RawBrowseCommitMapper.BuildTagConfig( driverType, "X", NativeTypeFields("NotATypeThisDriverKnows")); JsonNode.Parse(noFields)!.AsObject()["dataType"]!.GetValue().ShouldBe(expectedDefault); JsonNode.Parse(unparseable)!.AsObject()["dataType"]!.GetValue().ShouldBe(expectedDefault); } /// /// A numeric string is NOT a type name. Enum.TryParse accepts "3" for any enum and yields /// whatever member happens to sit at 3, so an unguarded parse would silently bind a tag to a type /// nobody named — the same trap MTConnectTagConfigModel documents for its own dataType. /// [Fact] public void BuildTagConfig_rejects_a_numeric_type_rather_than_indexing_the_enum() { // AbCipDataType member 9 is Real; a numeric parse would hand back exactly that. ((AbCipDataType)9).ShouldBe(AbCipDataType.Real); var json = RawBrowseCommitMapper.BuildTagConfig("AbCip", "X", NativeTypeFields("9")); JsonNode.Parse(json)!.AsObject()["dataType"]!.GetValue().ShouldBe("DInt"); } /// /// The stated type never displaces the address — the two travel in different fields and a commit /// that got the type right by losing the binding would be no better than the defect. /// [Fact] public void BuildTagConfig_stamps_the_type_without_disturbing_the_address() { var json = RawBrowseCommitMapper.BuildTagConfig( "AbCip", "Program:Main.Motor.Speed", NativeTypeFields("Real")); var o = JsonNode.Parse(json)!.AsObject(); o["tagPath"]!.GetValue().ShouldBe("Program:Main.Motor.Speed"); o["dataType"]!.GetValue().ShouldBe("Real"); } /// /// A driver whose tag config carries no data type is unaffected — the key must not appear, or a /// typed editor would round-trip a field its model does not own. /// /// The owning device's driver type. [Theory] [InlineData("OpcUaClient")] [InlineData("GalaxyMxGateway")] public void BuildTagConfig_does_not_invent_a_data_type_for_a_driver_that_has_none(string driverType) { var json = RawBrowseCommitMapper.BuildTagConfig(driverType, "X", NativeTypeFields("Real")); JsonNode.Parse(json)!.AsObject().ContainsKey("dataType").ShouldBeFalse(); } // ---- BuildTagConfig: MQTT (the address is a DESCRIPTOR, not a single reference string) -------- /// The Sparkplug address a browse session states for a metric under a device. /// The metric name to state. /// The stated address fields. private static Dictionary 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 { [MqttTagConfigKeys.Topic] = "otopcua/fixture/oven/temp" }); JsonNode.Parse(json)!.AsObject()["topic"]!.GetValue().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().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().ShouldBe("OtOpcUaSim"); o["edgeNodeId"]!.GetValue().ShouldBe("EdgeA"); o["deviceId"]!.GetValue().ShouldBe("Filler1"); o["metricName"]!.GetValue().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().ShouldBe("Node Control/Rebirth"); o["deviceId"]!.GetValue().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().ShouldBe("odd::group"); o["metricName"]!.GetValue().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 { [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 { [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().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().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 { [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().ShouldBe("Node Control/Rebirth"); o[MqttTagConfigKeys.GroupId]!.GetValue().ShouldBe("OtOpcUaSim"); o[MqttTagConfigKeys.EdgeNodeId]!.GetValue().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 { [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(); }