From bc9e83ed9f0a16a4e84252c00acd944740756337 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 11 Jun 2026 21:10:21 -0400 Subject: [PATCH] feat(composer): admit GalaxyMxGateway-backed equipment alias tags (+byte-parity) --- .../Phase7Composer.cs | 7 +- .../Drivers/DeploymentArtifact.cs | 13 ++- .../Phase7ComposerAliasTagTests.cs | 98 +++++++++++++++++++ .../DeploymentArtifactAliasParityTests.cs | 96 ++++++++++++++++++ 4 files changed, 211 insertions(+), 3 deletions(-) create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ComposerAliasTagTests.cs create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactAliasParityTests.cs diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs index dd527993..ad2c67bf 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs @@ -362,11 +362,16 @@ public static class Phase7Composer // Equipment tags = the inverse filter: a Tag bound to an Equipment (non-null EquipmentId) // whose driver's namespace is Equipment-kind. FullName is the driver-side wire reference // pulled from TagConfig — it becomes the variable's NodeId + read/write routing key. + // A Galaxy alias is the one exception to the Equipment-kind namespace rule: a GalaxyMxGateway + // driver lives in a SystemPlatform-kind namespace, yet an equipment-scoped alias Tag bound to + // it must still surface as an equipment tag — so admit those by DriverType. The galaxyTags + // producer keeps its `t.EquipmentId is null` guard, so an alias (EquipmentId set) never + // double-counts there. var equipmentTags = tags .Where(t => t.EquipmentId is not null) .Where(t => driversById.TryGetValue(t.DriverInstanceId, out var di) && namespacesById.TryGetValue(di.NamespaceId, out var ns) - && ns.Kind == NamespaceKind.Equipment) + && (ns.Kind == NamespaceKind.Equipment || di.DriverType == "GalaxyMxGateway")) .OrderBy(t => t.EquipmentId, StringComparer.Ordinal) .ThenBy(t => t.FolderPath ?? string.Empty, StringComparer.Ordinal) // coalesce so the sort matches the artifact-decode side exactly .ThenBy(t => t.Name, StringComparer.Ordinal) 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 0cb20f6f..7b5d0394 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs @@ -471,8 +471,11 @@ public static class DeploymentArtifact if (isEquipment) equipmentNamespaces.Add(id!); } - // driverInstanceId → namespaceId + // driverInstanceId → namespaceId, and driverInstanceId → DriverType. The DriverType map admits + // a Galaxy alias (a GalaxyMxGateway-backed equipment-scoped tag) that lives in a SystemPlatform + // namespace — byte-parity with the composer's `di.DriverType == "GalaxyMxGateway"` clause. var driverToNamespace = new Dictionary(StringComparer.Ordinal); + var driverToType = new Dictionary(StringComparer.Ordinal); foreach (var el in diArr.EnumerateArray()) { if (el.ValueKind != JsonValueKind.Object) continue; @@ -480,6 +483,9 @@ public static class DeploymentArtifact var ns = el.TryGetProperty("NamespaceId", out var nsEl) ? nsEl.GetString() : null; if (!string.IsNullOrWhiteSpace(id) && !string.IsNullOrWhiteSpace(ns)) driverToNamespace[id!] = ns!; + var dtype = el.TryGetProperty("DriverType", out var dtEl) ? dtEl.GetString() : null; + if (!string.IsNullOrWhiteSpace(id) && !string.IsNullOrWhiteSpace(dtype)) + driverToType[id!] = dtype!; } var result = new List(tagsArr.GetArrayLength()); @@ -502,7 +508,10 @@ public static class DeploymentArtifact if (string.IsNullOrWhiteSpace(tagId) || string.IsNullOrWhiteSpace(di) || string.IsNullOrWhiteSpace(name)) continue; if (!driverToNamespace.TryGetValue(di!, out var nsId)) continue; - if (!equipmentNamespaces.Contains(nsId)) continue; + // A GalaxyMxGateway-backed alias qualifies even though its namespace is SystemPlatform-kind + // (not Equipment) — byte-parity with the composer's broadened equipment-tag filter. + var isGalaxyAlias = driverToType.TryGetValue(di!, out var dtype2) && dtype2 == "GalaxyMxGateway"; + if (!equipmentNamespaces.Contains(nsId) && !isGalaxyAlias) continue; result.Add(new EquipmentTagPlan( TagId: tagId!, diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ComposerAliasTagTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ComposerAliasTagTests.cs new file mode 100644 index 00000000..348f476d --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ComposerAliasTagTests.cs @@ -0,0 +1,98 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Configuration.Entities; +using ZB.MOM.WW.OtOpcUa.Configuration.Enums; + +namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests; + +/// +/// Verifies the live-edit compose seam () admits a Galaxy +/// alias tag — an equipment-scoped (non-null ) +/// bound to a GalaxyMxGateway driver that lives in a SystemPlatform-kind namespace. +/// The broadened equipment-tag filter must surface the alias under +/// (carrying its driver-side FullName) while a +/// sibling SystemPlatform mirror tag (null EquipmentId) on the same driver stays in +/// and never double-counts. +/// +public sealed class Phase7ComposerAliasTagTests +{ + /// A GalaxyMxGateway driver in a SystemPlatform namespace carries two tags: an + /// equipment-scoped alias (EquipmentId set, FolderPath null, TagConfig FullName = the Galaxy ref) + /// and a SystemPlatform mirror (EquipmentId null, FolderPath set). Compose must put the alias in + /// EquipmentTags with its FullName and keep the mirror in GalaxyTags only. + [Fact] + public void Compose_admits_galaxy_alias_tag_in_equipment_tags() + { + var ns = new Namespace + { + NamespaceId = "ns-sp", + ClusterId = "c1", + Kind = NamespaceKind.SystemPlatform, + NamespaceUri = "urn:sp", + }; + var driver = new DriverInstance + { + DriverInstanceId = "drv-galaxy", + ClusterId = "c1", + NamespaceId = "ns-sp", + Name = "Galaxy1", + DriverType = "GalaxyMxGateway", + DriverConfig = "{}", + }; + var area = new UnsArea { UnsAreaId = "area-1", ClusterId = "c1", Name = "filling" }; + var line = new UnsLine { UnsLineId = "line-1", UnsAreaId = "area-1", Name = "line-1" }; + var equip = new Equipment + { + EquipmentId = "eq-1", + DriverInstanceId = "drv-galaxy", + UnsLineId = "line-1", + Name = "TestMachine_020", + MachineCode = "TESTMACHINE_020", + }; + + // (a) Equipment-scoped alias: EquipmentId set, FolderPath null, FullName = Galaxy ref. + var aliasTag = new Tag + { + TagId = "tag-alias", + DriverInstanceId = "drv-galaxy", + EquipmentId = "eq-1", + FolderPath = null, + Name = "TestChangingInt", + DataType = "Int32", + AccessLevel = TagAccessLevel.Read, + TagConfig = "{\"FullName\":\"TestMachine_020.TestChangingInt\"}", + }; + + // (b) SystemPlatform mirror: EquipmentId null, FolderPath set → stays a Galaxy tag. + var mirrorTag = new Tag + { + TagId = "tag-mirror", + DriverInstanceId = "drv-galaxy", + EquipmentId = null, + FolderPath = "TestMachine_020", + Name = "Speed", + DataType = "Float", + AccessLevel = TagAccessLevel.Read, + TagConfig = "{\"FullName\":\"TestMachine_020.Speed\"}", + }; + + var result = Phase7Composer.Compose( + new[] { area }, new[] { line }, new[] { equip }, + new[] { driver }, Array.Empty(), + new[] { aliasTag, mirrorTag }, new[] { ns }); + + // (a) the alias survives composition as an equipment tag, carrying its Galaxy FullName. + var alias = result.EquipmentTags.ShouldHaveSingleItem(); + alias.TagId.ShouldBe("tag-alias"); + alias.EquipmentId.ShouldBe("eq-1"); + alias.DriverInstanceId.ShouldBe("drv-galaxy"); + alias.Name.ShouldBe("TestChangingInt"); + alias.DataType.ShouldBe("Int32"); + alias.FullName.ShouldBe("TestMachine_020.TestChangingInt"); + + // (b) the SystemPlatform mirror stays in GalaxyTags and is NOT double-counted as equipment. + result.GalaxyTags.ShouldContain(g => g.TagId == "tag-mirror"); + result.GalaxyTags.ShouldNotContain(g => g.TagId == "tag-alias"); + result.EquipmentTags.ShouldNotContain(t => t.TagId == "tag-mirror"); + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactAliasParityTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactAliasParityTests.cs new file mode 100644 index 00000000..5e619e16 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactAliasParityTests.cs @@ -0,0 +1,96 @@ +using System.Text.Json; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Runtime.Drivers; + +namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers; + +/// +/// Verifies the artifact-decode mirror () +/// admits a Galaxy alias tag — an equipment-scoped tag (non-null EquipmentId) bound to a +/// GalaxyMxGateway driver in a SystemPlatform-kind namespace — into the decoded +/// EquipmentTags with byte-parity to the live-edit composer path: same FullName, EquipmentId, +/// DriverInstanceId, Name, DataType. The composer broadens the same filter by DriverType, so both +/// data-contract sites must agree on which tags qualify. +/// +public sealed class DeploymentArtifactAliasParityTests +{ + /// An artifact JSON blob with a GalaxyMxGateway driver in a SystemPlatform (Kind=1) + /// namespace and one equipment-scoped alias tag (EquipmentId set, FolderPath null, FullName = the + /// Galaxy ref). Decode must surface the alias in EquipmentTags carrying its driver-side FullName. + [Fact] + public void ParseComposition_admits_galaxy_alias_tag_in_equipment_tags() + { + var blob = JsonSerializer.SerializeToUtf8Bytes(new + { + Namespaces = new[] + { + new { NamespaceId = "ns-sp", Kind = 1 }, // NamespaceKind.SystemPlatform + }, + DriverInstances = new[] + { + new { DriverInstanceId = "drv-galaxy", DriverType = "GalaxyMxGateway", DriverConfig = "{}", NamespaceId = "ns-sp" }, + }, + Tags = new object[] + { + new + { + TagId = "tag-alias", + DriverInstanceId = "drv-galaxy", + EquipmentId = "eq-1", + Name = "TestChangingInt", + FolderPath = (string?)null, + DataType = "Int32", + TagConfig = "{\"FullName\":\"TestMachine_020.TestChangingInt\"}", + }, + }, + }); + + var c = DeploymentArtifact.ParseComposition(blob); + + var alias = c.EquipmentTags.ShouldHaveSingleItem(); + alias.TagId.ShouldBe("tag-alias"); + alias.EquipmentId.ShouldBe("eq-1"); + alias.DriverInstanceId.ShouldBe("drv-galaxy"); + alias.Name.ShouldBe("TestChangingInt"); + alias.DataType.ShouldBe("Int32"); + alias.FolderPath.ShouldBe(string.Empty); + alias.FullName.ShouldBe("TestMachine_020.TestChangingInt"); + } + + /// An equipment-scoped tag bound to a non-Galaxy driver in a SystemPlatform namespace is + /// NOT a Galaxy alias and must stay excluded from EquipmentTags — the broadened clause keys on the + /// GalaxyMxGateway DriverType, not on the namespace kind, so the contract narrows correctly. + [Fact] + public void ParseComposition_excludes_non_galaxy_systemplatform_equipment_tag() + { + var blob = JsonSerializer.SerializeToUtf8Bytes(new + { + Namespaces = new[] + { + new { NamespaceId = "ns-sp", Kind = 1 }, // NamespaceKind.SystemPlatform + }, + DriverInstances = new[] + { + new { DriverInstanceId = "drv-modbus", DriverType = "Modbus", DriverConfig = "{}", NamespaceId = "ns-sp" }, + }, + Tags = new object[] + { + new + { + TagId = "tag-x", + DriverInstanceId = "drv-modbus", + EquipmentId = "eq-1", + Name = "Source", + FolderPath = (string?)null, + DataType = "Int32", + TagConfig = "{\"FullName\":\"TestMachine_020.Source\"}", + }, + }, + }); + + var c = DeploymentArtifact.ParseComposition(blob); + + c.EquipmentTags.ShouldBeEmpty(); + } +}