8d9155682d
Two gaps the Task 23/24 review found, both blocking Task 26's live gate.
Gap 1 — browse-commit produced a silently dead MQTT tag. RawBrowseCommitMapper
had no `Mqtt` case, so a committed leaf fell through to the generic
`{"address": …}` key. Neither MqttTagDefinitionFactory entry point reads
`address`: the tag deployed clean and reported BadNodeIdUnknown forever, with
no signal at commit time. Predates Task 23 (Plain was affected too), but Task 23
built the Sparkplug metric tree precisely so an operator could browse and commit
a binding.
The address is a DESCRIPTOR, not a reference string, and it cannot be recovered
from the browse node id: `{group}/{node}[/{device}]::{metric}` where a metric
name legitimately contains `/` (`Node Control/Rebirth`) — the ambiguity
MetricSeparator's remarks already name. So the session STATES it, via a new
`AttributeInfo.AddressFields` seam, and the mapper reads it. Which keys are
emitted is also how the mapper learns Plain vs Sparkplug — the driver type
reaching it is just `Mqtt`, and the mode lives on the driver config. Key names
are single-sourced in the new `MqttTagConfigKeys` (producer, mapper, factory),
with the literals pinned by a test so a symmetric rename cannot silently unbind
already-persisted blobs. A leaf with no stated address is refused at commit in
words rather than committed dead.
Gap 2 — RequestRebirthAsync had no UI. Task 23 shipped it backend-only; Task 26
step 1 assumes the button, and Task 25's runbook notes the picker tree stays
empty until a birth lands, so it is the only way to fill it on demand. Added to
the /raw browse modal: scope = the clicked tree node (device/metric resolve up to
their edge node, group fans out and is refused whole past 32), an explicit
two-click confirm naming the resolved scope and its consequence, the outcome or
the refusal shown rather than swallowed. Offered only for a Sparkplug session
(new `IRebirthCapableBrowseSession.RebirthAvailable` — one session class serves
both modes, so a type test alone would draw a button that can only throw) and
only to a DriverOperator; the server-side gate remains the boundary.
Tests: MQTT 545 → 581, AdminUI 781 → 800. The round-trip tests feed the emitted
blob to the REAL factory and were falsified by mutating the emitted key name.
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
401 lines
17 KiB
C#
401 lines
17 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.Configuration.Enums;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
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: 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");
|
|
}
|
|
}
|