fix(opcua): UNS equipment folders browse by friendly Name, NodeId stays the logical Id

Equipment folder DisplayName was the colloquial MachineCode; the live rebuild (artifact
ReadEquipmentNode) + composer now use the UNS level-5 Name segment, matching Area/Line
folders + EquipmentNodeWalker. NodeId stays the logical EquipmentId so browse-path
resolution + ACLs are unaffected.
This commit is contained in:
Joseph Doherty
2026-06-06 14:51:12 -04:00
parent df0dc516c3
commit 08cddfe128
5 changed files with 47 additions and 3 deletions
@@ -6,7 +6,7 @@
{"id": 0, "nativeTaskId": 86, "subject": "Task 0: Confirm signatures + record architecture decisions", "status": "completed", "blockedBy": []},
{"id": 1, "nativeTaskId": 87, "subject": "Task 1: Carry equipment signals in the composition + artifact", "status": "completed", "blockedBy": [86]},
{"id": 2, "nativeTaskId": 88, "subject": "Task 2: Materialise equipment signals in the live rebuild", "status": "completed", "blockedBy": [87]},
{"id": 3, "nativeTaskId": 89, "subject": "Task 3: Friendly browse names for UNS folders", "status": "pending", "blockedBy": [87]},
{"id": 3, "nativeTaskId": 89, "subject": "Task 3: Friendly browse names for UNS folders", "status": "completed", "blockedBy": [87]},
{"id": 4, "nativeTaskId": 90, "subject": "Task 4: Idempotency + restart-safety", "status": "pending", "blockedBy": [88]},
{"id": 5, "nativeTaskId": 91, "subject": "Task 5: docker-dev integration verification + tool support", "status": "pending", "blockedBy": [88, 89]}
],
@@ -169,7 +169,10 @@ public static class Phase7Composer
var nodes = equipment
.OrderBy(e => e.EquipmentId, StringComparer.Ordinal)
.Select(e => new EquipmentNode(e.EquipmentId, e.MachineCode, e.UnsLineId))
// DisplayName = the UNS level-5 Name segment (friendly browse name, matching the Area
// and Line projections + EquipmentNodeWalker) — NOT the colloquial MachineCode. NodeId
// stays the logical EquipmentId so browse-path resolution + ACLs are unaffected.
.Select(e => new EquipmentNode(e.EquipmentId, e.Name, e.UnsLineId))
.ToList();
var plans = driverInstances
@@ -365,7 +365,10 @@ public static class DeploymentArtifact
private static EquipmentNode? ReadEquipmentNode(JsonElement el)
{
var id = el.TryGetProperty("EquipmentId", out var idEl) ? idEl.GetString() : null;
var displayName = el.TryGetProperty("MachineCode", out var mcEl) ? mcEl.GetString() : null;
// DisplayName = the UNS level-5 Name segment (friendly browse name, matching UnsArea/UnsLine
// + the live rebuild's source of truth) — NOT the colloquial MachineCode. NodeId stays the
// logical EquipmentId.
var displayName = el.TryGetProperty("Name", out var nameEl) ? nameEl.GetString() : null;
var lineId = el.TryGetProperty("UnsLineId", out var lineEl) ? lineEl.GetString() : null;
if (string.IsNullOrWhiteSpace(id)) return null;
return new EquipmentNode(id!, displayName ?? id!, lineId ?? string.Empty);
@@ -75,6 +75,22 @@ public sealed class Phase7ComposerPurityTests
.ShouldBe(new[] { "a", "m", "z" });
}
/// <summary>Verifies UNS equipment folders browse by the friendly UNS <c>Name</c> segment
/// (not the colloquial MachineCode, not the logical EquipmentId) while the NodeId stays the
/// logical EquipmentId — so browse-path resolution + ACLs are unaffected (decision #3).</summary>
[Fact]
public void Equipment_node_DisplayName_is_the_UNS_Name_not_MachineCode()
{
var equipment = new[] { NewEquipment("filling-eq") }; // Name="filling-eq", MachineCode="FILLING-EQ"
var node = Phase7Composer.Compose(equipment, Array.Empty<DriverInstance>(), Array.Empty<ScriptedAlarm>())
.EquipmentNodes.ShouldHaveSingleItem();
node.EquipmentId.ShouldBe("filling-eq"); // NodeId stays the logical Id
node.DisplayName.ShouldBe("filling-eq"); // browse name = UNS Name segment
node.DisplayName.ShouldNotBe("FILLING-EQ"); // not the colloquial MachineCode
}
private static Equipment NewEquipment(string id) => new()
{
EquipmentId = id,
@@ -177,6 +177,28 @@ public sealed class DeploymentArtifactTests
c.GalaxyTags.ShouldContain(g => g.TagId == "tag-gx");
}
/// <summary>
/// Verifies ParseComposition sets the equipment folder DisplayName to the UNS <c>Name</c>
/// segment — the source the live rebuild actually uses — not the colloquial MachineCode, so
/// equipment browses by its friendly UNS name. NodeId stays the logical EquipmentId.
/// </summary>
[Fact]
public void ParseComposition_equipment_DisplayName_is_UNS_Name_not_MachineCode()
{
var blob = JsonSerializer.SerializeToUtf8Bytes(new
{
Equipment = new[]
{
new { EquipmentId = "eq-1", Name = "filling-eq", MachineCode = "FILLING-EQ", UnsLineId = "line-1" },
},
});
var node = DeploymentArtifact.ParseComposition(blob).EquipmentNodes.ShouldHaveSingleItem();
node.EquipmentId.ShouldBe("eq-1");
node.DisplayName.ShouldBe("filling-eq");
}
/// <summary>Verifies that specs missing required fields are dropped.</summary>
[Fact]
public void Spec_missing_required_fields_is_dropped()