6b3da93151
A browse-committed AB CIP / TwinCAT / FOCAS tag bound with its model's DEFAULT
data type, not the one the device reported. A browsed REAL committed as
`{"tagPath":"Pump_1_Speed","dataType":"DInt"}` — and those three drivers decode
the wire bytes by that field, not by the raw row's OPC UA DataType — so the tag
read four bytes of a float through an integer decoder and published a plausible
wrong number with no error raised anywhere. FOCAS was missed when #536 was filed.
The type is CARRIED, not derived. Reverse-mapping the leaf's driver-agnostic
DriverDataType looks like the smaller fix and is wrong: SInt/Int/DInt/USInt/UInt
all collapse into Int32 on the way out of the driver, so that version would bind
a 16-bit INT as a 32-bit DINT while appearing to fix the REAL. Drivers now state
their own type name on DriverAttributeInfo.NativeDataType, which reaches the
picker as AttributeInfo.AddressFields["dataType"] and the commit mapper from
there. A leaf that states nothing keeps the model default — the pre-#536
behaviour, kept deliberately as the fallback rather than a guess.
Numeric strings are refused before parsing: Enum.TryParse accepts "9" for any
enum and Enum.IsDefined then confirms it, so an unguarded parse would bind a tag
to a type nobody named. A test caught this in the first version of the guard.
--- and the other half, which the fixture was hiding ---
Test Connect on a device with NO authored tags — every AB CIP device at the
moment an operator first presses the button — reported "CIP handshake failed"
against a healthy controller. That was written down as a fixture limitation
("ab_server doesn't implement @raw_cpu_type"). Both halves of that were wrong:
* @raw_cpu_type is not a libplctag special tag at all (only @raw, @tags and
@udt/ are), so it goes out as an ordinary symbolic name that no controller
defines, and the controller's "tag does not exist" IS the successful answer.
* ab_server answered an unresolvable tag path with CIP 0x03 where a Logix
controller answers 0x04 — ErrorBadParam vs ErrorNotFound, which the probe
classifies oppositely. Fixed in vendored patch 0002.
* With the fixture then telling the truth, the real defect surfaced:
AbCipDriverProbe.IsReachableException matched the native C status spellings
("STATUS_NOT_FOUND"), but libplctag.NET throws with the MANAGED enum name
("ErrorNotFound"). Those substrings can never match, so the reachable
classification could not fire on the exception path — which is the path every
no-authored-tags probe takes. This was failing on real hardware too, not just
against the sim. It now recovers the Status and defers to IsReachableStatus,
so there is one classification rather than two that can drift.
Rejected: switching the fallback to @tags. libplctag builds a tag-listing tag
with NO network I/O, so it reports OK against any TCP listener — measured green
against a Mosquitto broker.
Verification: full solution suite green. Falsification runs confirm exactly the
intended tests go red — 9 for the type carry, 3 (the reachable statuses only) for
the probe classification. Live on docker-dev: browse-committed TestString/
Motor_Run/TestSINT now store String/Bool/SInt, alongside last session's
Pump_1_Speed still holding the wrong DInt. TestSINT is the falsifier — its row
DataType is Int32, identical to TestDINT's, so no reverse map could have produced
"SInt".
Known consequence: pre-existing browse-committed tags keep their wrong dataType;
the fix applies to new commits only.
Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo
647 lines
29 KiB
C#
647 lines
29 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.AbCip;
|
|
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: the browsed data type (Gitea #536) --------------------------------------
|
|
|
|
/// <summary>The address fields a captured browse leaf states for its driver-native type.</summary>
|
|
/// <param name="nativeType">The driver's own data-type enum name, as the driver stamped it.</param>
|
|
/// <returns>The stated address fields.</returns>
|
|
private static Dictionary<string, string> NativeTypeFields(string nativeType) =>
|
|
new() { ["dataType"] = nativeType };
|
|
|
|
/// <summary>
|
|
/// The committed <c>dataType</c> 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
|
|
/// <c>DInt</c> default reads four bytes of a float as an integer and publishes a plausible
|
|
/// wrong number with no error anywhere.
|
|
/// </summary>
|
|
/// <param name="driverType">The owning device's driver type.</param>
|
|
/// <param name="nativeType">The native type the browse leaf stated.</param>
|
|
/// <param name="reference">The leaf's driver-side reference.</param>
|
|
[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<string>().ShouldBe(nativeType);
|
|
}
|
|
|
|
/// <summary>
|
|
/// The narrow integer widths are the reason the native type is CARRIED rather than derived from
|
|
/// the leaf's driver-agnostic <c>DriverDataType</c>: <c>SInt</c>, <c>Int</c> and <c>DInt</c> all
|
|
/// leave the driver as <c>Int32</c>, 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.
|
|
/// </summary>
|
|
/// <param name="nativeType">The Logix integer width the controller reported.</param>
|
|
[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<string>().ShouldBe(nativeType);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
/// <param name="driverType">The owning device's driver type.</param>
|
|
/// <param name="expectedDefault">The model default the commit must fall back to.</param>
|
|
[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<string>().ShouldBe(expectedDefault);
|
|
JsonNode.Parse(unparseable)!.AsObject()["dataType"]!.GetValue<string>().ShouldBe(expectedDefault);
|
|
}
|
|
|
|
/// <summary>
|
|
/// A numeric string is NOT a type name. <c>Enum.TryParse</c> 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 <c>MTConnectTagConfigModel</c> documents for its own dataType.
|
|
/// </summary>
|
|
[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<string>().ShouldBe("DInt");
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[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<string>().ShouldBe("Program:Main.Motor.Speed");
|
|
o["dataType"]!.GetValue<string>().ShouldBe("Real");
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
/// <param name="driverType">The owning device's driver type.</param>
|
|
[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) --------
|
|
|
|
/// <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();
|
|
}
|