Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/RawBrowseCommitMapperTests.cs
T
Joseph Doherty 64be41d80c fix(adminui): forward the browsed MTConnect leaf's inferred type into the committed TagConfig (#560)
RawBrowseCommitMapper.MapLeaf typed a browse-committed raw tag's OPC UA node
from the leaf's probe-inferred DriverDataType, but its BuildTagConfig MTConnect
branch emitted `new MTConnectTagConfigModel { FullName = address }` — and that
model's DataType defaults to String with ToJson() unconditionally writing
`dataType`. A non-blank authored type wins the driver's coercion precedence, so
the Agent-probe inference step was never consulted and every browsed SAMPLE
published text into a Double/Int64 node. Silent: the blob is perfectly valid, so
neither the authoring gate nor the deploy gate had anything to say.

Same defect class as #536, which is why the AbCip / TwinCAT / FOCAS branches
forward the browsed native type via NativeType(addressFields, …). MapLeaf now
passes driverDataType into BuildTagConfig, whose MTConnect branch uses it as the
NativeType fallback — a stated native `dataType` address field still wins, so
the branch reads like its siblings; the difference is only that MTConnect's
fallback is the leaf's own inference rather than a fixed model default. Its
coercion type IS a DriverDataType, the very enum the leaf carries, so nothing is
re-derived. NativeType's numeric-string and unparseable-name guards are shared
via the extracted ParseNativeType, so an unusable name still keeps String.

Tests assert the PAIR (row OPC UA type and blob coercion type agree with the
inference) — the blob alone would pass against a mapper that stopped typing the
row — plus the parity control that a leaf inferred (or reported) as String still
commits `"dataType":"String"`, so the forward cannot be implemented as a
deletion. Observed red first: expected "Float64" but was "String".
2026-08-10 07:35:12 -04:00

989 lines
45 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.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;
/// <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>
/// Gitea #540 — the commit path, judged by whether what it produces is authorable rather
/// than by which key it lands under.
/// <para>
/// The mapper is not where that defect lived: it faithfully wrote the reference the FOCAS
/// browser handed it, which was <c>{deviceHost}/{path}</c> 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 <c>o["address"] == reference</c> passed happily throughout.
/// </para>
/// </summary>
/// <param name="reference">A leaf reference of the kind the FOCAS browser emits.</param>
[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");
}
/// <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();
}
// ---- MTConnect: the commit must not retype the node it just typed (Gitea #560) ----------------
/// <summary>
/// A browse-committed MTConnect tag's OPC UA node type and its <c>TagConfig</c> coercion type
/// must BOTH come from the leaf's probe-inferred <see cref="DriverDataType"/> — the same defect
/// class as #536 arriving through a different door.
/// <para>
/// 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:
/// <c>MapLeaf</c> typed the node <c>Double</c> from the inference while <c>BuildTagConfig</c>
/// stamped the model's <c>String</c> 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.
/// </para>
/// </summary>
/// <param name="inferred">The <see cref="DriverDataType"/> the Agent's <c>/probe</c> inference reported.</param>
/// <param name="expectedNodeType">The OPC UA built-in name the raw tag row must carry.</param>
[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<string>().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<DriverDataType>(inferred));
model.Validate().ShouldBeNull();
}
/// <summary>
/// The inferred type is forwarded through <c>BuildTagConfig</c> itself, not patched on by the
/// caller — the same seam AB CIP / TwinCAT / FOCAS forward their browsed type through.
/// </summary>
[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<string>().ShouldBe("dev1_pos");
o["dataType"]!.GetValue<string>().ShouldBe("Float64");
}
/// <summary>
/// The parity control for the theory above: a leaf the browser reported NO type for keeps the
/// model's own <c>String</c> default — the pre-fix behaviour, and the right answer for a
/// weakly-typed Agent. A "fix" that emitted a blank or absent <c>dataType</c> would satisfy
/// "not String by default" while leaving the blob unauthorable, so this is what stops the
/// forward being implemented as a deletion.
/// </summary>
[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<string>().ShouldBe("String");
MTConnectTagConfigModel.FromJson(row.Tag.TagConfig).Validate().ShouldBeNull();
}
/// <summary>
/// A type name <see cref="DriverDataType"/> 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: <c>Enum.TryParse</c> accepts <c>"8"</c> for any enum and hands back
/// whatever member sits at that ordinal.
/// </summary>
/// <param name="stated">The unusable type name the browse leaf stated.</param>
[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<string>().ShouldBe("String");
}
// ---- 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();
// ---- BuildTagConfig: Sql (the address is a TUPLE, and the third part is WHICH ROW) — Gitea #552 --
/// <summary>The address a <c>SqlBrowseSession</c> states for a column of a table that has a timestamp.</summary>
/// <param name="schema">The owning schema.</param>
/// <param name="table">The owning table.</param>
/// <param name="column">The browsed column.</param>
/// <param name="timestampColumn">The table's preferred date/time column, or null to state none.</param>
/// <returns>The stated address fields.</returns>
private static Dictionary<string, string> SqlFields(
string schema = "dbo",
string table = "Readings",
string column = "oven_temp",
string? timestampColumn = "recorded")
{
var fields = new Dictionary<string, string>(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<string>().ShouldBe(nameof(SqlTagModel.WideRow));
o["table"]!.GetValue<string>().ShouldBe("Readings");
o["columnName"]!.GetValue<string>().ShouldBe("oven_temp");
o["rowSelector"]!.AsObject()["topByTimestamp"]!.GetValue<string>().ShouldBe("recorded");
o.ContainsKey("address").ShouldBeFalse(); // never the generic fallback key
}
/// <summary>
/// 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
/// <see cref="SqlEquipmentTagParser"/> (what the deployed driver runs). Before #552 the emitted blob
/// failed both — so a browse-committed Sql tag was never readable.
/// </summary>
[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();
}
/// <summary>
/// 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.
/// </summary>
[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();
}
/// <summary>
/// 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.
/// </summary>
[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();
}
/// <summary>
/// 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.
/// </summary>
[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");
}
/// <summary>
/// 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 <c>DescribeUncommittableLeaf</c> refuses the leaf first.
/// </summary>
[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<string>().ShouldBe("Settings");
o["columnName"]!.GetValue<string>().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();
}
/// <summary>A blank stated candidate is treated as none, not written through as a blank selector.</summary>
[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");
}
/// <summary>The acceptance control — a stated candidate makes the leaf committable.</summary>
[Fact]
public void DescribeUncommittableLeaf_accepts_a_sql_leaf_carrying_a_stated_candidate()
=> RawBrowseCommitMapper
.DescribeUncommittableLeaf(DriverTypeNames.Sql, "oven_temp", SqlFields())
.ShouldBeNull();
/// <summary>
/// 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.
/// </summary>
[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);
}
}