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.Configuration.Enums; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; 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: 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"); } }