docs+test(deploy): clarify driver-less attribution docs + no-line exclusion test (Task 2 review)

This commit is contained in:
Joseph Doherty
2026-06-08 07:02:25 -04:00
parent 0b5fc44866
commit d909a8e4f6
2 changed files with 49 additions and 3 deletions
@@ -209,9 +209,12 @@ public static class DeploymentArtifact
/// <summary>Cluster-scoped overload: the address-space composition a node should materialise given
/// its NodeId. Filters every projection to the node's own ClusterId (see <see cref="ResolveClusterScope"/>).
/// 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 <paramref name="onInconsistency"/> 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.</summary>
/// <param name="blob">The deployment artifact blob.</param>
@@ -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<string> DriverIds, HashSet<string> AreaIds, HashSet<string> EquipmentIds);
/// <summary>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.</summary>
/// 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.</summary>
/// <param name="blob">The deployment artifact blob.</param>
/// <param name="clusterId">The node's ClusterId to scope to.</param>
/// <returns>The resolved in-cluster id sets (empty on parse failure => empty composition).</returns>
@@ -579,6 +579,44 @@ public sealed class DeploymentArtifactTests
comp.EquipmentVirtualTags.ShouldBeEmpty();
}
/// <summary>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.</summary>
[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();
}
/// <summary>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.</summary>
[Fact]