From d909a8e4f6b6a9ca8c2c01b70dc402f127124996 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 8 Jun 2026 07:02:25 -0400 Subject: [PATCH] docs+test(deploy): clarify driver-less attribution docs + no-line exclusion test (Task 2 review) --- .../Drivers/DeploymentArtifact.cs | 14 +++++-- .../Drivers/DeploymentArtifactTests.cs | 38 +++++++++++++++++++ 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs index 763fd5c3..33ec3a59 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs @@ -209,9 +209,12 @@ public static class DeploymentArtifact /// Cluster-scoped overload: the address-space composition a node should materialise given /// its NodeId. Filters every projection to the node's own ClusterId (see ). + /// Equipment attribution is dual-path: driver-bound equipment (non-null DriverInstanceId) is kept when + /// its driver is in-cluster; driver-less equipment (null DriverInstanceId) is kept when its UNS line's + /// area is in-cluster. /// When is supplied it is invoked with a human-readable message for each - /// kept equipment whose owning UNS line is NOT in the node's cluster — a cross-cluster binding that - /// violates the same-cluster invariant (decision #122) and would orphan the equipment folder. This is + /// kept driver-bound equipment whose owning UNS line is NOT in the node's cluster — a cross-cluster binding + /// that violates the same-cluster invariant (decision #122) and would orphan the equipment folder. This is /// detection only (observability); the equipment is still returned, since the upstream draft validator /// is the authority that should prevent the binding in the first place. /// The deployment artifact blob. @@ -234,6 +237,9 @@ public static class DeploymentArtifact if (onInconsistency is not null) { var keptLineIds = keptLines.Select(l => l.UnsLineId).ToHashSet(StringComparer.OrdinalIgnoreCase); + // This cross-cluster check only fires for DRIVER-BOUND equipment. Driver-less equipment + // is attributed by its UNS line's area cluster, so by construction its line is always in + // keptLines — the condition `!keptLineIds.Contains(e.UnsLineId)` is never true for it. foreach (var e in keptEquipment) { if (!string.IsNullOrEmpty(e.UnsLineId) && !keptLineIds.Contains(e.UnsLineId)) @@ -265,7 +271,9 @@ public static class DeploymentArtifact private sealed record ClusterSets(HashSet DriverIds, HashSet AreaIds, HashSet EquipmentIds); /// Build the in-cluster id sets used to filter a composition: DriverInstanceIds + UnsAreaIds - /// that directly carry the ClusterId, plus EquipmentIds whose DriverInstanceId is in-cluster. + /// that directly carry the ClusterId, plus in-cluster EquipmentIds — driver-bound equipment attributed + /// by its driver's cluster, and driver-less equipment (null DriverInstanceId) attributed by its UNS + /// line's area cluster. /// The deployment artifact blob. /// The node's ClusterId to scope to. /// The resolved in-cluster id sets (empty on parse failure => empty composition). diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactTests.cs index 17f93746..e20fd048 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactTests.cs @@ -579,6 +579,44 @@ public sealed class DeploymentArtifactTests comp.EquipmentVirtualTags.ShouldBeEmpty(); } + /// Verifies driver-less equipment with no UnsLineId (null/absent) is EXCLUDED from the scoped + /// composition and throws nothing: without a line there is no area chain to resolve a cluster from. + [Fact] + public void Driverless_equipment_excluded_when_it_has_no_line() + { + var blob = BlobOf(new + { + Clusters = new[] { new { ClusterId = "C1" }, new { ClusterId = "C2" } }, + Nodes = new[] + { + new { NodeId = "c1-1:4053", ClusterId = "C1" }, + new { NodeId = "c2-1:4053", ClusterId = "C2" }, + }, + DriverInstances = new[] + { + // One driver per cluster so the artifact is genuinely multi-cluster (scoped, not None). + new { DriverInstanceId = "c1-driver", DriverType = "Modbus", DriverConfig = "{}", ClusterId = "C1", NamespaceId = "c1-ns" }, + new { DriverInstanceId = "c2-driver", DriverType = "Modbus", DriverConfig = "{}", ClusterId = "C2", NamespaceId = "c2-ns" }, + }, + UnsAreas = new[] { new { UnsAreaId = "A1", Name = "Area1", ClusterId = "C1" } }, + // Equipment has no DriverInstanceId (driver-less) and no UnsLineId — cannot resolve a cluster. + Equipment = new[] + { + new { EquipmentId = "E-noline", Name = "NoLine" }, + }, + Scripts = new[] { new { ScriptId = "scr", SourceCode = "return 1;" } }, + VirtualTags = new[] + { + new { VirtualTagId = "vt-noline", EquipmentId = "E-noline", Name = "VNL", DataType = "Float", ScriptId = "scr" }, + }, + }); + + var comp = DeploymentArtifact.ParseComposition(blob, "c1-1:4053"); + + comp.EquipmentNodes.ShouldBeEmpty(); + comp.EquipmentVirtualTags.ShouldBeEmpty(); + } + /// Verifies the unchanged driver-bound path: equipment with an in-cluster DriverInstanceId is /// kept even when (as here) no UNS line/area rows describe it — attribution is still by driver. [Fact]