The pre-refactor design minted OPC UA NodeIds directly from the driver's
FullReference (the native-address string). That had three long-term
problems:
1. OPC UA Part 3 §5.2.2 requires NodeIds to be immutable across a node's
lifetime. A rename of the underlying device address — Galaxy attribute,
S7 tag, Modbus register alias — changed the NodeId and broke every
client that had pinned the previous identifier.
2. Two drivers with coincidentally-matching native addresses (e.g. `temp`
in Modbus and `temp` in S7 under different Equipment rows) collided on
the NodeId identifier.
3. TagConfig was being placed verbatim on the wire; for drivers whose
TagConfig is JSON (every driver shipped today, per the
CK_Tag_TagConfig_IsJson check constraint), clients saw the raw JSON
blob as the NodeId string.
Refactor:
* DriverNodeManager.Variable now mints a stable path-based NodeId
`{driverId}/{folder-path}/{browseName}` and records the driver-side
FullReference in a new _fullRefByNodeId map. OnReadValue / OnWriteValue
/ ResolveFullRef look the FullReference up via that map instead of
casting NodeId.Identifier. The old cast path is preserved as a
fallback so any test fixture that still registers variables with
FullRef-shaped NodeIds keeps working.
* EquipmentNodeWalker.AddTagVariable now extracts the cross-driver
`FullName` field from Tag.TagConfig before handing the address to
DriverAttributeInfo. Every shipped driver stores the wire reference in
TagConfig[FullName]; falling back to the raw string covers any future
driver that wants an opaque non-JSON address. ExtractFullName is
exposed internal for unit coverage.
* scripts/e2e/test-galaxy.ps1 defaults updated to the new path-based
NodeIds. Verified live against p7-smoke-galaxy on the dev box:
`ns=2;s=p7-smoke-galaxy/lab-floor/galaxy-line/reactor-1/Source` reads
return Status=0x00000000 with a real Galaxy byte-array value.
Test suite: 195/195 Core.Tests + 283/283 Server.Tests green. Five new
ExtractFullName / FullName-passthrough tests added.
Task #112 GA-3 — golden-path read verified end-to-end; remaining E2E
script stages still blocked on pre-existing issues (ScriptedAlarm
predicate NRE on empty upstream cache, PowerShell $changeLines.Count
guard), tracked separately.
Task #134 — complete.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
385 lines
15 KiB
C#
385 lines
15 KiB
C#
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
using ZB.MOM.WW.OtOpcUa.Core.OpcUa;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Core.Tests.OpcUa;
|
|
|
|
[Trait("Category", "Unit")]
|
|
public sealed class EquipmentNodeWalkerTests
|
|
{
|
|
[Fact]
|
|
public void Walk_EmptyContent_EmitsNothing()
|
|
{
|
|
var rec = new RecordingBuilder("root");
|
|
EquipmentNodeWalker.Walk(rec, new EquipmentNamespaceContent([], [], [], []));
|
|
|
|
rec.Children.ShouldBeEmpty();
|
|
}
|
|
|
|
[Fact]
|
|
public void Walk_EmitsArea_Line_Equipment_Folders_In_UnsOrder()
|
|
{
|
|
var content = new EquipmentNamespaceContent(
|
|
Areas: [Area("area-1", "warsaw"), Area("area-2", "berlin")],
|
|
Lines: [Line("line-1", "area-1", "oven-line"), Line("line-2", "area-2", "press-line")],
|
|
Equipment: [Eq("eq-1", "line-1", "oven-3"), Eq("eq-2", "line-2", "press-7")],
|
|
Tags: []);
|
|
|
|
var rec = new RecordingBuilder("root");
|
|
EquipmentNodeWalker.Walk(rec, content);
|
|
|
|
rec.Children.Select(c => c.BrowseName).ShouldBe(["berlin", "warsaw"]); // ordered by Name
|
|
var warsaw = rec.Children.First(c => c.BrowseName == "warsaw");
|
|
warsaw.Children.Select(c => c.BrowseName).ShouldBe(["oven-line"]);
|
|
warsaw.Children[0].Children.Select(c => c.BrowseName).ShouldBe(["oven-3"]);
|
|
}
|
|
|
|
[Fact]
|
|
public void Walk_AddsFiveIdentifierProperties_OnEquipmentNode_Skipping_NullZTagSapid()
|
|
{
|
|
var uuid = Guid.NewGuid();
|
|
var eq = Eq("eq-1", "line-1", "oven-3");
|
|
eq.EquipmentUuid = uuid;
|
|
eq.MachineCode = "MC-42";
|
|
eq.ZTag = null;
|
|
eq.SAPID = null;
|
|
var content = new EquipmentNamespaceContent(
|
|
[Area("area-1", "warsaw")], [Line("line-1", "area-1", "line-a")], [eq], []);
|
|
|
|
var rec = new RecordingBuilder("root");
|
|
EquipmentNodeWalker.Walk(rec, content);
|
|
|
|
var equipmentNode = rec.Children[0].Children[0].Children[0];
|
|
var props = equipmentNode.Properties.Select(p => p.BrowseName).ToList();
|
|
props.ShouldContain("EquipmentId");
|
|
props.ShouldContain("EquipmentUuid");
|
|
props.ShouldContain("MachineCode");
|
|
props.ShouldNotContain("ZTag");
|
|
props.ShouldNotContain("SAPID");
|
|
equipmentNode.Properties.First(p => p.BrowseName == "EquipmentUuid").Value.ShouldBe(uuid.ToString());
|
|
}
|
|
|
|
[Fact]
|
|
public void Walk_Adds_ZTag_And_SAPID_When_Present()
|
|
{
|
|
var eq = Eq("eq-1", "line-1", "oven-3");
|
|
eq.ZTag = "ZT-0042";
|
|
eq.SAPID = "10000042";
|
|
var content = new EquipmentNamespaceContent(
|
|
[Area("area-1", "warsaw")], [Line("line-1", "area-1", "line-a")], [eq], []);
|
|
|
|
var rec = new RecordingBuilder("root");
|
|
EquipmentNodeWalker.Walk(rec, content);
|
|
|
|
var equipmentNode = rec.Children[0].Children[0].Children[0];
|
|
equipmentNode.Properties.First(p => p.BrowseName == "ZTag").Value.ShouldBe("ZT-0042");
|
|
equipmentNode.Properties.First(p => p.BrowseName == "SAPID").Value.ShouldBe("10000042");
|
|
}
|
|
|
|
[Fact]
|
|
public void Walk_Materializes_Identification_Subfolder_When_AnyFieldPresent()
|
|
{
|
|
var eq = Eq("eq-1", "line-1", "oven-3");
|
|
eq.Manufacturer = "Trumpf";
|
|
eq.Model = "TruLaser-3030";
|
|
var content = new EquipmentNamespaceContent(
|
|
[Area("area-1", "warsaw")], [Line("line-1", "area-1", "line-a")], [eq], []);
|
|
|
|
var rec = new RecordingBuilder("root");
|
|
EquipmentNodeWalker.Walk(rec, content);
|
|
|
|
var equipmentNode = rec.Children[0].Children[0].Children[0];
|
|
var identification = equipmentNode.Children.FirstOrDefault(c => c.BrowseName == "Identification");
|
|
identification.ShouldNotBeNull();
|
|
identification!.Properties.Select(p => p.BrowseName).ShouldContain("Manufacturer");
|
|
identification.Properties.Select(p => p.BrowseName).ShouldContain("Model");
|
|
}
|
|
|
|
[Fact]
|
|
public void Walk_Omits_Identification_Subfolder_When_AllFieldsNull()
|
|
{
|
|
var eq = Eq("eq-1", "line-1", "oven-3"); // no identification fields
|
|
var content = new EquipmentNamespaceContent(
|
|
[Area("area-1", "warsaw")], [Line("line-1", "area-1", "line-a")], [eq], []);
|
|
|
|
var rec = new RecordingBuilder("root");
|
|
EquipmentNodeWalker.Walk(rec, content);
|
|
|
|
var equipmentNode = rec.Children[0].Children[0].Children[0];
|
|
equipmentNode.Children.ShouldNotContain(c => c.BrowseName == "Identification");
|
|
}
|
|
|
|
[Fact]
|
|
public void Walk_Emits_Variable_Per_BoundTag_Under_Equipment()
|
|
{
|
|
var eq = Eq("eq-1", "line-1", "oven-3");
|
|
var tag1 = NewTag("tag-1", "Temperature", "Int32", "plcaddr-01", equipmentId: "eq-1");
|
|
var tag2 = NewTag("tag-2", "Setpoint", "Float32", "plcaddr-02", equipmentId: "eq-1");
|
|
var unboundTag = NewTag("tag-3", "Orphan", "Int32", "plcaddr-03", equipmentId: null); // SystemPlatform-style, walker skips
|
|
var content = new EquipmentNamespaceContent(
|
|
[Area("area-1", "warsaw")], [Line("line-1", "area-1", "line-a")],
|
|
[eq], [tag1, tag2, unboundTag]);
|
|
|
|
var rec = new RecordingBuilder("root");
|
|
EquipmentNodeWalker.Walk(rec, content);
|
|
|
|
var equipmentNode = rec.Children[0].Children[0].Children[0];
|
|
equipmentNode.Variables.Count.ShouldBe(2);
|
|
equipmentNode.Variables.Select(v => v.BrowseName).ShouldBe(["Setpoint", "Temperature"]);
|
|
equipmentNode.Variables.First(v => v.BrowseName == "Temperature").AttributeInfo.FullName.ShouldBe("plcaddr-01");
|
|
equipmentNode.Variables.First(v => v.BrowseName == "Setpoint").AttributeInfo.DriverDataType.ShouldBe(DriverDataType.Float32);
|
|
}
|
|
|
|
[Fact]
|
|
public void Walk_FallsBack_To_String_For_Unparseable_DataType()
|
|
{
|
|
var eq = Eq("eq-1", "line-1", "oven-3");
|
|
var tag = NewTag("tag-1", "Mystery", "NotARealType", "plcaddr-42", equipmentId: "eq-1");
|
|
var content = new EquipmentNamespaceContent(
|
|
[Area("area-1", "warsaw")], [Line("line-1", "area-1", "line-a")], [eq], [tag]);
|
|
|
|
var rec = new RecordingBuilder("root");
|
|
EquipmentNodeWalker.Walk(rec, content);
|
|
|
|
var variable = rec.Children[0].Children[0].Children[0].Variables.Single();
|
|
variable.AttributeInfo.DriverDataType.ShouldBe(DriverDataType.String);
|
|
}
|
|
|
|
[Fact]
|
|
public void Walk_Emits_VirtualTag_Variables_With_Virtual_Source_Discriminator()
|
|
{
|
|
var eq = Eq("eq-1", "line-1", "oven-3");
|
|
var vtag = new VirtualTag
|
|
{
|
|
VirtualTagRowId = Guid.NewGuid(), GenerationId = 1,
|
|
VirtualTagId = "vt-1", EquipmentId = "eq-1", Name = "LineRate",
|
|
DataType = "Float32", ScriptId = "scr-1", Historize = true,
|
|
};
|
|
var content = new EquipmentNamespaceContent(
|
|
[Area("area-1", "warsaw")], [Line("line-1", "area-1", "line-a")],
|
|
[eq], [], VirtualTags: [vtag]);
|
|
|
|
var rec = new RecordingBuilder("root");
|
|
EquipmentNodeWalker.Walk(rec, content);
|
|
|
|
var equipmentNode = rec.Children[0].Children[0].Children[0];
|
|
var v = equipmentNode.Variables.Single(x => x.BrowseName == "LineRate");
|
|
v.AttributeInfo.Source.ShouldBe(NodeSourceKind.Virtual);
|
|
v.AttributeInfo.VirtualTagId.ShouldBe("vt-1");
|
|
v.AttributeInfo.ScriptedAlarmId.ShouldBeNull();
|
|
v.AttributeInfo.IsHistorized.ShouldBeTrue();
|
|
v.AttributeInfo.DriverDataType.ShouldBe(DriverDataType.Float32);
|
|
}
|
|
|
|
[Fact]
|
|
public void Walk_Emits_ScriptedAlarm_Variables_With_ScriptedAlarm_Source_And_IsAlarm()
|
|
{
|
|
var eq = Eq("eq-1", "line-1", "oven-3");
|
|
var alarm = new ScriptedAlarm
|
|
{
|
|
ScriptedAlarmRowId = Guid.NewGuid(), GenerationId = 1,
|
|
ScriptedAlarmId = "al-1", EquipmentId = "eq-1", Name = "HighTemp",
|
|
AlarmType = "LimitAlarm", MessageTemplate = "{Temp} exceeded",
|
|
PredicateScriptId = "scr-9", Severity = 800,
|
|
};
|
|
var content = new EquipmentNamespaceContent(
|
|
[Area("area-1", "warsaw")], [Line("line-1", "area-1", "line-a")],
|
|
[eq], [], ScriptedAlarms: [alarm]);
|
|
|
|
var rec = new RecordingBuilder("root");
|
|
EquipmentNodeWalker.Walk(rec, content);
|
|
|
|
var v = rec.Children[0].Children[0].Children[0].Variables.Single(x => x.BrowseName == "HighTemp");
|
|
v.AttributeInfo.Source.ShouldBe(NodeSourceKind.ScriptedAlarm);
|
|
v.AttributeInfo.ScriptedAlarmId.ShouldBe("al-1");
|
|
v.AttributeInfo.VirtualTagId.ShouldBeNull();
|
|
v.AttributeInfo.IsAlarm.ShouldBeTrue();
|
|
v.AttributeInfo.DriverDataType.ShouldBe(DriverDataType.Boolean);
|
|
}
|
|
|
|
[Fact]
|
|
public void Walk_Skips_Disabled_VirtualTags_And_Alarms()
|
|
{
|
|
var eq = Eq("eq-1", "line-1", "oven-3");
|
|
var vtag = new VirtualTag
|
|
{
|
|
VirtualTagRowId = Guid.NewGuid(), GenerationId = 1,
|
|
VirtualTagId = "vt-1", EquipmentId = "eq-1", Name = "Disabled",
|
|
DataType = "Float32", ScriptId = "scr-1", Enabled = false,
|
|
};
|
|
var alarm = new ScriptedAlarm
|
|
{
|
|
ScriptedAlarmRowId = Guid.NewGuid(), GenerationId = 1,
|
|
ScriptedAlarmId = "al-1", EquipmentId = "eq-1", Name = "DisabledAlarm",
|
|
AlarmType = "LimitAlarm", MessageTemplate = "x",
|
|
PredicateScriptId = "scr-9", Enabled = false,
|
|
};
|
|
var content = new EquipmentNamespaceContent(
|
|
[Area("area-1", "warsaw")], [Line("line-1", "area-1", "line-a")],
|
|
[eq], [], VirtualTags: [vtag], ScriptedAlarms: [alarm]);
|
|
|
|
var rec = new RecordingBuilder("root");
|
|
EquipmentNodeWalker.Walk(rec, content);
|
|
|
|
rec.Children[0].Children[0].Children[0].Variables.ShouldBeEmpty();
|
|
}
|
|
|
|
[Fact]
|
|
public void Walk_Null_VirtualTags_And_ScriptedAlarms_Is_Safe()
|
|
{
|
|
// Backwards-compat — callers that don't populate the new collections still work.
|
|
var eq = Eq("eq-1", "line-1", "oven-3");
|
|
var content = new EquipmentNamespaceContent(
|
|
[Area("area-1", "warsaw")], [Line("line-1", "area-1", "line-a")], [eq], []);
|
|
|
|
var rec = new RecordingBuilder("root");
|
|
EquipmentNodeWalker.Walk(rec, content); // must not throw
|
|
|
|
rec.Children[0].Children[0].Children[0].Variables.ShouldBeEmpty();
|
|
}
|
|
|
|
[Fact]
|
|
public void Driver_tag_default_NodeSourceKind_is_Driver()
|
|
{
|
|
var eq = Eq("eq-1", "line-1", "oven-3");
|
|
var tag = NewTag("t-1", "Temp", "Int32", "plc-01", "eq-1");
|
|
var content = new EquipmentNamespaceContent(
|
|
[Area("area-1", "warsaw")], [Line("line-1", "area-1", "line-a")],
|
|
[eq], [tag]);
|
|
|
|
var rec = new RecordingBuilder("root");
|
|
EquipmentNodeWalker.Walk(rec, content);
|
|
|
|
var v = rec.Children[0].Children[0].Children[0].Variables.Single();
|
|
v.AttributeInfo.Source.ShouldBe(NodeSourceKind.Driver);
|
|
v.AttributeInfo.VirtualTagId.ShouldBeNull();
|
|
v.AttributeInfo.ScriptedAlarmId.ShouldBeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public void ExtractFullName_unwraps_json_object_with_FullName_field()
|
|
{
|
|
EquipmentNodeWalker.ExtractFullName(
|
|
"{\"FullName\":\"MESReceiver_001.MoveInBatchID\",\"DataType\":\"Int32\"}")
|
|
.ShouldBe("MESReceiver_001.MoveInBatchID");
|
|
}
|
|
|
|
[Fact]
|
|
public void ExtractFullName_handles_S7_style_extra_fields()
|
|
{
|
|
EquipmentNodeWalker.ExtractFullName(
|
|
"{\"FullName\":\"DB1_DBW0\",\"Address\":\"DB1.DBW0\",\"DataType\":\"Int16\"}")
|
|
.ShouldBe("DB1_DBW0");
|
|
}
|
|
|
|
[Fact]
|
|
public void ExtractFullName_returns_raw_when_not_json()
|
|
{
|
|
// Drivers that opt out of JSON TagConfig still work — fallback preserves the literal
|
|
// string so the driver's IReadable sees whatever the row author stored.
|
|
EquipmentNodeWalker.ExtractFullName("raw-tag-ref").ShouldBe("raw-tag-ref");
|
|
}
|
|
|
|
[Fact]
|
|
public void ExtractFullName_returns_raw_when_json_missing_FullName_field()
|
|
{
|
|
EquipmentNodeWalker.ExtractFullName("{\"Address\":\"DB1.DBW0\"}")
|
|
.ShouldBe("{\"Address\":\"DB1.DBW0\"}");
|
|
}
|
|
|
|
[Fact]
|
|
public void Driver_tag_FullName_passes_through_from_TagConfig_json()
|
|
{
|
|
// The walker hands the driver the unwrapped FullName string so IReadable.ReadAsync
|
|
// sees the plain address, not the raw TagConfig JSON. Verifies the dispatch contract
|
|
// the path-based NodeId refactor relies on.
|
|
var eq = Eq("eq-1", "line-1", "oven-3");
|
|
var tag = NewTag("t-1", "Temp", "Int32", "plc-01", "eq-1",
|
|
tagConfig: "{\"FullName\":\"plc-01/HR200\",\"DataType\":\"Int32\"}");
|
|
var content = new EquipmentNamespaceContent(
|
|
[Area("area-1", "warsaw")], [Line("line-1", "area-1", "line-a")],
|
|
[eq], [tag]);
|
|
|
|
var rec = new RecordingBuilder("root");
|
|
EquipmentNodeWalker.Walk(rec, content);
|
|
|
|
var v = rec.Children[0].Children[0].Children[0].Variables.Single();
|
|
v.AttributeInfo.FullName.ShouldBe("plc-01/HR200");
|
|
}
|
|
|
|
// ----- builders for test seed rows -----
|
|
|
|
private static UnsArea Area(string id, string name) => new()
|
|
{
|
|
UnsAreaId = id, ClusterId = "c1", Name = name, GenerationId = 1,
|
|
};
|
|
|
|
private static UnsLine Line(string id, string areaId, string name) => new()
|
|
{
|
|
UnsLineId = id, UnsAreaId = areaId, Name = name, GenerationId = 1,
|
|
};
|
|
|
|
private static Equipment Eq(string equipmentId, string lineId, string name) => new()
|
|
{
|
|
EquipmentRowId = Guid.NewGuid(),
|
|
GenerationId = 1,
|
|
EquipmentId = equipmentId,
|
|
EquipmentUuid = Guid.NewGuid(),
|
|
DriverInstanceId = "drv",
|
|
UnsLineId = lineId,
|
|
Name = name,
|
|
MachineCode = "MC-" + name,
|
|
};
|
|
|
|
private static Tag NewTag(string tagId, string name, string dataType, string address,
|
|
string? equipmentId, string? tagConfig = null) => new()
|
|
{
|
|
TagRowId = Guid.NewGuid(),
|
|
GenerationId = 1,
|
|
TagId = tagId,
|
|
DriverInstanceId = "drv",
|
|
EquipmentId = equipmentId,
|
|
Name = name,
|
|
DataType = dataType,
|
|
AccessLevel = ZB.MOM.WW.OtOpcUa.Configuration.Enums.TagAccessLevel.ReadWrite,
|
|
TagConfig = tagConfig ?? address,
|
|
};
|
|
|
|
// ----- recording IAddressSpaceBuilder -----
|
|
|
|
private sealed class RecordingBuilder(string browseName) : IAddressSpaceBuilder
|
|
{
|
|
public string BrowseName { get; } = browseName;
|
|
public List<RecordingBuilder> Children { get; } = new();
|
|
public List<RecordingVariable> Variables { get; } = new();
|
|
public List<RecordingProperty> Properties { get; } = new();
|
|
|
|
public IAddressSpaceBuilder Folder(string name, string _)
|
|
{
|
|
var child = new RecordingBuilder(name);
|
|
Children.Add(child);
|
|
return child;
|
|
}
|
|
|
|
public IVariableHandle Variable(string name, string _, DriverAttributeInfo attr)
|
|
{
|
|
var v = new RecordingVariable(name, attr);
|
|
Variables.Add(v);
|
|
return v;
|
|
}
|
|
|
|
public void AddProperty(string name, DriverDataType _, object? value) =>
|
|
Properties.Add(new RecordingProperty(name, value));
|
|
}
|
|
|
|
private sealed record RecordingProperty(string BrowseName, object? Value);
|
|
|
|
private sealed record RecordingVariable(string BrowseName, DriverAttributeInfo AttributeInfo) : IVariableHandle
|
|
{
|
|
public string FullReference => AttributeInfo.FullName;
|
|
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => throw new NotSupportedException();
|
|
}
|
|
}
|