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 — the equipment-signal mirror of the Galaxy-tag path. A /// SystemPlatform (Galaxy) tag in the same blob must NOT leak into EquipmentTags and must /// still route to GalaxyTags. /// [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-sp", Kind = 1 }, // NamespaceKind.SystemPlatform }, DriverInstances = new[] { new { DriverInstanceId = "drv-modbus", DriverType = "Modbus", DriverConfig = "{}", NamespaceId = "ns-eq" }, new { DriverInstanceId = "drv-galaxy", DriverType = "Galaxy", DriverConfig = "{}", NamespaceId = "ns-sp" }, }, 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-gx", DriverInstanceId = "drv-galaxy", 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 Galaxy tag still routes to GalaxyTags and does NOT leak into EquipmentTags. c.GalaxyTags.ShouldContain(g => g.TagId == "tag-gx"); } /// /// 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" }, }, Namespaces = new[] { new { NamespaceId = "main-ns", ClusterId = "MAIN", Kind = 1 }, new { NamespaceId = "sa-ns", ClusterId = "SITE-A", Kind = 1 }, }, Tags = new[] { new { TagId = "t-main", DriverInstanceId = "main-galaxy", EquipmentId = (string?)null, Name = "M1", FolderPath = "F", DataType = "Boolean", TagConfig = "{}" }, new { TagId = "t-sa", DriverInstanceId = "sa-galaxy", EquipmentId = (string?)null, 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.GalaxyTags.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.GalaxyTags.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.GalaxyTags.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); } }