Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/RawBrowseCommitMapperTests.cs
T
Joseph Doherty 7498d39e5b fix(sql): browse-commit states a topByTimestamp row selector (#552)
`RawBrowseCommitMapper.BuildSqlTagConfig` emitted a WideRow blob carrying only
table + columnName. A wide-row tag must also say WHICH row it reads, so both
`SqlEquipmentTagParser` (the runtime authority) and `SqlTagConfigModel.Validate`
(the authoring gate) refused it — a browse-committed Sql tag was never readable,
first silently as BadNodeIdUnknown and, since #551 put the gate on the import,
loudly at commit.

`SqlBrowseSession` now states a fourth address field: the table's preferred
date/time-typed column, computed from the columns its attributes call ALREADY
read (no extra catalog round-trip). Candidacy is decided by
`ISqlDialect.MapColumnType` reporting `DriverDataType.DateTime` — never by column
name, which would order a wide-row read by a string column that merely looks like
a timestamp. Multiple candidates tie-break on FIRST IN CATALOG ORDER (every
dialect's column SQL orders by ordinal position), so the pick is stable across
calls and servers; it is a visible, editable default the typed editor round-trips.

No candidate (a table with no date/time column) is an operator-actionable refusal,
not an invented selector: the only remaining selector is a whereColumn/whereValue
pair whose value is data, not schema, so `DescribeUncommittableLeaf` refuses that
leaf by name, names the table, and points at the tag editor.

The session and the mapper now agree via `SqlBrowseAddressFields` constants rather
than two independently spelled string literals — a disagreement there is silent,
and is exactly the shape this defect had.

`RawTagConfigGateTests`' Sql leg flips from "records the gap" to "browse-commit
output passes the gate", with the no-candidate case pinned as its own test.
2026-08-10 01:20:12 -04:00

886 lines
40 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();
}
// ---- 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);
}
}