diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/Tag.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/Tag.cs index 889e9d7f..e6517171 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/Tag.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/Tag.cs @@ -3,9 +3,11 @@ using ZB.MOM.WW.OtOpcUa.Configuration.Enums; namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities; /// -/// One canonical tag (signal) in a cluster's generation. Per decision #110: -/// is REQUIRED when the driver is in an Equipment-kind namespace -/// and NULL when in SystemPlatform-kind namespace (Galaxy hierarchy preserved). +/// One canonical tag (signal) in a cluster's generation. set ⟺ the +/// tag participates in the Equipment tree, regardless of the driver's namespace kind. A +/// GalaxyMxGateway-bound equipment tag is an alias — a Galaxy attribute surfaced +/// under a UNS name, with its Galaxy reference carried in TagConfig.FullName. +/// is NULL for SystemPlatform mirror tags (FolderPath-scoped). /// public sealed class Tag { @@ -30,8 +32,8 @@ public sealed class Tag public string? DeviceId { get; set; } /// - /// Required when driver is in Equipment-kind namespace; NULL when in SystemPlatform-kind. - /// Cross-table invariant enforced by sp_ValidateDraft (decision #110). + /// Set when the tag belongs to an Equipment (driver tag OR Galaxy alias); NULL for + /// SystemPlatform mirror tags. /// public string? EquipmentId { get; set; } 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 16dc5f21..88db5322 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftValidator.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftValidator.cs @@ -33,10 +33,43 @@ public static class DraftValidator ValidateEquipmentIdDerivation(draft, errors); ValidateDriverNamespaceCompatibility(draft, errors); ValidateNoEquipmentSignalNameCollision(draft, errors); + ValidateAliasTagFullName(draft, errors); return errors; } + private static void ValidateAliasTagFullName(DraftSnapshot draft, List errors) + { + var typeByDriver = draft.DriverInstances + .ToDictionary(d => d.DriverInstanceId, d => d.DriverType, StringComparer.Ordinal); + foreach (var t in draft.Tags) + { + if (t.EquipmentId is null) continue; + if (!typeByDriver.TryGetValue(t.DriverInstanceId, out var dtype) || dtype != "GalaxyMxGateway") + continue; + if (string.IsNullOrWhiteSpace(ExtractTagConfigFullName(t.TagConfig))) + errors.Add(new("AliasTagMissingReference", + $"Alias tag '{t.TagId}' on equipment '{t.EquipmentId}' is missing a Galaxy reference (TagConfig.FullName)", + t.TagId)); + } + } + + // Minimal reader for the top-level "FullName" string in a tag's schemaless TagConfig JSON + // (mirrors Phase7Composer.ExtractTagFullName — a small local copy, consistent with this codebase + // where the composer keeps its own). + private static string? ExtractTagConfigFullName(string? tagConfig) + { + if (string.IsNullOrWhiteSpace(tagConfig)) return null; + try + { + using var doc = System.Text.Json.JsonDocument.Parse(tagConfig); + return doc.RootElement.ValueKind == System.Text.Json.JsonValueKind.Object + && doc.RootElement.TryGetProperty("FullName", out var fn) + && fn.ValueKind == System.Text.Json.JsonValueKind.String ? fn.GetString() : null; + } + catch (System.Text.Json.JsonException) { return null; } + } + private static void ValidateNoEquipmentSignalNameCollision(DraftSnapshot draft, List errors) { // Materialiser NodeId key: "{EquipmentId}[/{FolderPath}]/{Name}". Tag (EquipmentId != null) and 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 6e98af3b..5452884b 100644 --- a/tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/DraftValidatorTests.cs +++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/DraftValidatorTests.cs @@ -405,16 +405,21 @@ public sealed class DraftValidatorTests DraftValidator.Validate(draft).ShouldNotContain(e => e.Code == "EquipmentSignalNameCollision"); } - private static Tag BuildTag(string? equipmentId, string name, string? folderPath) => new() + private static Tag BuildTag( + string? equipmentId, + string name, + string? folderPath, + string driverInstanceId = "d", + string tagConfig = "{}") => new() { TagId = $"tag-{name}", - DriverInstanceId = "d", + DriverInstanceId = driverInstanceId, EquipmentId = equipmentId, Name = name, FolderPath = folderPath, DataType = "Float", AccessLevel = TagAccessLevel.Read, - TagConfig = "{}", + TagConfig = tagConfig, }; private static VirtualTag BuildVirtualTag(string equipmentId, string name) => new() @@ -426,6 +431,57 @@ public sealed class DraftValidatorTests ScriptId = "s-1", }; + // ------------------------------------------------------------------------------------ + // ValidateAliasTagFullName — Galaxy alias tags must carry TagConfig.FullName + // ------------------------------------------------------------------------------------ + + /// Verifies that an equipment-scoped Tag bound to a GalaxyMxGateway driver whose + /// TagConfig has no FullName (an alias with no Galaxy reference) is rejected — it would + /// subscribe to nothing. + [Fact] + public void AliasTag_missing_FullName_is_rejected() + { + var draft = new DraftSnapshot + { + GenerationId = 1, ClusterId = "c", + DriverInstances = [new DriverInstance { DriverInstanceId = "d-galaxy", ClusterId = "c", NamespaceId = "ns-1", Name = "Galaxy", DriverType = "GalaxyMxGateway", DriverConfig = "{}" }], + Tags = [BuildTag(equipmentId: "eq-1", name: "alias", folderPath: null, driverInstanceId: "d-galaxy", tagConfig: "{}")], + }; + + DraftValidator.Validate(draft).ShouldContain(e => e.Code == "AliasTagMissingReference" && e.Context == "tag-alias"); + } + + /// Verifies that an equipment-scoped Galaxy alias tag carrying a TagConfig.FullName + /// reference is accepted. + [Fact] + public void AliasTag_with_FullName_is_accepted() + { + var draft = new DraftSnapshot + { + GenerationId = 1, ClusterId = "c", + DriverInstances = [new DriverInstance { DriverInstanceId = "d-galaxy", ClusterId = "c", NamespaceId = "ns-1", Name = "Galaxy", DriverType = "GalaxyMxGateway", DriverConfig = "{}" }], + Tags = [BuildTag(equipmentId: "eq-1", name: "alias", folderPath: null, driverInstanceId: "d-galaxy", tagConfig: "{\"FullName\":\"X.Y\"}")], + }; + + DraftValidator.Validate(draft).ShouldNotContain(e => e.Code == "AliasTagMissingReference"); + } + + /// Verifies that an equipment-scoped Tag bound to a NON-Galaxy driver with an empty + /// TagConfig is NOT flagged as an alias missing its reference — only GalaxyMxGateway-bound + /// equipment tags are aliases. + [Fact] + public void AliasTag_check_skips_nonGalaxy_equipment_tag() + { + var draft = new DraftSnapshot + { + GenerationId = 1, ClusterId = "c", + DriverInstances = [new DriverInstance { DriverInstanceId = "d-modbus", ClusterId = "c", NamespaceId = "ns-1", Name = "Modbus", DriverType = "Modbus", DriverConfig = "{}" }], + Tags = [BuildTag(equipmentId: "eq-1", name: "alias", folderPath: null, driverInstanceId: "d-modbus", tagConfig: "{}")], + }; + + DraftValidator.Validate(draft).ShouldNotContain(e => e.Code == "AliasTagMissingReference"); + } + // ------------------------------------------------------------------------------------ // Phase 6.3 task #148 part 2 — ValidateClusterTopology // ------------------------------------------------------------------------------------