Per ADR-002, adds the Driver/Virtual/ScriptedAlarm discriminator to DriverAttributeInfo
so the DriverNodeManager's dispatch layer can route Read/Write/Subscribe to the right
runtime subsystem — drivers (unchanged), VirtualTagEngine (Phase 7 Stream B), or
ScriptedAlarmEngine (Phase 7 Stream C).
## Changes
- NodeSourceKind enum added to Core.Abstractions (Driver=0/Virtual=1/ScriptedAlarm=2).
- DriverAttributeInfo gains Source / VirtualTagId / ScriptedAlarmId parameters — all
default so existing call sites (every driver) compile unchanged.
- EquipmentNamespaceContent gains VirtualTags + ScriptedAlarms optional collections.
- EquipmentNodeWalker emits:
- Virtual-tag variables — Source=Virtual, VirtualTagId set, Historize flag honored
- Scripted-alarm variables — Source=ScriptedAlarm, ScriptedAlarmId set, IsAlarm=true
(triggers node-manager AlarmConditionState materialization)
- Skips disabled virtual tags + scripted alarms
## Tests — 13/13 in EquipmentNodeWalkerTests (5 new)
- Virtual-tag variables carry Source=Virtual + VirtualTagId + Historize flag
- Scripted-alarm variables carry Source=ScriptedAlarm + IsAlarm=true + Boolean type
- Disabled rows skipped
- Null VirtualTags/ScriptedAlarms collections safe (back-compat for non-Phase-7 callers)
- Driver tags default Source=Driver (ensures no discriminator regression)
## Next
Stream G follow-up: DriverNodeManager dispatch (Read/Write/Subscribe routing by
NodeSourceKind), SealedBootstrap wiring of VirtualTagEngine + ScriptedAlarmEngine,
end-to-end integration test.
333 lines
13 KiB
C#
333 lines
13 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();
|
|
}
|
|
|
|
// ----- 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) => 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 = 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();
|
|
}
|
|
}
|