Phase 1b of the v2 entity-model rewrite. The design's live-edit model means
the 12 v2 live-edit entities no longer carry a generation scope — they're
edited directly via AdminOperationsActor, with RowVersion (added in Task 14a)
providing last-write-wins detection.
Entity changes (12 files):
Equipment, DriverInstance, Device, Tag, PollGroup, Namespace,
UnsArea, UnsLine, NodeAcl, Script, VirtualTag, ScriptedAlarm
- Removed: public long GenerationId
- Removed: public ConfigGeneration? Generation (navigation)
DbContext changes (OtOpcUaConfigDbContext.cs):
- Removed 12 HasOne(x => x.Generation).WithMany().HasForeignKey... mappings
- Rewrote ~36 indexes: dropped the GenerationId column from each composite
key, renamed UX_<Table>_Generation_<X> -> UX_<Table>_<X> and
IX_<Table>_Generation_<X> -> IX_<Table>_<X>. Logical IDs become globally
unique (UX_<Table>_LogicalId on the LogicalId column alone).
- Removed Namespace's redundant UX_Namespace_Generation_LogicalId_Cluster
index (subsumed by the new UX_Namespace_LogicalId).
Core.Tests fixtures (4 files):
Removed "GenerationId = 1," lines from:
- PermissionTrieBuilderTests.cs (NodeAcl Row factory)
- PermissionTrieTests.cs (NodeAcl Row factory)
- TriePermissionEvaluatorTests.cs (NodeAcl Row factory + 2 gen{1,5}Row
mutations that test stale-generation evaluation; the trie itself still
carries a generation tag via PermissionTrie.GenerationId, fed in via
PermissionTrieBuilder.Build's generationId parameter, so the tests
still exercise the production code path)
- EquipmentNodeWalkerTests.cs (Area/Line/Eq/Tag/VirtualTag/ScriptedAlarm
builders)
Expected breakage (accepted per Task 56 policy):
src/Server/ZB.MOM.WW.OtOpcUa.Server ~25 errors (DriverInstanceBootstrapper,
AuthorizationBootstrap,
EquipmentNamespaceContentLoader,
Phase7Composer, ...)
src/Server/ZB.MOM.WW.OtOpcUa.Admin ~45 errors (VirtualTags.razor,
ScriptedAlarms.razor,
DriverInstanceService,
EquipmentService,
EquipmentImportBatchService,
UnsService,
FocasDriverDetailService,
...)
Server.Tests, Admin.Tests, Admin.E2ETests also break transitively (they
project-reference Server/Admin). All deleted in Task 56.
Verification:
dotnet build src/Core/ZB.MOM.WW.OtOpcUa.Configuration -> 0 errors
dotnet build tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests -> 0 errors
dotnet build tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests -> 0 errors
dotnet build (whole solution) -> 70 errors, all in Server/Admin
383 lines
15 KiB
C#
383 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(),
|
|
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(),
|
|
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(),
|
|
VirtualTagId = "vt-1", EquipmentId = "eq-1", Name = "Disabled",
|
|
DataType = "Float32", ScriptId = "scr-1", Enabled = false,
|
|
};
|
|
var alarm = new ScriptedAlarm
|
|
{
|
|
ScriptedAlarmRowId = Guid.NewGuid(),
|
|
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,
|
|
};
|
|
|
|
private static UnsLine Line(string id, string areaId, string name) => new()
|
|
{
|
|
UnsLineId = id, UnsAreaId = areaId, Name = name,
|
|
};
|
|
|
|
private static Equipment Eq(string equipmentId, string lineId, string name) => new()
|
|
{
|
|
EquipmentRowId = Guid.NewGuid(),
|
|
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(),
|
|
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();
|
|
}
|
|
}
|