From 95be607a0758227f26638288c93e1c885778b6a2 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 12 Jun 2026 21:45:19 -0400 Subject: [PATCH] feat(opcua): remove SystemPlatform-mirror GalaxyTags contract end-to-end (composer+applier+artifact, byte-parity) --- .../ScriptAnalysis/IScriptTagCatalog.cs | 5 +- .../Phase7Applier.cs | 56 ++------- .../Phase7Composer.cs | 61 ++------- .../Phase7Plan.cs | 15 +-- .../Drivers/DeploymentArtifact.cs | 112 ++--------------- .../Drivers/DriverHostActor.cs | 8 +- .../OpcUa/OpcUaPublishActor.cs | 7 +- .../Phase7ApplierHierarchyTests.cs | 17 +-- .../Phase7ApplierTests.cs | 116 ++---------------- .../Phase7ComposerAliasTagTests.cs | 15 ++- .../DeploymentArtifactAliasParityTests.cs | 109 ++++++++-------- .../Drivers/DeploymentArtifactTests.cs | 29 +++-- .../OpcUa/OpcUaPublishActorRebuildTests.cs | 48 +++++--- 13 files changed, 167 insertions(+), 431 deletions(-) diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/IScriptTagCatalog.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/IScriptTagCatalog.cs index 669601e4..e5411e87 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/IScriptTagCatalog.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/IScriptTagCatalog.cs @@ -59,9 +59,8 @@ public sealed record ScriptTagInfo(string Path, string Kind, string DataType, st /// /// Equipment driver tag (EquipmentId != null) → the driver FullName /// extracted from Tag.TagConfig (the verified DependencyMux key). -/// SystemPlatform tag (EquipmentId == null) → the MXAccess dot-ref -/// (FolderPath.Name when a folder is set, else Name) — see -/// Phase7Composer's GalaxyTagPlan.MxAccessRef. +/// Galaxy points are ordinary equipment tags now (GalaxyMxGateway is a standard +/// Equipment-kind driver), so they resolve by this same FullName key. /// VirtualTag → its leaf Name only, as a BEST-EFFORT key (the live resolution /// of virtual-tag cascade/write targets is unconfirmed). /// diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Applier.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Applier.cs index 738f66c1..7e3c64da 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Applier.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Applier.cs @@ -70,22 +70,20 @@ public sealed class Phase7Applier var changedCount = plan.ChangedEquipment.Count + plan.ChangedDrivers.Count + plan.ChangedAlarms.Count + - plan.ChangedGalaxyTags.Count + plan.ChangedEquipmentTags.Count + + plan.ChangedEquipmentTags.Count + plan.ChangedEquipmentVirtualTags.Count; var addedCount = plan.AddedEquipment.Count + plan.AddedDrivers.Count + plan.AddedAlarms.Count + - plan.AddedGalaxyTags.Count + plan.AddedEquipmentTags.Count + + plan.AddedEquipmentTags.Count + plan.AddedEquipmentVirtualTags.Count; - // Any add/remove of Equipment, ScriptedAlarm, Galaxy tag, Equipment tag, or Equipment - // VirtualTag topology requires a real address-space rebuild. Driver-instance changes don't - // touch the address-space topology directly — they go through DriverHostActor's spawn-plan - // in Runtime. + // Any add/remove of Equipment, ScriptedAlarm, Equipment tag, or Equipment VirtualTag topology + // requires a real address-space rebuild. Driver-instance changes don't touch the address-space + // topology directly — they go through DriverHostActor's spawn-plan in Runtime. // TODO(equipment-virtualtags): when MaterialiseEquipmentVirtualTags drives per-delta sink work, revisit whether ChangedEquipmentVirtualTags should also force needsRebuild. var needsRebuild = plan.AddedEquipment.Count > 0 || plan.RemovedEquipment.Count > 0 || plan.AddedAlarms.Count > 0 || plan.RemovedAlarms.Count > 0 || - plan.AddedGalaxyTags.Count > 0 || plan.RemovedGalaxyTags.Count > 0 || plan.AddedEquipmentTags.Count > 0 || plan.RemovedEquipmentTags.Count > 0 || plan.AddedEquipmentVirtualTags.Count > 0 || plan.RemovedEquipmentVirtualTags.Count > 0; @@ -141,46 +139,8 @@ public sealed class Phase7Applier } /// - /// Materialise Galaxy / SystemPlatform-namespace tags from a composition snapshot: - /// for each , ensure its FolderPath segment exists (a folder - /// under the namespace root), then ensure a Variable node sits inside that folder for - /// the leaf . Variable starts with BadWaitingForInitialData; - /// the Galaxy driver's OnDataChange path fills the value in once SubscribeBulk lands. - /// Idempotent. - /// - /// The composition result containing the Galaxy tags to materialise. - public void MaterialiseGalaxyTags(Phase7CompositionResult composition) - { - ArgumentNullException.ThrowIfNull(composition); - if (composition.GalaxyTags.Count == 0) return; - - // Folders first — each distinct FolderPath becomes one folder under the root. - var foldersCreated = new HashSet(StringComparer.Ordinal); - foreach (var tag in composition.GalaxyTags) - { - if (string.IsNullOrWhiteSpace(tag.FolderPath)) continue; - if (!foldersCreated.Add(tag.FolderPath)) continue; - SafeEnsureFolder(tag.FolderPath, parentNodeId: null, displayName: tag.FolderPath); - } - - // Variables: NodeId is "." so it matches the MXAccess ref the - // Galaxy driver subscribes to. Browse-path lookup via OPC UA Translate is the canonical - // resolution; flat NodeId keeps the address space lookup cheap. - foreach (var tag in composition.GalaxyTags) - { - var nodeId = string.IsNullOrWhiteSpace(tag.FolderPath) ? tag.DisplayName : tag.MxAccessRef; - var parent = string.IsNullOrWhiteSpace(tag.FolderPath) ? null : tag.FolderPath; - SafeEnsureVariable(nodeId, parent, tag.DisplayName, tag.DataType); - } - - _logger.LogInformation( - "Phase7Applier: Galaxy tags materialised (tags={Tags}, folders={Folders})", - composition.GalaxyTags.Count, foldersCreated.Count); - } - - /// - /// Materialise Equipment-namespace tags from a composition snapshot — the equipment-signal - /// analogue of . For each , + /// Materialise Equipment-namespace tags from a composition snapshot. + /// For each , /// ensure its optional FolderPath sub-folder under the existing equipment folder, then /// ensure a Variable (NodeId = FullName, the driver-side ref) inside it. Variables /// start BadWaitingForInitialData; the driver fills live values in a later milestone. @@ -222,7 +182,7 @@ public sealed class Phase7Applier // would collide in the sink (EnsureVariable keys on NodeId) and drop all but one machine's // signal. The driver-side FullName lives on EquipmentTagPlan for the later values milestone to // route by. Parent is the FolderPath sub-folder when set, else the equipment folder directly. - // Like the Galaxy pass, per-variable idempotency relies on the sink's own EnsureVariable. + // Per-variable idempotency relies on the sink's own EnsureVariable. foreach (var tag in composition.EquipmentTags) { var parent = string.IsNullOrWhiteSpace(tag.FolderPath) diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs index a0189cab..79da6bd1 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs @@ -9,18 +9,15 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer; /// Outcome of — pure value tuple, no side effects. /// + carry the UNS topology so the applier can /// materialise the Area/Line/Equipment folder hierarchy in the address space; equipment carries -/// its parent line id so the applier knows where to hang each equipment folder. -/// carries SystemPlatform-namespace tags (Galaxy hierarchy) so the -/// applier can materialise their FolderPath + Variable nodes ahead of any driver subscribe. +/// its parent line id so the applier knows where to hang each equipment folder. public sealed record Phase7CompositionResult( IReadOnlyList UnsAreas, IReadOnlyList UnsLines, IReadOnlyList EquipmentNodes, IReadOnlyList DriverInstancePlans, - IReadOnlyList ScriptedAlarmPlans, - IReadOnlyList GalaxyTags) + IReadOnlyList ScriptedAlarmPlans) { - /// Convenience constructor for tests + earlier callers that don't carry UNS or Galaxy data. + /// Convenience constructor for tests + earlier callers that don't carry UNS data. /// The equipment nodes. /// The driver instance plans. /// The scripted alarm plans. @@ -29,33 +26,17 @@ public sealed record Phase7CompositionResult( IReadOnlyList driverInstancePlans, IReadOnlyList scriptedAlarmPlans) : this(Array.Empty(), Array.Empty(), - equipmentNodes, driverInstancePlans, scriptedAlarmPlans, Array.Empty()) - { - } - - /// Convenience constructor for callers carrying UNS but not Galaxy data. - /// The UNS areas. - /// The UNS lines. - /// The equipment nodes. - /// The driver instance plans. - /// The scripted alarm plans. - public Phase7CompositionResult( - IReadOnlyList unsAreas, - IReadOnlyList unsLines, - IReadOnlyList equipmentNodes, - IReadOnlyList driverInstancePlans, - IReadOnlyList scriptedAlarmPlans) - : this(unsAreas, unsLines, equipmentNodes, driverInstancePlans, scriptedAlarmPlans, Array.Empty()) + equipmentNodes, driverInstancePlans, scriptedAlarmPlans) { } /// /// Equipment-namespace tags — a with non-null - /// in an Equipment-kind namespace. Mirror of for the UNS - /// equipment-signal path: Phase7Applier.MaterialiseEquipmentTags materialises each as - /// a Variable under its existing equipment folder. Declared as an init-only member defaulting - /// to empty (rather than a 7th positional parameter) so every existing convenience - /// constructor + call site keeps compiling unchanged; new producers set it via initializer. + /// in an Equipment-kind namespace. Phase7Applier.MaterialiseEquipmentTags + /// materialises each as a Variable under its existing equipment folder. Declared as an + /// init-only member defaulting to empty (rather than a positional parameter) so every existing + /// convenience constructor + call site keeps compiling unchanged; new producers set it via + /// initializer. /// public IReadOnlyList EquipmentTags { get; init; } = Array.Empty(); @@ -81,21 +62,6 @@ public sealed record EquipmentNode(string EquipmentId, string DisplayName, strin public sealed record DriverInstancePlan(string DriverInstanceId, string DriverType, string ConfigJson); public sealed record ScriptedAlarmPlan(string ScriptedAlarmId, string EquipmentId, string PredicateScriptId, string MessageTemplate); -/// -/// One Galaxy / SystemPlatform-namespace tag from a row where -/// is null. Carries the FolderPath segment that the applier -/// turns into a folder, the leaf for the Variable, the OPC UA -/// , and the dot-form MXAccess reference () -/// that the Galaxy driver consumes when subscribing. -/// -public sealed record GalaxyTagPlan( - string TagId, - string DriverInstanceId, - string FolderPath, - string DisplayName, - string DataType, - string MxAccessRef); - /// /// One Equipment-namespace tag from a row whose /// is non-null and whose owning driver's namespace is Equipment-kind. Carries the stable @@ -105,8 +71,7 @@ public sealed record GalaxyTagPlan( /// , and the driver-side reference (extracted from /// Tag.TagConfig) the later values milestone routes reads/writes by. The variable's NodeId /// is folder-scoped (parent/Name), NOT , because a raw driver ref -/// (e.g. a Modbus register) is not unique across identical machines. The equipment-signal -/// analogue of . +/// (e.g. a Modbus register) is not unique across identical machines. /// public sealed record EquipmentTagPlan( string TagId, @@ -253,7 +218,7 @@ public sealed record EquipmentScriptedAlarmPlan( /// public static class Phase7Composer { - /// Convenience overload for legacy callers + tests that don't yet supply UNS / Galaxy data. + /// Convenience overload for legacy callers + tests that don't supply UNS topology or tags. /// The equipment. /// The driver instances. /// The scripted alarms. @@ -265,7 +230,7 @@ public static class Phase7Composer Compose(Array.Empty(), Array.Empty(), equipment, driverInstances, scriptedAlarms, Array.Empty(), Array.Empty()); - /// UNS-aware overload that doesn't yet supply Galaxy tags. + /// UNS-aware overload that doesn't supply tags. /// The UNS areas. /// The UNS lines. /// The equipment. @@ -440,7 +405,7 @@ public static class Phase7Composer Enabled: a.Enabled)); } - return new Phase7CompositionResult(areas, lines, nodes, plans, alarms, Array.Empty()) + return new Phase7CompositionResult(areas, lines, nodes, plans, alarms) { EquipmentTags = equipmentTags, EquipmentVirtualTags = equipmentVirtualTags, diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Plan.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Plan.cs index 84ded60b..0641f75d 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Plan.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Plan.cs @@ -21,10 +21,7 @@ public sealed record Phase7Plan( IReadOnlyList ChangedDrivers, IReadOnlyList AddedAlarms, IReadOnlyList RemovedAlarms, - IReadOnlyList ChangedAlarms, - IReadOnlyList AddedGalaxyTags, - IReadOnlyList RemovedGalaxyTags, - IReadOnlyList ChangedGalaxyTags) + IReadOnlyList ChangedAlarms) { /// /// Equipment-namespace tag diff sets, keyed by . Added as @@ -60,14 +57,12 @@ public sealed record Phase7Plan( AddedEquipment.Count == 0 && RemovedEquipment.Count == 0 && ChangedEquipment.Count == 0 && AddedDrivers.Count == 0 && RemovedDrivers.Count == 0 && ChangedDrivers.Count == 0 && AddedAlarms.Count == 0 && RemovedAlarms.Count == 0 && ChangedAlarms.Count == 0 && - AddedGalaxyTags.Count == 0 && RemovedGalaxyTags.Count == 0 && ChangedGalaxyTags.Count == 0 && AddedEquipmentTags.Count == 0 && RemovedEquipmentTags.Count == 0 && ChangedEquipmentTags.Count == 0 && AddedEquipmentVirtualTags.Count == 0 && RemovedEquipmentVirtualTags.Count == 0 && ChangedEquipmentVirtualTags.Count == 0; public sealed record EquipmentDelta(EquipmentNode Previous, EquipmentNode Current); public sealed record DriverDelta(DriverInstancePlan Previous, DriverInstancePlan Current); public sealed record AlarmDelta(ScriptedAlarmPlan Previous, ScriptedAlarmPlan Current); - public sealed record GalaxyTagDelta(GalaxyTagPlan Previous, GalaxyTagPlan Current); public sealed record EquipmentTagDelta(EquipmentTagPlan Previous, EquipmentTagPlan Current); public sealed record EquipmentVirtualTagDelta(EquipmentVirtualTagPlan Previous, EquipmentVirtualTagPlan Current); } @@ -102,11 +97,6 @@ public static class Phase7Planner a => a.ScriptedAlarmId, (a, b) => new Phase7Plan.AlarmDelta(a, b)); - var (addedGalaxy, removedGalaxy, changedGalaxy) = DiffById( - previous.GalaxyTags, next.GalaxyTags, - t => t.TagId, - (a, b) => new Phase7Plan.GalaxyTagDelta(a, b)); - var (addedEqTags, removedEqTags, changedEqTags) = DiffById( previous.EquipmentTags, next.EquipmentTags, t => t.TagId, @@ -125,8 +115,7 @@ public static class Phase7Planner return new Phase7Plan( addedEq, removedEq, changedEq, addedDrv, removedDrv, changedDrv, - addedAlarm, removedAlarm, changedAlarm, - addedGalaxy, removedGalaxy, changedGalaxy) + addedAlarm, removedAlarm, changedAlarm) { AddedEquipmentTags = addedEqTags, RemovedEquipmentTags = removedEqTags, 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 7b5d0394..50ae20d0 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs @@ -192,12 +192,11 @@ public static class DeploymentArtifact var equipment = ReadArray(root, "Equipment", ReadEquipmentNode); var drivers = ReadArray(root, "DriverInstances", ReadDriverPlan); var alarms = ReadArray(root, "ScriptedAlarms", ReadAlarmPlan); - var galaxyTags = BuildGalaxyTagPlans(root, drivers); var equipmentTags = BuildEquipmentTagPlans(root); var equipmentVirtualTags = BuildEquipmentVirtualTagPlans(root, equipmentTags); var equipmentScriptedAlarms = BuildEquipmentScriptedAlarmPlans(root); - return new Phase7CompositionResult(areas, lines, equipment, drivers, alarms, galaxyTags) + return new Phase7CompositionResult(areas, lines, equipment, drivers, alarms) { EquipmentTags = equipmentTags, EquipmentVirtualTags = equipmentVirtualTags, @@ -258,8 +257,7 @@ public static class DeploymentArtifact keptLines, keptEquipment, full.DriverInstancePlans.Where(d => sets.DriverIds.Contains(d.DriverInstanceId)).ToArray(), - full.ScriptedAlarmPlans.Where(a => sets.EquipmentIds.Contains(a.EquipmentId)).ToArray(), - full.GalaxyTags.Where(t => sets.DriverIds.Contains(t.DriverInstanceId)).ToArray()) + full.ScriptedAlarmPlans.Where(a => sets.EquipmentIds.Contains(a.EquipmentId)).ToArray()) { EquipmentTags = full.EquipmentTags.Where(t => sets.DriverIds.Contains(t.DriverInstanceId)).ToArray(), EquipmentVirtualTags = full.EquipmentVirtualTags.Where(v => sets.EquipmentIds.Contains(v.EquipmentId)).ToArray(), @@ -354,95 +352,15 @@ public static class DeploymentArtifact Array.Empty(), Array.Empty(), Array.Empty(), - Array.Empty(), - Array.Empty()); - - /// - /// Cross-reference the artifact's Tags + Namespaces + DriverInstances arrays to find - /// SystemPlatform-namespace tags (Galaxy hierarchy), then emit one - /// per qualifying tag. Mirrors Phase7Composer.Compose's filter so a compose-side - /// plan and an artifact-decode plan agree on the same set of tags. - /// - private static IReadOnlyList BuildGalaxyTagPlans(JsonElement root, IReadOnlyList drivers) - { - if (!root.TryGetProperty("Tags", out var tagsArr) || tagsArr.ValueKind != JsonValueKind.Array) - return Array.Empty(); - if (!root.TryGetProperty("Namespaces", out var nsArr) || nsArr.ValueKind != JsonValueKind.Array) - return Array.Empty(); - if (!root.TryGetProperty("DriverInstances", out var diArr) || diArr.ValueKind != JsonValueKind.Array) - return Array.Empty(); - - // namespaceId → Kind ("SystemPlatform"/"Equipment"/"Simulated") — enum serialises as int by default, - // but ConfigComposer's snapshot uses default JsonSerializer which writes numbers. Tolerate both. - var systemPlatformNamespaces = new HashSet(StringComparer.Ordinal); - foreach (var el in nsArr.EnumerateArray()) - { - if (el.ValueKind != JsonValueKind.Object) continue; - var id = el.TryGetProperty("NamespaceId", out var idEl) ? idEl.GetString() : null; - if (string.IsNullOrWhiteSpace(id)) continue; - if (!el.TryGetProperty("Kind", out var kindEl)) continue; - var isSystemPlatform = kindEl.ValueKind switch - { - JsonValueKind.Number => kindEl.GetInt32() == 1, // NamespaceKind.SystemPlatform = 1 - JsonValueKind.String => string.Equals(kindEl.GetString(), "SystemPlatform", StringComparison.Ordinal), - _ => false, - }; - if (isSystemPlatform) systemPlatformNamespaces.Add(id!); - } - - // driverInstanceId → namespaceId - var driverToNamespace = new Dictionary(StringComparer.Ordinal); - foreach (var el in diArr.EnumerateArray()) - { - if (el.ValueKind != JsonValueKind.Object) continue; - var id = el.TryGetProperty("DriverInstanceId", out var idEl) ? idEl.GetString() : null; - var ns = el.TryGetProperty("NamespaceId", out var nsEl) ? nsEl.GetString() : null; - if (!string.IsNullOrWhiteSpace(id) && !string.IsNullOrWhiteSpace(ns)) - driverToNamespace[id!] = ns!; - } - - var result = new List(tagsArr.GetArrayLength()); - foreach (var el in tagsArr.EnumerateArray()) - { - if (el.ValueKind != JsonValueKind.Object) continue; - // Skip tags with non-null EquipmentId (Equipment-namespace tags belong to a different path). - if (el.TryGetProperty("EquipmentId", out var eqEl) && eqEl.ValueKind != JsonValueKind.Null) continue; - - var tagId = el.TryGetProperty("TagId", out var tEl) ? tEl.GetString() : null; - var di = el.TryGetProperty("DriverInstanceId", out var diEl) ? diEl.GetString() : null; - var name = el.TryGetProperty("Name", out var nmEl) ? nmEl.GetString() : null; - var folder = el.TryGetProperty("FolderPath", out var fpEl) && fpEl.ValueKind != JsonValueKind.Null - ? fpEl.GetString() : null; - var dataType = el.TryGetProperty("DataType", out var dtEl) ? dtEl.GetString() : null; - - if (string.IsNullOrWhiteSpace(tagId) || string.IsNullOrWhiteSpace(di) || string.IsNullOrWhiteSpace(name)) - continue; - if (!driverToNamespace.TryGetValue(di!, out var nsId)) continue; - if (!systemPlatformNamespaces.Contains(nsId)) continue; - - var folderPath = folder ?? string.Empty; - var mxRef = string.IsNullOrWhiteSpace(folderPath) ? name! : $"{folderPath}.{name}"; - result.Add(new GalaxyTagPlan(tagId!, di!, folderPath, name!, dataType ?? "BaseDataType", mxRef)); - } - - result.Sort((a, b) => - { - var byDriver = string.CompareOrdinal(a.DriverInstanceId, b.DriverInstanceId); - if (byDriver != 0) return byDriver; - var byFolder = string.CompareOrdinal(a.FolderPath, b.FolderPath); - if (byFolder != 0) return byFolder; - return string.CompareOrdinal(a.DisplayName, b.DisplayName); - }); - return result; - } + Array.Empty()); /// /// Cross-reference the artifact's Tags + Namespaces + DriverInstances arrays to find /// Equipment-namespace tags (non-null EquipmentId, owning namespace Kind == Equipment), then /// emit one per qualifying tag. The artifact-decode mirror of - /// Phase7Composer.Compose's equipment filter — the inverse of - /// — so the compose-side + artifact-decode plans agree on the same set of tags. FullName is - /// read from each tag's TagConfig blob (top-level "FullName" field). + /// Phase7Composer.Compose's equipment filter — so the compose-side + artifact-decode + /// plans agree on the same set of tags. FullName is read from each tag's TagConfig blob + /// (top-level "FullName" field). /// private static IReadOnlyList BuildEquipmentTagPlans(JsonElement root) { @@ -454,7 +372,7 @@ public static class DeploymentArtifact return Array.Empty(); // namespaceId → Equipment-kind. Kind serialises as a number by default (Equipment = 0); - // tolerate the string form too (matches BuildGalaxyTagPlans's number/string handling). + // tolerate the string form too. var equipmentNamespaces = new HashSet(StringComparer.Ordinal); foreach (var el in nsArr.EnumerateArray()) { @@ -471,11 +389,8 @@ public static class DeploymentArtifact if (isEquipment) equipmentNamespaces.Add(id!); } - // 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. + // driverInstanceId → namespaceId. var driverToNamespace = new Dictionary(StringComparer.Ordinal); - var driverToType = new Dictionary(StringComparer.Ordinal); foreach (var el in diArr.EnumerateArray()) { if (el.ValueKind != JsonValueKind.Object) continue; @@ -483,9 +398,6 @@ 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()); @@ -508,10 +420,10 @@ public static class DeploymentArtifact if (string.IsNullOrWhiteSpace(tagId) || string.IsNullOrWhiteSpace(di) || string.IsNullOrWhiteSpace(name)) continue; if (!driverToNamespace.TryGetValue(di!, out var 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; + // Equipment-kind namespace only — byte-parity with the composer's pure + // `ns.Kind == NamespaceKind.Equipment` predicate (no Galaxy exception). Galaxy points are + // ordinary equipment tags now (GalaxyMxGateway is a standard Equipment-kind driver). + if (!equipmentNamespaces.Contains(nsId)) continue; result.Add(new EquipmentTagPlan( TagId: tagId!, diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs index 6e2247b6..f71b3a08 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs @@ -547,8 +547,8 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers } /// - /// SubscribeBulk pass. After an apply, read the deployment's SystemPlatform / Galaxy tags, - /// group their dot-form MXAccess references by driver instance, and hand each running driver + /// SubscribeBulk pass. After an apply, read the deployment's Equipment-namespace tags, + /// group their driver-side FullName references by driver instance, and hand each running driver /// child its desired subscription set via . /// The child retains the set and (re)subscribes on every Connected entry, so values stream into /// the OPC UA sink and resume after reconnects. Drivers with no configured tags get an empty set @@ -582,11 +582,11 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers return; } - var refsByDriver = composition.GalaxyTags + var refsByDriver = composition.EquipmentTags .GroupBy(t => t.DriverInstanceId, StringComparer.Ordinal) .ToDictionary( g => g.Key, - g => (IReadOnlyList)g.Select(t => t.MxAccessRef) + g => (IReadOnlyList)g.Select(t => t.FullName) .Distinct(StringComparer.Ordinal) .ToArray(), StringComparer.Ordinal); diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs index a6deca75..abde4509 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs @@ -63,8 +63,7 @@ public sealed class OpcUaPublishActor : ReceiveActor Array.Empty(), Array.Empty(), Array.Empty(), - Array.Empty(), - Array.Empty()); + Array.Empty()); /// Gets the number of writes performed. public int WriteCount => _writes; @@ -242,10 +241,6 @@ public sealed class OpcUaPublishActor : ReceiveActor // nodes (keyed by ScriptedAlarmId so AlarmStateUpdate writes target them); disabled // alarms are skipped. _applier.MaterialiseScriptedAlarms(composition); - // Galaxy / SystemPlatform tags get their own pass: ensures their FolderPath folder - // + Variable node exist so clients can browse them. The Galaxy driver fills values - // on a future SubscribeBulk pass; until then variables show BadWaitingForInitialData. - _applier.MaterialiseGalaxyTags(composition); // Equipment-namespace tags get their own pass: ensures each signal's Variable (and any // FolderPath sub-folder) exists under its already-materialised equipment folder so // clients can browse them. Live values arrive in a later milestone; until then the diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ApplierHierarchyTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ApplierHierarchyTests.cs index 5d7b23ef..2a51c508 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ApplierHierarchyTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ApplierHierarchyTests.cs @@ -36,8 +36,7 @@ public sealed class Phase7ApplierHierarchyTests : IDisposable UnsLines: new[] { new UnsLineProjection("line-1", "area-1", "Cell A") }, EquipmentNodes: new[] { new EquipmentNode("eq-1", "Pump-1", "line-1") }, DriverInstancePlans: Array.Empty(), - ScriptedAlarmPlans: Array.Empty(), - GalaxyTags: Array.Empty()); + ScriptedAlarmPlans: Array.Empty()); applier.MaterialiseHierarchy(composition); @@ -60,8 +59,7 @@ public sealed class Phase7ApplierHierarchyTests : IDisposable UnsLines: Array.Empty(), EquipmentNodes: new[] { new EquipmentNode("eq-orphan", "Orphan", UnsLineId: "") }, DriverInstancePlans: Array.Empty(), - ScriptedAlarmPlans: Array.Empty(), - GalaxyTags: Array.Empty()); + ScriptedAlarmPlans: Array.Empty()); applier.MaterialiseHierarchy(composition); @@ -95,8 +93,7 @@ public sealed class Phase7ApplierHierarchyTests : IDisposable UnsLines: new[] { new UnsLineProjection("line-1", "area-A", "Line 1") }, EquipmentNodes: new[] { new EquipmentNode("eq-1", "Eq 1", "line-1"), new EquipmentNode("eq-2", "Eq 2", "line-1") }, DriverInstancePlans: Array.Empty(), - ScriptedAlarmPlans: Array.Empty(), - GalaxyTags: Array.Empty())); + ScriptedAlarmPlans: Array.Empty())); sdkServer.NodeManager!.FolderCount.ShouldBe(5); // 2 areas + 1 line + 2 equipment @@ -106,8 +103,7 @@ public sealed class Phase7ApplierHierarchyTests : IDisposable UnsLines: new[] { new UnsLineProjection("line-1", "area-A", "Line 1") }, EquipmentNodes: new[] { new EquipmentNode("eq-1", "Eq 1", "line-1"), new EquipmentNode("eq-2", "Eq 2", "line-1") }, DriverInstancePlans: Array.Empty(), - ScriptedAlarmPlans: Array.Empty(), - GalaxyTags: Array.Empty())); + ScriptedAlarmPlans: Array.Empty())); sdkServer.NodeManager!.FolderCount.ShouldBe(5); } @@ -143,8 +139,7 @@ public sealed class Phase7ApplierHierarchyTests : IDisposable UnsLines: Array.Empty(), EquipmentNodes: new[] { new EquipmentNode("eq-1", "filling-eq", UnsLineId: "") }, DriverInstancePlans: Array.Empty(), - ScriptedAlarmPlans: Array.Empty(), - GalaxyTags: Array.Empty()) + ScriptedAlarmPlans: Array.Empty()) { EquipmentTags = new[] { @@ -201,7 +196,7 @@ public sealed class Phase7ApplierHierarchyTests : IDisposable new[] { area }, new[] { line }, new[] { equipment }, new[] { driver }, Array.Empty(), new[] { tag }, new[] { ns }); - // Compose-side EquipmentTags extraction (the inverse of the Galaxy filter). + // Compose-side EquipmentTags extraction. var planned = composition.EquipmentTags.ShouldHaveSingleItem(); planned.EquipmentId.ShouldBe("eq-1"); planned.FullName.ShouldBe("40001"); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ApplierTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ApplierTests.cs index 387ba14e..6820cd42 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ApplierTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ApplierTests.cs @@ -59,10 +59,7 @@ public sealed class Phase7ApplierTests ChangedDrivers: Array.Empty(), AddedAlarms: Array.Empty(), RemovedAlarms: Array.Empty(), - ChangedAlarms: Array.Empty(), - AddedGalaxyTags: Array.Empty(), - RemovedGalaxyTags: Array.Empty(), - ChangedGalaxyTags: Array.Empty()); + ChangedAlarms: Array.Empty()); var outcome = applier.Apply(plan); @@ -93,10 +90,7 @@ public sealed class Phase7ApplierTests }, AddedAlarms: Array.Empty(), RemovedAlarms: Array.Empty(), - ChangedAlarms: Array.Empty(), - AddedGalaxyTags: Array.Empty(), - RemovedGalaxyTags: Array.Empty(), - ChangedGalaxyTags: Array.Empty()); + ChangedAlarms: Array.Empty()); var outcome = applier.Apply(plan); @@ -118,66 +112,6 @@ public sealed class Phase7ApplierTests outcome.RebuildCalled.ShouldBeTrue(); } - /// Verifies MaterialiseGalaxyTags creates one folder per distinct FolderPath and one - /// variable per tag, with root-level tags hung directly under the namespace root. - [Fact] - public void MaterialiseGalaxyTags_creates_folder_per_distinct_path_and_variable_per_tag() - { - var sink = new RecordingSink(); - var applier = new Phase7Applier(sink, NullLogger.Instance); - - var composition = new Phase7CompositionResult( - UnsAreas: Array.Empty(), - UnsLines: Array.Empty(), - EquipmentNodes: Array.Empty(), - DriverInstancePlans: Array.Empty(), - ScriptedAlarmPlans: Array.Empty(), - GalaxyTags: new[] - { - new GalaxyTagPlan("t1", "drv", "section.area", "Temperature", "Float", "section.area.Temperature"), - new GalaxyTagPlan("t2", "drv", "", "Pressure", "Int32", "Pressure"), - }); - - applier.MaterialiseGalaxyTags(composition); - - // One folder for the single distinct non-empty FolderPath; the root-level tag adds none. - sink.FolderCalls.ShouldHaveSingleItem().ShouldBe(("section.area", null, "section.area")); - - // Foldered tag → NodeId is its MxAccessRef under the FolderPath parent. - // Root-level tag → NodeId is its DisplayName under the root (null parent). - sink.VariableCalls.ShouldContain(("section.area.Temperature", "section.area", "Temperature", "Float")); - sink.VariableCalls.ShouldContain(("Pressure", (string?)null, "Pressure", "Int32")); - sink.VariableCalls.Count.ShouldBe(2); - } - - /// Verifies that two tags sharing a FolderPath produce a single EnsureFolder call - /// (deduped) but one EnsureVariable per tag. - [Fact] - public void MaterialiseGalaxyTags_dedupes_folders_for_tags_sharing_a_path() - { - var sink = new RecordingSink(); - var applier = new Phase7Applier(sink, NullLogger.Instance); - - var composition = new Phase7CompositionResult( - UnsAreas: Array.Empty(), - UnsLines: Array.Empty(), - EquipmentNodes: Array.Empty(), - DriverInstancePlans: Array.Empty(), - ScriptedAlarmPlans: Array.Empty(), - GalaxyTags: new[] - { - new GalaxyTagPlan("t1", "drv", "line.cell", "Speed", "Float", "line.cell.Speed"), - new GalaxyTagPlan("t2", "drv", "line.cell", "Torque", "Float", "line.cell.Torque"), - }); - - applier.MaterialiseGalaxyTags(composition); - - sink.FolderCalls.ShouldHaveSingleItem().ShouldBe(("line.cell", null, "line.cell")); - sink.VariableCalls.Count.ShouldBe(2); - sink.VariableCalls.ShouldContain(("line.cell.Speed", "line.cell", "Speed", "Float")); - sink.VariableCalls.ShouldContain(("line.cell.Torque", "line.cell", "Torque", "Float")); - } - /// Verifies MaterialiseEquipmentTags creates one Variable per equipment tag directly /// under its existing equipment folder, with a folder-scoped NodeId (parent/Name — NOT the raw /// FullName), parent == EquipmentId, displayName == Name, and does NOT re-create the equipment @@ -193,8 +127,7 @@ public sealed class Phase7ApplierTests UnsLines: Array.Empty(), EquipmentNodes: Array.Empty(), DriverInstancePlans: Array.Empty(), - ScriptedAlarmPlans: Array.Empty(), - GalaxyTags: Array.Empty()) + ScriptedAlarmPlans: Array.Empty()) { EquipmentTags = new[] { @@ -345,8 +278,8 @@ public sealed class Phase7ApplierTests } /// Verifies that added equipment tags in an otherwise-empty plan trigger an - /// address-space rebuild (parity with the Galaxy-tag path — the planner now diffs equipment - /// tags, so a tags-only deploy is no longer a silent no-op). + /// address-space rebuild (the planner now diffs equipment tags, so a tags-only deploy is no + /// longer a silent no-op). [Fact] public void Added_equipment_tags_trigger_rebuild() { @@ -393,42 +326,10 @@ public sealed class Phase7ApplierTests sink.RebuildCalls.ShouldBe(1); } - /// Verifies that added Galaxy tags in an otherwise-empty plan trigger an address-space rebuild. - [Fact] - public void Added_galaxy_tags_trigger_rebuild() - { - var sink = new RecordingSink(); - var applier = new Phase7Applier(sink, NullLogger.Instance); - - var plan = new Phase7Plan( - AddedEquipment: Array.Empty(), - RemovedEquipment: Array.Empty(), - ChangedEquipment: Array.Empty(), - AddedDrivers: Array.Empty(), - RemovedDrivers: Array.Empty(), - ChangedDrivers: Array.Empty(), - AddedAlarms: Array.Empty(), - RemovedAlarms: Array.Empty(), - ChangedAlarms: Array.Empty(), - AddedGalaxyTags: new[] - { - new GalaxyTagPlan("t1", "drv", "section.area", "Temperature", "Float", "section.area.Temperature"), - }, - RemovedGalaxyTags: Array.Empty(), - ChangedGalaxyTags: Array.Empty()); - - var outcome = applier.Apply(plan); - - outcome.RebuildCalled.ShouldBeTrue(); - outcome.AddedNodes.ShouldBe(1); - sink.RebuildCalls.ShouldBe(1); - } - private static Phase7Plan EmptyPlan => new( Array.Empty(), Array.Empty(), Array.Empty(), Array.Empty(), Array.Empty(), Array.Empty(), - Array.Empty(), Array.Empty(), Array.Empty(), - Array.Empty(), Array.Empty(), Array.Empty()); + Array.Empty(), Array.Empty(), Array.Empty()); private static Phase7Plan WithEquipmentRemoval(params string[] ids) => new( AddedEquipment: Array.Empty(), @@ -439,10 +340,7 @@ public sealed class Phase7ApplierTests ChangedDrivers: Array.Empty(), AddedAlarms: Array.Empty(), RemovedAlarms: Array.Empty(), - ChangedAlarms: Array.Empty(), - AddedGalaxyTags: Array.Empty(), - RemovedGalaxyTags: Array.Empty(), - ChangedGalaxyTags: Array.Empty()); + ChangedAlarms: Array.Empty()); private sealed class RecordingSink : IOpcUaAddressSpaceSink { diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ComposerAliasTagTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ComposerAliasTagTests.cs index f5ad9e28..da7dcee8 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ComposerAliasTagTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ComposerAliasTagTests.cs @@ -11,16 +11,15 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests; /// Equipment-kind driver. An equipment-scoped (non-null /// ) bound to a GalaxyMxGateway driver living in an /// Equipment-kind namespace must surface under -/// (carrying its driver-side FullName), and -/// the retired SystemPlatform-mirror producer means -/// is always empty. +/// (carrying its driver-side FullName). The +/// SystemPlatform-mirror GalaxyTags contract is retired entirely. /// public sealed class Phase7ComposerAliasTagTests { /// A GalaxyMxGateway driver in an Equipment-kind namespace carries an /// equipment-scoped Galaxy tag (EquipmentId set, FolderPath null, TagConfig FullName = the Galaxy - /// ref). Compose must put it in EquipmentTags with its FullName, and GalaxyTags must be empty - /// (the SystemPlatform mirror producer is gone). + /// ref). Compose must put it in EquipmentTags with its FullName, coalescing the null FolderPath to + /// string.Empty (the SystemPlatform mirror producer is gone entirely). [Fact] public void Compose_admits_galaxy_equipment_tag_in_equipment_tags() { @@ -77,8 +76,8 @@ public sealed class Phase7ComposerAliasTagTests tag.Name.ShouldBe("TestChangingInt"); tag.DataType.ShouldBe("Int32"); tag.FullName.ShouldBe("TestMachine_020.TestChangingInt"); - - // The SystemPlatform-mirror producer is retired → GalaxyTags is always empty. - result.GalaxyTags.ShouldBeEmpty(); + // The input Tag.FolderPath is null; the composer coalesces it to string.Empty (the explicit + // byte-parity null-coalesce the artifact-decode side mirrors). + tag.FolderPath.ShouldBe(string.Empty); } } 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 index 5e619e16..61bafad2 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactAliasParityTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactAliasParityTests.cs @@ -7,19 +7,65 @@ 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. +/// treats a Galaxy point as an ordinary equipment tag — an equipment-scoped tag (non-null +/// EquipmentId) bound to a GalaxyMxGateway driver in an Equipment-kind namespace — +/// into the decoded EquipmentTags with byte-parity to the live-edit composer path: same FullName, +/// EquipmentId, DriverInstanceId, Name, DataType. Both data-contract sites gate purely on the namespace +/// Kind being Equipment (no Galaxy/DriverType exception — the SystemPlatform-mirror contract is +/// retired), so they 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. + /// An artifact JSON blob with a GalaxyMxGateway driver in an Equipment (Kind=0) namespace and + /// one equipment-scoped tag (EquipmentId set, FolderPath null, FullName = the Galaxy ref). Decode must + /// surface the tag in EquipmentTags carrying its driver-side FullName, coalescing the null FolderPath to + /// string.Empty. [Fact] - public void ParseComposition_admits_galaxy_alias_tag_in_equipment_tags() + public void ParseComposition_admits_galaxy_equipment_tag_in_equipment_tags() + { + var blob = JsonSerializer.SerializeToUtf8Bytes(new + { + Namespaces = new[] + { + new { NamespaceId = "ns-eq", Kind = 0 }, // NamespaceKind.Equipment + }, + DriverInstances = new[] + { + new { DriverInstanceId = "drv-galaxy", DriverType = "GalaxyMxGateway", DriverConfig = "{}", NamespaceId = "ns-eq" }, + }, + Tags = new object[] + { + new + { + TagId = "tag-galaxy", + DriverInstanceId = "drv-galaxy", + EquipmentId = "eq-1", + Name = "TestChangingInt", + FolderPath = (string?)null, + DataType = "Int32", + TagConfig = "{\"FullName\":\"TestMachine_020.TestChangingInt\"}", + }, + }, + }); + + var c = DeploymentArtifact.ParseComposition(blob); + + var tag = c.EquipmentTags.ShouldHaveSingleItem(); + tag.TagId.ShouldBe("tag-galaxy"); + tag.EquipmentId.ShouldBe("eq-1"); + tag.DriverInstanceId.ShouldBe("drv-galaxy"); + tag.Name.ShouldBe("TestChangingInt"); + tag.DataType.ShouldBe("Int32"); + tag.FolderPath.ShouldBe(string.Empty); + tag.FullName.ShouldBe("TestMachine_020.TestChangingInt"); + } + + /// An equipment-scoped GalaxyMxGateway tag in a SystemPlatform-kind namespace must NOT surface + /// in EquipmentTags — byte-parity with the composer's pure ns.Kind == NamespaceKind.Equipment + /// predicate. The retired SystemPlatform-mirror contract no longer carried a DriverType exception, so a + /// non-Equipment namespace excludes the tag regardless of driver type. + [Fact] + public void ParseComposition_excludes_galaxy_tag_in_non_equipment_namespace() { var blob = JsonSerializer.SerializeToUtf8Bytes(new { @@ -32,54 +78,11 @@ public sealed class DeploymentArtifactAliasParityTests 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", + DriverInstanceId = "drv-galaxy", EquipmentId = "eq-1", Name = "Source", FolderPath = (string?)null, 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 e20fd048..832af534 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 @@ -193,9 +193,10 @@ public sealed class DeploymentArtifactTests /// /// 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. + /// from the tag's TagConfig blob. A tag in a non-Equipment (SystemPlatform) namespace with a + /// null EquipmentId must NOT surface in EquipmentTags — byte-parity with the composer's pure + /// ns.Kind == NamespaceKind.Equipment predicate (the SystemPlatform-mirror contract is + /// retired, so such a tag routes nowhere). /// [Fact] public void ParseComposition_reads_EquipmentTags_from_equipment_namespace() @@ -247,8 +248,9 @@ public sealed class DeploymentArtifactTests 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"); + // The SystemPlatform tag (null EquipmentId, non-Equipment namespace) does NOT leak into + // EquipmentTags — byte-parity with the composer's pure ns.Kind == Equipment predicate. + c.EquipmentTags.ShouldNotContain(t => t.TagId == "tag-gx"); } /// @@ -387,15 +389,18 @@ public sealed class DeploymentArtifactTests 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 = 1 }, - new { NamespaceId = "sa-ns", ClusterId = "SITE-A", Kind = 1 }, + 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?)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 = "{}" }, + 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 = "{}" }, }, }; @@ -406,18 +411,18 @@ public sealed class DeploymentArtifactTests 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" }); + 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.GalaxyTags.Select(t => t.TagId).ShouldBe(new[] { "t-sa" }); + 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.GalaxyTags.ShouldBeEmpty(); + comp.EquipmentTags.ShouldBeEmpty(); comp.DriverInstancePlans.ShouldBeEmpty(); } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorRebuildTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorRebuildTests.cs index d524f602..c399e456 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorRebuildTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorRebuildTests.cs @@ -102,10 +102,11 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase /// Wiring proof for per-ClusterId scoping (Task 4): a multi-cluster artifact must /// materialise ONLY the local node's cluster slice. Mirrors the multi-cluster artifact /// shape exercised in DeploymentArtifactTests (MAIN + SITE-A, one Galaxy driver + - /// one SystemPlatform tag each). The scoped rebuild for the SITE-A node must surface the - /// SITE-A tag (t-sa → variable F.S1) and NOT MAIN's (t-main → - /// F.M1); the mirror holds for the MAIN node. Without the production scoping edit, - /// the unscoped parse would materialise BOTH variables on every node. + /// one equipment tag each — Galaxy points are ordinary equipment tags now). The scoped + /// rebuild for the SITE-A node must surface the SITE-A tag (t-sa → folder-scoped + /// variable eq-sa/F/S1) and NOT MAIN's (t-maineq-main/F/M1); the + /// mirror holds for the MAIN node. Without the production scoping edit, the unscoped parse + /// would materialise BOTH variables on every node. /// [Fact] public void Rebuild_materialises_only_the_nodes_cluster() @@ -125,10 +126,10 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase siteActor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId())); AwaitAssert(() => sinkA.RebuildCalls.ShouldBe(1), duration: TimeSpan.FromSeconds(2)); - // t-sa (Name "S1", FolderPath "F") → MxAccessRef "F.S1" → variable node "F.S1". - sinkA.Calls.ShouldContain("EV:F.S1"); + // t-sa (EquipmentId "eq-sa", FolderPath "F", Name "S1") → folder-scoped variable "eq-sa/F/S1". + sinkA.Calls.ShouldContain("EV:eq-sa/F/S1"); // t-main (MAIN cluster) must NOT leak onto the SITE-A node. - sinkA.Calls.ShouldNotContain("EV:F.M1"); + sinkA.Calls.ShouldNotContain("EV:eq-main/F/M1"); // --- MAIN node: the mirror — only MAIN's tag's variable, never SITE-A's. --- var dbM = NewInMemoryDbFactory(); @@ -145,15 +146,15 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase mainActor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId())); AwaitAssert(() => sinkM.RebuildCalls.ShouldBe(1), duration: TimeSpan.FromSeconds(2)); - sinkM.Calls.ShouldContain("EV:F.M1"); - sinkM.Calls.ShouldNotContain("EV:F.S1"); + sinkM.Calls.ShouldContain("EV:eq-main/F/M1"); + sinkM.Calls.ShouldNotContain("EV:eq-sa/F/S1"); } /// /// Seal a 2-cluster deployment (MAIN + SITE-A) whose artifact mirrors the multi-cluster - /// shape the composer emits: a Clusters + Nodes map, one SystemPlatform - /// namespace + Galaxy driver + Galaxy tag per cluster. Used by - /// . + /// shape the composer emits: a Clusters + Nodes map, one Equipment namespace + + /// Galaxy driver + equipment tag per cluster (Galaxy points are ordinary equipment tags now). + /// Used by . /// private static void SeedMultiClusterDeployment(IDbContextFactory dbFactory) { @@ -165,6 +166,21 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase new { NodeId = "central-1:4053", ClusterId = "MAIN" }, new { NodeId = "site-a-1:4053", ClusterId = "SITE-A" }, }, + UnsAreas = new[] + { + new { UnsAreaId = "area-main", ClusterId = "MAIN", Name = "main-area" }, + new { UnsAreaId = "area-sa", ClusterId = "SITE-A", Name = "sa-area" }, + }, + UnsLines = new[] + { + new { UnsLineId = "line-main", UnsAreaId = "area-main", Name = "main-line" }, + new { UnsLineId = "line-sa", UnsAreaId = "area-sa", Name = "sa-line" }, + }, + Equipment = new[] + { + new { EquipmentId = "eq-main", DriverInstanceId = "main-galaxy", UnsLineId = "line-main", Name = "eq-main", MachineCode = "EQ-MAIN" }, + new { EquipmentId = "eq-sa", DriverInstanceId = "sa-galaxy", UnsLineId = "line-sa", Name = "eq-sa", MachineCode = "EQ-SA" }, + }, DriverInstances = new[] { new { DriverInstanceId = "main-galaxy", DriverType = "GalaxyMxGateway", DriverConfig = "{}", ClusterId = "MAIN", NamespaceId = "main-ns" }, @@ -172,13 +188,13 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase }, Namespaces = new[] { - new { NamespaceId = "main-ns", ClusterId = "MAIN", Kind = 1 }, // NamespaceKind.SystemPlatform - new { NamespaceId = "sa-ns", ClusterId = "SITE-A", Kind = 1 }, + new { NamespaceId = "main-ns", ClusterId = "MAIN", Kind = 0 }, // NamespaceKind.Equipment + new { NamespaceId = "sa-ns", ClusterId = "SITE-A", Kind = 0 }, }, 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 = "{}" }, + 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 = "{}" }, }, ScriptedAlarms = Array.Empty(), });