using System.Linq; using System.Text; using System.Text.Json; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Runtime.Drivers; namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers; public sealed class DeploymentArtifactTests { private static byte[] BlobOf(object snapshot) => System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(snapshot); private static object MultiClusterSnapshot() => new { Clusters = new[] { new { ClusterId = "MAIN" }, new { ClusterId = "SITE-A" } }, Nodes = new[] { new { NodeId = "central-1:4053", ClusterId = "MAIN" }, new { NodeId = "site-a-1:4053", ClusterId = "SITE-A" }, }, DriverInstances = new[] { new { DriverInstanceRowId = Guid.NewGuid(), DriverInstanceId = "main-galaxy", Name = "g", DriverType = "GalaxyMxGateway", Enabled = true, DriverConfig = "{}", ClusterId = "MAIN", NamespaceId = "main-ns" }, new { DriverInstanceRowId = Guid.NewGuid(), DriverInstanceId = "sa-modbus", Name = "m", DriverType = "Modbus", Enabled = true, DriverConfig = "{}", ClusterId = "SITE-A", NamespaceId = "sa-ns" }, }, }; /// Verifies a single-cluster artifact resolves to None (apply everything). [Fact] public void ResolveClusterScope_single_cluster_artifact_returns_None() { var blob = BlobOf(new { Clusters = new[] { new { ClusterId = "MAIN" } }, Nodes = Array.Empty() }); var scope = DeploymentArtifact.ResolveClusterScope(blob, "central-1:4053"); scope.Mode.ShouldBe(ClusterFilterMode.None); } /// Verifies a multi-cluster artifact scopes a known node to its own ClusterId. [Fact] public void ResolveClusterScope_multi_cluster_known_node_scopes_to_its_cluster() { var scope = DeploymentArtifact.ResolveClusterScope(BlobOf(MultiClusterSnapshot()), "site-a-1:4053"); scope.Mode.ShouldBe(ClusterFilterMode.ScopeTo); scope.ClusterId.ShouldBe("SITE-A"); } /// Verifies a multi-cluster artifact suppresses an unknown node. [Fact] public void ResolveClusterScope_multi_cluster_unknown_node_suppresses() { var scope = DeploymentArtifact.ResolveClusterScope(BlobOf(MultiClusterSnapshot()), "ghost-9:4053"); scope.Mode.ShouldBe(ClusterFilterMode.Suppress); } /// Verifies the scoped parse returns only the node's own cluster's drivers. [Fact] public void ParseDriverInstances_scoped_returns_only_my_clusters_drivers() { var specs = DeploymentArtifact.ParseDriverInstances(BlobOf(MultiClusterSnapshot()), "central-1:4053"); specs.Select(s => s.DriverInstanceId).ShouldBe(new[] { "main-galaxy" }); } /// Verifies the scoped parse returns nothing for an unknown node. [Fact] public void ParseDriverInstances_scoped_unknown_node_returns_empty() { var specs = DeploymentArtifact.ParseDriverInstances(BlobOf(MultiClusterSnapshot()), "ghost-9:4053"); specs.ShouldBeEmpty(); } /// Verifies the scoped parse returns all drivers for a single-cluster artifact. [Fact] public void ParseDriverInstances_scoped_single_cluster_returns_all() { var blob = BlobOf(new { Clusters = new[] { new { ClusterId = "MAIN" } }, Nodes = new[] { new { NodeId = "n1:4053", ClusterId = "MAIN" } }, DriverInstances = new[] { new { DriverInstanceRowId = Guid.NewGuid(), DriverInstanceId = "d1", Name = "d", DriverType = "Modbus", Enabled = true, DriverConfig = "{}", ClusterId = "MAIN" } }, }); DeploymentArtifact.ParseDriverInstances(blob, "anything:4053").Select(s => s.DriverInstanceId).ShouldBe(new[] { "d1" }); } /// Verifies that empty blob returns empty list. [Fact] public void Empty_blob_returns_empty_list() { DeploymentArtifact.ParseDriverInstances(ReadOnlySpan.Empty).ShouldBeEmpty(); } /// Verifies that malformed JSON returns empty list. [Fact] public void Malformed_json_returns_empty_list() { DeploymentArtifact.ParseDriverInstances(Encoding.UTF8.GetBytes("not json")).ShouldBeEmpty(); } /// Verifies that snapshot without DriverInstances returns empty. [Fact] public void Snapshot_without_DriverInstances_returns_empty() { var blob = Encoding.UTF8.GetBytes("{\"Clusters\":[]}"); DeploymentArtifact.ParseDriverInstances(blob).ShouldBeEmpty(); } /// Verifies that driver instances are parsed from composer-shaped blob. [Fact] public void Parses_driver_instances_from_composer_shaped_blob() { // Mirrors the shape ConfigComposer.SnapshotAndFlattenAsync emits — Pascal-case fields // serialised directly off the EF entity. var rowId = Guid.NewGuid(); var blob = JsonSerializer.SerializeToUtf8Bytes(new { DriverInstances = new[] { new { DriverInstanceRowId = rowId, DriverInstanceId = "DI-modbus-1", Name = "Modbus Line A", DriverType = "Modbus", Enabled = true, DriverConfig = "{\"host\":\"127.0.0.1\"}", }, new { DriverInstanceRowId = Guid.NewGuid(), DriverInstanceId = "DI-disabled", Name = "Decommissioned", DriverType = "AbCip", Enabled = false, DriverConfig = "{}", }, }, }); var specs = DeploymentArtifact.ParseDriverInstances(blob); specs.Count.ShouldBe(2); specs[0].DriverInstanceRowId.ShouldBe(rowId); specs[0].DriverInstanceId.ShouldBe("DI-modbus-1"); specs[0].DriverType.ShouldBe("Modbus"); specs[0].Enabled.ShouldBeTrue(); specs[0].DriverConfig.ShouldContain("127.0.0.1"); specs[1].Enabled.ShouldBeFalse(); } /// Verifies that ParseComposition returns empty for empty blob. [Fact] public void ParseComposition_returns_empty_for_empty_blob() { var c = DeploymentArtifact.ParseComposition(ReadOnlySpan.Empty); c.EquipmentNodes.ShouldBeEmpty(); c.DriverInstancePlans.ShouldBeEmpty(); c.ScriptedAlarmPlans.ShouldBeEmpty(); } /// Verifies that ParseComposition reads all three entity classes sorted by ID. [Fact] public void ParseComposition_reads_all_three_entity_classes_sorted_by_id() { var blob = JsonSerializer.SerializeToUtf8Bytes(new { Equipment = new[] { new { EquipmentId = "eq-z", MachineCode = "Z", UnsLineId = "line-1" }, new { EquipmentId = "eq-a", MachineCode = "A", UnsLineId = "line-1" }, }, DriverInstances = new[] { new { DriverInstanceId = "drv-1", DriverType = "Modbus", DriverConfig = "{}" }, }, ScriptedAlarms = new[] { new { ScriptedAlarmId = "alarm-1", EquipmentId = "eq-a", PredicateScriptId = "script-1", MessageTemplate = "high", }, }, }); var c = DeploymentArtifact.ParseComposition(blob); c.EquipmentNodes.Select(e => e.EquipmentId).ShouldBe(new[] { "eq-a", "eq-z" }); c.DriverInstancePlans.Single().DriverInstanceId.ShouldBe("drv-1"); c.ScriptedAlarmPlans.Single().ScriptedAlarmId.ShouldBe("alarm-1"); } /// /// Verifies ParseComposition surfaces Equipment-namespace tags (non-null EquipmentId in an /// Equipment-kind namespace) as EquipmentTags, with FullName extracted /// from the tag's TagConfig blob. A tag in a non-Equipment (Simulated) namespace with a /// null EquipmentId must NOT surface in EquipmentTags — byte-parity with the composer's pure /// ns.Kind == NamespaceKind.Equipment predicate (only Equipment-kind namespaces route /// into EquipmentTags, so such a tag routes nowhere). /// [Fact] public void ParseComposition_reads_EquipmentTags_from_equipment_namespace() { var blob = JsonSerializer.SerializeToUtf8Bytes(new { Namespaces = new[] { new { NamespaceId = "ns-eq", Kind = 0 }, // NamespaceKind.Equipment new { NamespaceId = "ns-sim", Kind = 1 }, // NamespaceKind.Simulated (non-Equipment) }, DriverInstances = new[] { new { DriverInstanceId = "drv-modbus", DriverType = "Modbus", DriverConfig = "{}", NamespaceId = "ns-eq" }, new { DriverInstanceId = "drv-sim", DriverType = "Modbus", DriverConfig = "{}", NamespaceId = "ns-sim" }, }, Tags = new object[] { new { TagId = "tag-eq", DriverInstanceId = "drv-modbus", EquipmentId = "eq-1", Name = "Speed", FolderPath = (string?)null, DataType = "Float", TagConfig = "{\"FullName\":\"40001\"}", }, new { TagId = "tag-sim", DriverInstanceId = "drv-sim", EquipmentId = (string?)null, Name = "Temp", FolderPath = "area", DataType = "Float", TagConfig = "{\"FullName\":\"area.Temp\"}", }, }, }); var c = DeploymentArtifact.ParseComposition(blob); var tag = c.EquipmentTags.ShouldHaveSingleItem(); tag.TagId.ShouldBe("tag-eq"); tag.EquipmentId.ShouldBe("eq-1"); tag.DriverInstanceId.ShouldBe("drv-modbus"); tag.Name.ShouldBe("Speed"); tag.DataType.ShouldBe("Float"); tag.FullName.ShouldBe("40001"); // extracted from TagConfig, not the raw blob // The tag in the non-Equipment (Simulated) namespace, with null EquipmentId, does NOT leak // into EquipmentTags — byte-parity with the composer's pure ns.Kind == Equipment predicate. c.EquipmentTags.ShouldNotContain(t => t.TagId == "tag-sim"); } /// /// Verifies ParseComposition surfaces Equipment-namespace VirtualTags (joined to their Script /// by ScriptId for the expression source) as EquipmentVirtualTags, with the /// DependencyRefs extracted from the script's ctx.GetTag("…") literals — the /// artifact-decode mirror of Phase7Composer.Compose's VirtualTag producer. /// [Fact] public void ParseComposition_reads_EquipmentVirtualTags_from_virtualtags_and_scripts() { var blob = JsonSerializer.SerializeToUtf8Bytes(new { Scripts = new[] { new { ScriptId = "scr-1", SourceCode = "return ctx.GetTag(\"TestMachine_001.TestDouble\").Value;", }, }, VirtualTags = new[] { new { VirtualTagId = "vt-1", EquipmentId = "eq-1", Name = "Doubled", DataType = "Float", ScriptId = "scr-1", }, }, }); var c = DeploymentArtifact.ParseComposition(blob); var vt = c.EquipmentVirtualTags.ShouldHaveSingleItem(); vt.VirtualTagId.ShouldBe("vt-1"); vt.EquipmentId.ShouldBe("eq-1"); vt.Name.ShouldBe("Doubled"); vt.DataType.ShouldBe("Float"); vt.FolderPath.ShouldBe(""); vt.Expression.ShouldBe("return ctx.GetTag(\"TestMachine_001.TestDouble\").Value;"); vt.DependencyRefs.ShouldBe(new[] { "TestMachine_001.TestDouble" }); } /// /// Verifies ParseComposition sets the equipment folder DisplayName to the UNS Name /// 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. /// [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"); } /// Verifies that specs missing required fields are dropped. [Fact] public void Spec_missing_required_fields_is_dropped() { var blob = JsonSerializer.SerializeToUtf8Bytes(new { DriverInstances = new object[] { new { Name = "no-id" }, new { DriverInstanceId = "DI-ok", DriverType = "Modbus", DriverConfig = "{}", }, }, }); var specs = DeploymentArtifact.ParseDriverInstances(blob); specs.Single().DriverInstanceId.ShouldBe("DI-ok"); } /// Verifies that a malformed blob resolves to None rather than throwing. [Fact] public void ResolveClusterScope_malformed_blob_returns_None() { var scope = DeploymentArtifact.ResolveClusterScope("not json"u8.ToArray(), "central-1:4053"); scope.Mode.ShouldBe(ClusterFilterMode.None); } /// Verifies that a blank ClusterId in the node row resolves to Suppress. [Fact] public void ResolveClusterScope_blank_cluster_id_suppresses() { var blob = BlobOf(new { Clusters = new[] { new { ClusterId = "MAIN" }, new { ClusterId = "SITE-A" } }, Nodes = new[] { new { NodeId = "central-1:4053", ClusterId = "" } }, }); DeploymentArtifact.ResolveClusterScope(blob, "central-1:4053").Mode.ShouldBe(ClusterFilterMode.Suppress); } /// Verifies that NodeId matching in ResolveClusterScope is case-insensitive. [Fact] public void ResolveClusterScope_node_id_match_is_case_insensitive() { var blob = BlobOf(new { Clusters = new[] { new { ClusterId = "MAIN" }, new { ClusterId = "SITE-A" } }, Nodes = new[] { new { NodeId = "Central-1:4053", ClusterId = "MAIN" } }, }); var scope = DeploymentArtifact.ResolveClusterScope(blob, "central-1:4053"); scope.Mode.ShouldBe(ClusterFilterMode.ScopeTo); scope.ClusterId.ShouldBe("MAIN"); } private static object MultiClusterSnapshotWithTags() => new { Clusters = new[] { new { ClusterId = "MAIN" }, new { ClusterId = "SITE-A" } }, Nodes = new[] { new { NodeId = "central-1:4053", ClusterId = "MAIN" }, new { NodeId = "site-a-1:4053", ClusterId = "SITE-A" }, }, DriverInstances = new[] { new { DriverInstanceId = "main-galaxy", DriverType = "GalaxyMxGateway", DriverConfig = "{}", ClusterId = "MAIN", NamespaceId = "main-ns" }, new { DriverInstanceId = "sa-galaxy", DriverType = "GalaxyMxGateway", DriverConfig = "{}", ClusterId = "SITE-A", NamespaceId = "sa-ns" }, }, // Galaxy points are ordinary equipment tags now — Equipment-kind namespaces with non-null // EquipmentId, so the cluster-scoped decode filters them via EquipmentTags (by their driver's // cluster), exactly as it filtered the retired GalaxyTags. Namespaces = new[] { new { NamespaceId = "main-ns", ClusterId = "MAIN", Kind = 0 }, new { NamespaceId = "sa-ns", ClusterId = "SITE-A", Kind = 0 }, }, Tags = new[] { new { TagId = "t-main", DriverInstanceId = "main-galaxy", EquipmentId = (string?)"eq-main", Name = "M1", FolderPath = "F", DataType = "Boolean", TagConfig = "{}" }, new { TagId = "t-sa", DriverInstanceId = "sa-galaxy", EquipmentId = (string?)"eq-sa", Name = "S1", FolderPath = "F", DataType = "Boolean", TagConfig = "{}" }, }, }; [Fact] public void ParseComposition_scoped_keeps_only_my_clusters_drivers_and_tags() { var blob = BlobOf(MultiClusterSnapshotWithTags()); var main = DeploymentArtifact.ParseComposition(blob, "central-1:4053"); main.DriverInstancePlans.Select(d => d.DriverInstanceId).ShouldBe(new[] { "main-galaxy" }); main.EquipmentTags.Select(t => t.TagId).ShouldBe(new[] { "t-main" }); var siteA = DeploymentArtifact.ParseComposition(blob, "site-a-1:4053"); siteA.DriverInstancePlans.Select(d => d.DriverInstanceId).ShouldBe(new[] { "sa-galaxy" }); siteA.EquipmentTags.Select(t => t.TagId).ShouldBe(new[] { "t-sa" }); } [Fact] public void ParseComposition_scoped_unknown_node_is_empty() { var comp = DeploymentArtifact.ParseComposition(BlobOf(MultiClusterSnapshotWithTags()), "ghost-9:4053"); comp.EquipmentTags.ShouldBeEmpty(); comp.DriverInstancePlans.ShouldBeEmpty(); } /// Verifies the cluster-scoped overload keeps only EquipmentVirtualTags whose EquipmentId /// belongs to an in-cluster driver (mirroring how EquipmentTags + ScriptedAlarms are filtered). [Fact] public void ParseComposition_scoped_keeps_only_my_clusters_virtual_tags() { var blob = BlobOf(new { Clusters = new[] { new { ClusterId = "MAIN" }, new { ClusterId = "SITE-A" } }, Nodes = new[] { new { NodeId = "central-1:4053", ClusterId = "MAIN" }, new { NodeId = "site-a-1:4053", ClusterId = "SITE-A" }, }, DriverInstances = new[] { new { DriverInstanceId = "main-modbus", DriverType = "Modbus", DriverConfig = "{}", ClusterId = "MAIN", NamespaceId = "main-ns" }, new { DriverInstanceId = "sa-modbus", DriverType = "Modbus", DriverConfig = "{}", ClusterId = "SITE-A", NamespaceId = "sa-ns" }, }, Equipment = new[] { new { EquipmentId = "eq-main", Name = "eqm", UnsLineId = "l1", DriverInstanceId = "main-modbus" }, new { EquipmentId = "eq-sa", Name = "eqs", UnsLineId = "l2", DriverInstanceId = "sa-modbus" }, }, Scripts = new[] { new { ScriptId = "scr", SourceCode = "return 1;" }, }, VirtualTags = new[] { new { VirtualTagId = "vt-main", EquipmentId = "eq-main", Name = "VM", DataType = "Float", ScriptId = "scr" }, new { VirtualTagId = "vt-sa", EquipmentId = "eq-sa", Name = "VS", DataType = "Float", ScriptId = "scr" }, }, }); var main = DeploymentArtifact.ParseComposition(blob, "central-1:4053"); main.EquipmentVirtualTags.Select(v => v.VirtualTagId).ShouldBe(new[] { "vt-main" }); var siteA = DeploymentArtifact.ParseComposition(blob, "site-a-1:4053"); siteA.EquipmentVirtualTags.Select(v => v.VirtualTagId).ShouldBe(new[] { "vt-sa" }); } [Fact] public void ParseComposition_single_cluster_node_id_overload_matches_legacy() { var blob = BlobOf(new { Clusters = new[] { new { ClusterId = "MAIN" } }, Nodes = new[] { new { NodeId = "n1:4053", ClusterId = "MAIN" } }, DriverInstances = new[] { new { DriverInstanceId = "d1", DriverType = "Modbus", DriverConfig = "{}", ClusterId = "MAIN", NamespaceId = "ns" } }, }); DeploymentArtifact.ParseComposition(blob, "anything:4053").DriverInstancePlans.Count .ShouldBe(DeploymentArtifact.ParseComposition(blob).DriverInstancePlans.Count); } /// An artifact where an equipment's driver is in the node's cluster but its UNS line's area /// is in another cluster: controls the area's ClusterId. private static byte[] OrphanEquipmentBlob(string areaCluster) => BlobOf(new { Clusters = new[] { new { ClusterId = "MAIN" }, new { ClusterId = "SITE-A" } }, Nodes = new[] { new { NodeId = "central-1:4053", ClusterId = "MAIN" } }, DriverInstances = new[] { new { DriverInstanceId = "main-driver", DriverType = "Modbus", DriverConfig = "{}", ClusterId = "MAIN", NamespaceId = "main-ns" }, }, UnsAreas = new[] { new { UnsAreaId = "area-1", Name = "Area1", ClusterId = areaCluster } }, UnsLines = new[] { new { UnsLineId = "line-1", UnsAreaId = "area-1", Name = "Line1" } }, Equipment = new[] { new { EquipmentId = "equip-1", Name = "Equip1", UnsLineId = "line-1", DriverInstanceId = "main-driver" }, }, }); /// Verifies the inconsistency callback fires when a kept equipment's UNS line belongs to /// another cluster (a cross-cluster orphan binding). [Fact] public void ParseComposition_scoped_flags_cross_cluster_orphan_equipment() { var warnings = new List(); var comp = DeploymentArtifact.ParseComposition( OrphanEquipmentBlob(areaCluster: "SITE-A"), "central-1:4053", warnings.Add); comp.EquipmentNodes.Select(e => e.EquipmentId).ShouldBe(new[] { "equip-1" }); // still returned warnings.Count.ShouldBe(1); warnings[0].ShouldContain("equip-1"); warnings[0].ShouldContain("line-1"); } /// Verifies the inconsistency callback does NOT fire when the equipment, its driver, and its /// UNS line/area are all in the same cluster (the normal, invariant-respecting case). [Fact] public void ParseComposition_scoped_consistent_equipment_does_not_warn() { var warnings = new List(); var comp = DeploymentArtifact.ParseComposition( OrphanEquipmentBlob(areaCluster: "MAIN"), "central-1:4053", warnings.Add); comp.EquipmentNodes.Select(e => e.EquipmentId).ShouldBe(new[] { "equip-1" }); comp.UnsLines.Select(l => l.UnsLineId).ShouldBe(new[] { "line-1" }); warnings.ShouldBeEmpty(); } /// A multi-cluster artifact with one driver-less equipment (no DriverInstanceId /// property at all => null driver) attributed only via its UNS line's area cluster: /// UnsArea A1 (ClusterId = ), UnsLine L1 (UnsAreaId = A1), /// Equipment E1 (UnsLineId = L1, no driver), plus an EquipmentVirtualTag on E1. The MAIN-cluster /// node always carries a driver so the artifact is genuinely multi-cluster (scoped, not None). private static byte[] DriverlessEquipmentBlob(string areaCluster) => 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[] { 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 = areaCluster } }, UnsLines = new[] { new { UnsLineId = "L1", UnsAreaId = "A1", Name = "Line1" } }, // Driver-less equipment: the DriverInstanceId property is absent => the parser sees null. Equipment = new[] { new { EquipmentId = "E1", Name = "Equip1", UnsLineId = "L1" }, }, Scripts = new[] { new { ScriptId = "scr", SourceCode = "return 1;" } }, VirtualTags = new[] { new { VirtualTagId = "vt-e1", EquipmentId = "E1", Name = "VE1", DataType = "Float", ScriptId = "scr" }, }, }); /// Verifies driver-less equipment (null DriverInstanceId) IS kept when its UNS line's area /// is in the node's cluster — attributed via the line→area→cluster chain rather than via a driver. /// Both the EquipmentNode and its EquipmentVirtualTag must survive the scoped parse. [Fact] public void Driverless_equipment_kept_when_its_line_area_is_in_cluster() { var comp = DeploymentArtifact.ParseComposition( DriverlessEquipmentBlob(areaCluster: "C1"), "c1-1:4053"); comp.EquipmentNodes.Select(e => e.EquipmentId).ShouldBe(new[] { "E1" }); comp.EquipmentVirtualTags.Select(v => v.VirtualTagId).ShouldBe(new[] { "vt-e1" }); } /// Verifies driver-less equipment is EXCLUDED when its UNS line's area is in another cluster: /// the node scopes to C1 but area A1 (and thus line L1, equipment E1) lives in C2. [Fact] public void Driverless_equipment_excluded_when_its_line_area_in_other_cluster() { var comp = DeploymentArtifact.ParseComposition( DriverlessEquipmentBlob(areaCluster: "C2"), "c1-1:4053"); comp.EquipmentNodes.ShouldBeEmpty(); 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] public void Driverbound_equipment_kept_when_driver_in_cluster() { 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[] { new { DriverInstanceId = "c1-driver", DriverType = "Modbus", DriverConfig = "{}", ClusterId = "C1", NamespaceId = "c1-ns" }, new { DriverInstanceId = "c2-driver", DriverType = "Modbus", DriverConfig = "{}", ClusterId = "C2", NamespaceId = "c2-ns" }, }, Equipment = new[] { new { EquipmentId = "E-c1", Name = "Eq-c1", UnsLineId = "l1", DriverInstanceId = "c1-driver" }, new { EquipmentId = "E-c2", Name = "Eq-c2", UnsLineId = "l2", DriverInstanceId = "c2-driver" }, }, }); var comp = DeploymentArtifact.ParseComposition(blob, "c1-1:4053"); comp.EquipmentNodes.Select(e => e.EquipmentId).ShouldBe(new[] { "E-c1" }); } }