From 83c7149be05899351a046c50557e1755b84fa484 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 7 Jun 2026 10:33:45 -0400 Subject: [PATCH] feat(config): DraftValidator rule + DraftSnapshot.VirtualTags for Tag/VirtualTag NodeId collisions --- .../Validation/DraftSnapshot.cs | 4 ++ .../Validation/DraftValidator.cs | 24 +++++++ .../DraftValidatorTests.cs | 70 +++++++++++++++++++ 3 files changed, 98 insertions(+) diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftSnapshot.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftSnapshot.cs index fe8464c5..45e711e3 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftSnapshot.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftSnapshot.cs @@ -39,6 +39,10 @@ public sealed class DraftSnapshot public IReadOnlyList Equipment { get; init; } = []; /// Gets the list of tags. public IReadOnlyList Tags { get; init; } = []; + + /// Equipment-bound VirtualTags (script-derived signals). Shares the equipment NodeId space + /// with Tags; the collision rule checks both. + public IReadOnlyList VirtualTags { get; init; } = []; /// Gets the list of poll groups. public IReadOnlyList PollGroups { get; init; } = []; diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftValidator.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftValidator.cs index 718485b8..6447f831 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftValidator.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftValidator.cs @@ -32,10 +32,34 @@ public static class DraftValidator ValidateReservationPreflight(draft, errors); ValidateEquipmentIdDerivation(draft, errors); ValidateDriverNamespaceCompatibility(draft, errors); + ValidateNoEquipmentSignalNameCollision(draft, errors); return errors; } + private static void ValidateNoEquipmentSignalNameCollision(DraftSnapshot draft, List errors) + { + // Materialiser NodeId key: "{EquipmentId}[/{FolderPath}]/{Name}". Tag (EquipmentId != null) and + // VirtualTag share this space with no DB cross-table uniqueness, so the same key from both collides. + static string Key(string eq, string? folder, string name) => + string.IsNullOrWhiteSpace(folder) ? $"{eq}/{name}" : $"{eq}/{folder}/{name}"; + + var signals = draft.Tags + .Where(t => t.EquipmentId is not null) + .Select(t => (Key: Key(t.EquipmentId!, t.FolderPath, t.Name), Eq: t.EquipmentId!, t.Name)) + .Concat(draft.VirtualTags + .Select(v => (Key: Key(v.EquipmentId, null, v.Name), Eq: v.EquipmentId, v.Name))); + + foreach (var g in signals.GroupBy(s => s.Key, StringComparer.Ordinal).Where(g => g.Count() > 1)) + { + var f = g.First(); + errors.Add(new("EquipmentSignalNameCollision", + $"{g.Count()} signals collide on OPC UA NodeId '{g.Key}' (equipment '{f.Eq}', name '{f.Name}'); " + + "a Name must be unique across Tag and VirtualTag within an equipment+folder", + f.Eq)); + } + } + private static bool IsValidSegment(string? s) => s is not null && (UnsSegment.IsMatch(s) || s == UnsDefaultSegment); diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/DraftValidatorTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/DraftValidatorTests.cs index 1effa46b..045e32f6 100644 --- a/tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/DraftValidatorTests.cs +++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/DraftValidatorTests.cs @@ -186,6 +186,76 @@ public sealed class DraftValidatorTests errors.ShouldContain(e => e.Code == "UnsSegmentInvalid"); } + // ------------------------------------------------------------------------------------ + // ValidateNoEquipmentSignalNameCollision — Tag/VirtualTag NodeId collision + // ------------------------------------------------------------------------------------ + + /// Verifies that an equipment-bound Tag and a VirtualTag sharing the same + /// equipment+name collide on a single OPC UA NodeId. + [Fact] + public void Tag_and_VirtualTag_same_equipment_and_name_collide() + { + var draft = new DraftSnapshot + { + GenerationId = 1, ClusterId = "c", + Tags = [BuildTag(equipmentId: "eq-1", name: "speed", folderPath: null)], + VirtualTags = [BuildVirtualTag(equipmentId: "eq-1", name: "speed")], + }; + + DraftValidator.Validate(draft).ShouldContain(e => e.Code == "EquipmentSignalNameCollision"); + } + + /// Verifies that a Tag in a sub-folder does not collide with a VirtualTag at the + /// equipment root even when the names match — the NodeId keys differ. + [Fact] + public void Same_name_different_folder_does_not_collide() + { + var draft = new DraftSnapshot + { + GenerationId = 1, ClusterId = "c", + Tags = [BuildTag(equipmentId: "eq-1", name: "speed", folderPath: "metrics")], + VirtualTags = [BuildVirtualTag(equipmentId: "eq-1", name: "speed")], + }; + + DraftValidator.Validate(draft).ShouldNotContain(e => e.Code == "EquipmentSignalNameCollision"); + } + + /// Verifies that a SystemPlatform Tag (EquipmentId == null) is excluded from the + /// equipment-signal node space and so never collides with an equipment VirtualTag. + [Fact] + public void SystemPlatform_tag_sharing_name_with_equipment_vtag_excluded() + { + var draft = new DraftSnapshot + { + GenerationId = 1, ClusterId = "c", + Tags = [BuildTag(equipmentId: null, name: "speed", folderPath: null)], + VirtualTags = [BuildVirtualTag(equipmentId: "eq-1", name: "speed")], + }; + + DraftValidator.Validate(draft).ShouldNotContain(e => e.Code == "EquipmentSignalNameCollision"); + } + + private static Tag BuildTag(string? equipmentId, string name, string? folderPath) => new() + { + TagId = $"tag-{name}", + DriverInstanceId = "d", + EquipmentId = equipmentId, + Name = name, + FolderPath = folderPath, + DataType = "Float", + AccessLevel = TagAccessLevel.Read, + TagConfig = "{}", + }; + + private static VirtualTag BuildVirtualTag(string equipmentId, string name) => new() + { + VirtualTagId = $"vtag-{name}", + EquipmentId = equipmentId, + Name = name, + DataType = "Float", + ScriptId = "s-1", + }; + // ------------------------------------------------------------------------------------ // Phase 6.3 task #148 part 2 — ValidateClusterTopology // ------------------------------------------------------------------------------------