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; using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts; 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(); } // ---- MTConnect: the commit must not retype the node it just typed (Gitea #560) ---------------- /// /// A browse-committed MTConnect tag's OPC UA node type and its TagConfig coercion type /// must BOTH come from the leaf's probe-inferred — the same defect /// class as #536 arriving through a different door. /// /// The pair is asserted together on purpose. The blob alone would pass against a mapper that /// stopped typing the row, and the row alone was already right while the defect was live: /// MapLeaf typed the node Double from the inference while BuildTagConfig /// stamped the model's String default into the blob, and a non-blank authored type wins /// the driver's coercion precedence — so every browsed SAMPLE published text into a numeric /// node. Only the DISAGREEMENT is the bug, so only the agreement can pin the fix. /// /// /// The the Agent's /probe inference reported. /// The OPC UA built-in name the raw tag row must carry. [Theory] [InlineData("Float64", "Double")] [InlineData("Int64", "Int64")] [InlineData("Boolean", "Boolean")] [InlineData("String", "String")] // an EVENT item — String is a real inference here, not a default public void MapLeaf_types_an_MTConnect_node_and_its_coercion_alike(string inferred, string expectedNodeType) { var row = RawBrowseCommitMapper.MapLeaf( driverType: DriverTypeNames.MTConnect, fullName: "dev1_pos", browseName: "position", driverDataType: inferred, defaultDataType: "Double", groupPrefix: null, folderPath: null, createGroups: false); row.Tag.DataType.ShouldBe(expectedNodeType); JsonNode.Parse(row.Tag.TagConfig)!.AsObject()["dataType"]!.GetValue().ShouldBe(inferred); // …and the blob stays one the typed editor and the driver both accept, so the commit is not // merely self-consistent but authorable. var model = MTConnectTagConfigModel.FromJson(row.Tag.TagConfig); model.FullName.ShouldBe("dev1_pos"); model.DataType.ShouldBe(Enum.Parse(inferred)); model.Validate().ShouldBeNull(); } /// /// The inferred type is forwarded through BuildTagConfig itself, not patched on by the /// caller — the same seam AB CIP / TwinCAT / FOCAS forward their browsed type through. /// [Fact] public void BuildTagConfig_stamps_the_browsed_MTConnect_type() { var json = RawBrowseCommitMapper.BuildTagConfig( DriverTypeNames.MTConnect, "dev1_pos", addressFields: null, driverDataType: "Float64"); var o = JsonNode.Parse(json)!.AsObject(); o["fullName"]!.GetValue().ShouldBe("dev1_pos"); o["dataType"]!.GetValue().ShouldBe("Float64"); } /// /// The parity control for the theory above: a leaf the browser reported NO type for keeps the /// model's own String default — the pre-fix behaviour, and the right answer for a /// weakly-typed Agent. A "fix" that emitted a blank or absent dataType would satisfy /// "not String by default" while leaving the blob unauthorable, so this is what stops the /// forward being implemented as a deletion. /// [Fact] public void MapLeaf_keeps_the_MTConnect_String_default_when_the_browser_states_no_type() { var row = RawBrowseCommitMapper.MapLeaf( driverType: DriverTypeNames.MTConnect, fullName: "dev1_exec", browseName: "execution", driverDataType: null, defaultDataType: "String", groupPrefix: null, folderPath: null, createGroups: false); JsonNode.Parse(row.Tag.TagConfig)!.AsObject()["dataType"]!.GetValue().ShouldBe("String"); MTConnectTagConfigModel.FromJson(row.Tag.TagConfig).Validate().ShouldBeNull(); } /// /// A type name does not carry — an older browser, or a driver that /// stamped something else — keeps the model default rather than failing the commit or writing a /// name the runtime would reject. Mirrors the sibling drivers' fallback exactly, including the /// numeric-string guard: Enum.TryParse accepts "8" for any enum and hands back /// whatever member sits at that ordinal. /// /// The unusable type name the browse leaf stated. [Theory] [InlineData("NotATypeThisDriverKnows")] [InlineData("8")] public void BuildTagConfig_falls_back_to_String_for_an_unusable_MTConnect_type(string stated) { var json = RawBrowseCommitMapper.BuildTagConfig( DriverTypeNames.MTConnect, "dev1_pos", addressFields: null, driverDataType: stated); JsonNode.Parse(json)!.AsObject()["dataType"]!.GetValue().ShouldBe("String"); } // ---- 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(); // ---- BuildTagConfig: Sql (the address is a TUPLE, and the third part is WHICH ROW) — Gitea #552 -- /// The address a SqlBrowseSession states for a column of a table that has a timestamp. /// The owning schema. /// The owning table. /// The browsed column. /// The table's preferred date/time column, or null to state none. /// The stated address fields. private static Dictionary SqlFields( string schema = "dbo", string table = "Readings", string column = "oven_temp", string? timestampColumn = "recorded") { var fields = new Dictionary(StringComparer.Ordinal) { [SqlBrowseAddressFields.Schema] = schema, [SqlBrowseAddressFields.Table] = table, [SqlBrowseAddressFields.ColumnName] = column, }; if (timestampColumn is not null) fields[SqlBrowseAddressFields.TopByTimestamp] = timestampColumn; return fields; } [Fact] public void BuildTagConfig_Sql_writes_the_row_selector_the_session_stated() { var json = RawBrowseCommitMapper.BuildTagConfig(DriverTypeNames.Sql, "oven_temp", SqlFields()); var o = JsonNode.Parse(json)!.AsObject(); o["model"]!.GetValue().ShouldBe(nameof(SqlTagModel.WideRow)); o["table"]!.GetValue().ShouldBe("Readings"); o["columnName"]!.GetValue().ShouldBe("oven_temp"); o["rowSelector"]!.AsObject()["topByTimestamp"]!.GetValue().ShouldBe("recorded"); o.ContainsKey("address").ShouldBeFalse(); // never the generic fallback key } /// /// The blob must satisfy BOTH gates, and they are two different implementations of one rule: the /// AdminUI model (what the authoring gate and the typed editor run) and /// (what the deployed driver runs). Before #552 the emitted blob /// failed both — so a browse-committed Sql tag was never readable. /// [Fact] public void BuildTagConfig_Sql_blob_is_accepted_by_the_model_AND_by_the_REAL_driver_parser() { var json = RawBrowseCommitMapper.BuildTagConfig(DriverTypeNames.Sql, "oven_temp", SqlFields()); SqlTagConfigModel.FromJson(json).Validate().ShouldBeNull(); TagConfigValidator.Validate(DriverTypeNames.Sql, json).ShouldBeNull(); SqlEquipmentTagParser.TryParse(json, "Plant/Sql/dev1/OvenTemp", out var def).ShouldBeTrue(); def.Name.ShouldBe("Plant/Sql/dev1/OvenTemp"); // v3: identity is the RawPath def.Model.ShouldBe(SqlTagModel.WideRow); def.Table.ShouldBe("Readings"); def.ColumnName.ShouldBe("oven_temp"); def.RowSelectorTopByTimestamp.ShouldBe("recorded"); def.RowSelectorColumn.ShouldBeNull(); def.RowSelectorValue.ShouldBeNull(); } /// /// The falsification control for the pair above: the blob the mapper emitted BEFORE #552 — the same /// bytes minus the selector — must be refused by both. Without it, the acceptance assertions could /// pass against gates that accept anything. /// [Fact] public void The_Sql_double_acceptance_is_falsifiable_a_selector_less_blob_is_refused_by_both() { var legacy = new JsonObject { ["model"] = nameof(SqlTagModel.WideRow), ["table"] = "Readings", ["columnName"] = "oven_temp", }.ToJsonString(); SqlTagConfigModel.FromJson(legacy).Validate().ShouldNotBeNullOrEmpty(); TagConfigValidator.Validate(DriverTypeNames.Sql, legacy).ShouldNotBeNullOrEmpty(); SqlEquipmentTagParser.TryParse(legacy, "Plant/Sql/dev1/OvenTemp", out _).ShouldBeFalse(); } /// /// The selector is a visible, editable DEFAULT: it survives a load→save through the typed editor's /// model unchanged, and the operator can replace it with a where-pair, which must also still parse. /// [Fact] public void BuildTagConfig_Sql_blob_round_trips_the_typed_editor() { var json = RawBrowseCommitMapper.BuildTagConfig(DriverTypeNames.Sql, "oven_temp", SqlFields()); var loaded = SqlTagConfigModel.FromJson(json); loaded.Model.ShouldBe(SqlTagModel.WideRow); loaded.Table.ShouldBe("Readings"); loaded.ColumnName.ShouldBe("oven_temp"); loaded.RowSelector.TopByTimestamp.ShouldBe("recorded"); var resaved = loaded.ToJson(); SqlEquipmentTagParser.TryParse(resaved, "p", out var def).ShouldBeTrue(); def.RowSelectorTopByTimestamp.ShouldBe("recorded"); // …and the operator's own edit — swapping the browse default for a where-pair — is equally readable. loaded.RowSelector.TopByTimestamp = null; loaded.RowSelector.WhereColumn = "machine_id"; loaded.RowSelector.WhereValue = "7"; var edited = loaded.ToJson(); SqlTagConfigModel.FromJson(edited).Validate().ShouldBeNull(); SqlEquipmentTagParser.TryParse(edited, "p", out var editedDef).ShouldBeTrue(); editedDef.RowSelectorColumn.ShouldBe("machine_id"); editedDef.RowSelectorValue.ShouldBe("7"); editedDef.RowSelectorTopByTimestamp.ShouldBeNull(); } /// /// A non-default schema qualifies the table, and the selector column is a column of THAT table — the /// two facts travel together from one session call, so neither can be attributed to the wrong table. /// [Fact] public void BuildTagConfig_Sql_qualifies_a_non_default_schema_and_keeps_its_own_selector() { var json = RawBrowseCommitMapper.BuildTagConfig( DriverTypeNames.Sql, "level", SqlFields(schema: "sales", table: "Readings", column: "level", timestampColumn: "sold_at")); SqlEquipmentTagParser.TryParse(json, "p", out var def).ShouldBeTrue(); def.Table.ShouldBe("sales.Readings"); def.RowSelectorTopByTimestamp.ShouldBe("sold_at"); } /// /// No stated candidate ⇒ NO invented selector. The blob keeps the wide-row shape (so it opens in the /// typed editor with the table and column already filled in) and is refused by the gate — the commit /// itself never gets this far, because DescribeUncommittableLeaf refuses the leaf first. /// [Fact] public void BuildTagConfig_Sql_invents_no_selector_when_the_session_stated_no_candidate() { var json = RawBrowseCommitMapper.BuildTagConfig( DriverTypeNames.Sql, "setpoint", SqlFields(table: "Settings", column: "setpoint", timestampColumn: null)); var o = JsonNode.Parse(json)!.AsObject(); o["table"]!.GetValue().ShouldBe("Settings"); o["columnName"]!.GetValue().ShouldBe("setpoint"); o.ContainsKey("rowSelector").ShouldBeFalse("an empty or fabricated selector is worse than none"); TagConfigValidator.Validate(DriverTypeNames.Sql, json).ShouldNotBeNullOrEmpty(); SqlEquipmentTagParser.TryParse(json, "p", out _).ShouldBeFalse(); } /// A blank stated candidate is treated as none, not written through as a blank selector. [Fact] public void BuildTagConfig_Sql_treats_a_blank_stated_candidate_as_none() { var json = RawBrowseCommitMapper.BuildTagConfig( DriverTypeNames.Sql, "setpoint", SqlFields(timestampColumn: " ")); JsonNode.Parse(json)!.AsObject().ContainsKey("rowSelector").ShouldBeFalse(); } [Fact] public void DescribeUncommittableLeaf_refuses_a_sql_leaf_whose_table_has_no_date_time_column() { var error = RawBrowseCommitMapper.DescribeUncommittableLeaf( DriverTypeNames.Sql, "setpoint", SqlFields(table: "Settings", timestampColumn: null)); error.ShouldNotBeNull(); error!.ShouldContain("setpoint"); error.ShouldContain("Settings"); // names the table, not just the leaf error.ShouldContain("whereColumn"); // and names the fix } [Fact] public void DescribeUncommittableLeaf_refuses_a_sql_leaf_whose_address_was_never_stated() { var error = RawBrowseCommitMapper.DescribeUncommittableLeaf( DriverTypeNames.Sql, "oven_temp", addressFields: null); error.ShouldNotBeNull(); error!.ShouldContain("oven_temp"); } /// The acceptance control — a stated candidate makes the leaf committable. [Fact] public void DescribeUncommittableLeaf_accepts_a_sql_leaf_carrying_a_stated_candidate() => RawBrowseCommitMapper .DescribeUncommittableLeaf(DriverTypeNames.Sql, "oven_temp", SqlFields()) .ShouldBeNull(); /// /// The two refusals are one seam and must stay in agreement: a leaf the mapper can build a /// gate-passing blob for must be committable, and one it cannot must be refused. Getting this /// backwards either blocks a working commit or lets an unreadable tag through. /// [Theory] [InlineData("recorded", false)] [InlineData(null, true)] public void Sql_refusal_and_the_emitted_blob_agree(string? timestampColumn, bool expectRefused) { var fields = SqlFields(timestampColumn: timestampColumn); var refused = RawBrowseCommitMapper .DescribeUncommittableLeaf(DriverTypeNames.Sql, "oven_temp", fields) is not null; var blobRejected = TagConfigValidator.Validate( DriverTypeNames.Sql, RawBrowseCommitMapper.BuildTagConfig(DriverTypeNames.Sql, "oven_temp", fields)) is not null; refused.ShouldBe(expectRefused); blobRejected.ShouldBe(expectRefused); } }