From f4f3e17e3ed0176e35f5bfa3f710afca6490abbf Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 16 Jul 2026 09:30:12 -0400 Subject: [PATCH] feat(v3-batch4-wp1): composer/planner emit both Raw + UNS subtrees MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Un-darken the address-space composition for the v3 dual namespace. Composer (AddressSpaceComposer): - Raw subtree: RawContainerNode (Folder/Driver/Device/TagGroup as Object/Folder nodes, keyed s=, parent-before-child) + RawTagPlan (raw tag Variables keyed (realm=Raw, s=) carrying DataType / writable / historize+historian tagname / array shape). Native-alarm intent attaches at the RAW tag (ConditionId = RawPath) with the list of referencing equipment UNS paths (one alarm at the raw tag, not one per equipment). - UNS subtree: UnsReferenceVariable per UnsTagReference, keyed (realm=Uns, s=///), carrying its backing RawPath (Organizes UNS->Raw target + fan-out) and inheriting DataType/AccessLevel from the backing raw tag. Effective name = DisplayNameOverride else backing raw Tag.Name. - All RawPaths flow through one shared RawTopology (RawPathResolver + memoised container paths), byte-parity with EquipmentReferenceMap. - Every new node carries an AddressSpaceRealm (so WP3's applier picks the namespace at the call site). Additive/defaulted model changes only — the un-migrated AddressSpaceApplier still compiles. Planner (AddressSpacePlanner.Compute) + AddressSpacePlan: - Per-realm diff sets: RawContainers + RawTags keyed by RawPath (NodeId) so a rename = remove+add; UnsReferenceVariables keyed by the stable UnsTagReferenceId so a backing-tag rename re-points (BackingRawPath moves, NodeId stable) and a display-name-override edit is UNS-only. - PINNED: raw-tag rename = remove+add in Raw AND re-point in UNS. Classifier folds the new sets in (adds->PureAdd, removes->PureRemove, changed->Rebuild safe default). Tests: AddressSpaceComposerDualNamespaceTests (7), AddressSpacePlannerDualNamespaceTests (8, incl. the rename pin), AddressSpaceChangeClassifierDualNamespaceTests (8); reactivated EquipmentNamespaceMaterializationTests' Batch-4-pending case, re-authored to drive the composer over the seeded config (the artifact-decode seam is WP3). Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox --- .../AddressSpaceChangeClassifier.cs | 18 +- .../AddressSpaceComposer.cs | 585 +++++++++++++++++- .../AddressSpacePlan.cs | 88 +++ .../EquipmentNamespaceMaterializationTests.cs | 101 +-- ...SpaceChangeClassifierDualNamespaceTests.cs | 103 +++ .../AddressSpaceComposerDualNamespaceTests.cs | 208 +++++++ .../AddressSpacePlannerDualNamespaceTests.cs | 234 +++++++ 7 files changed, 1253 insertions(+), 84 deletions(-) create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceChangeClassifierDualNamespaceTests.cs create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceComposerDualNamespaceTests.cs create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpacePlannerDualNamespaceTests.cs diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceChangeClassifier.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceChangeClassifier.cs index 695ef436..8a05bc74 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceChangeClassifier.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceChangeClassifier.cs @@ -53,10 +53,16 @@ public static class AddressSpaceChangeClassifier // 2 — any node-affecting CHANGE forces a full rebuild (default-closed). ChangedEquipment / // ChangedAlarms are always node-affecting; a changed tag is node-affecting UNLESS it is - // surgical-eligible, and a changed vtag is node-affecting UNLESS it is node-irrelevant. Evaluated - // BEFORE the add/remove split so a non-surgical change alongside pure adds still rebuilds. + // surgical-eligible, and a changed vtag is node-affecting UNLESS it is node-irrelevant. The v3 + // Batch-4 Raw/UNS Changed sets (container re-shape, raw-tag attribute/alarm edit, UNS re-point / + // display-name-override) route to Rebuild as the safe default — WP3's applier owns any surgical + // in-place refinement for them. Evaluated BEFORE the add/remove split so a non-surgical change + // alongside pure adds still rebuilds. if (plan.ChangedEquipment.Count > 0 || plan.ChangedAlarms.Count > 0 || + plan.ChangedRawContainers.Count > 0 || + plan.ChangedRawTags.Count > 0 || + plan.ChangedUnsReferenceVariables.Count > 0 || plan.ChangedEquipmentTags.Any(d => !TagDeltaIsSurgicalEligible(d)) || plan.ChangedEquipmentVirtualTags.Any(d => !VtagDeltaIsNodeIrrelevant(d))) { @@ -68,10 +74,14 @@ public static class AddressSpaceChangeClassifier // add/remove split below and leave a driver-only plan classified AttributeOnly. var hasAdds = plan.AddedEquipment.Count + plan.AddedAlarms.Count + - plan.AddedEquipmentTags.Count + plan.AddedEquipmentVirtualTags.Count > 0; + plan.AddedEquipmentTags.Count + plan.AddedEquipmentVirtualTags.Count + + plan.AddedRawContainers.Count + plan.AddedRawTags.Count + + plan.AddedUnsReferenceVariables.Count > 0; var hasRemoves = plan.RemovedEquipment.Count + plan.RemovedAlarms.Count + - plan.RemovedEquipmentTags.Count + plan.RemovedEquipmentVirtualTags.Count > 0; + plan.RemovedEquipmentTags.Count + plan.RemovedEquipmentVirtualTags.Count + + plan.RemovedRawContainers.Count + plan.RemovedRawTags.Count + + plan.RemovedUnsReferenceVariables.Count > 0; // 3 — additions AND removals. if (hasAdds && hasRemoves) return AddressSpaceChangeKind.AddRemoveMix; diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceComposer.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceComposer.cs index 24d46012..c770e863 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceComposer.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceComposer.cs @@ -1,4 +1,5 @@ using System.Diagnostics; +using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; using ZB.MOM.WW.OtOpcUa.Commons.Types; using ZB.MOM.WW.OtOpcUa.Configuration.Entities; using ZB.MOM.WW.OtOpcUa.Configuration.Enums; @@ -53,8 +54,163 @@ public sealed record AddressSpaceComposition( /// constructor + call site keeps compiling unchanged. /// public IReadOnlyList EquipmentScriptedAlarms { get; init; } = Array.Empty(); + + // ----- v3 Batch 4 dual-namespace address space (un-darkened) ----- + + /// + /// The Raw subtree's container nodes (Folder → Driver → Device → TagGroup) as + /// Object/Folder nodes in the namespace. Each + /// carries its s=<RawPath> NodeId and its parent NodeId ( = cluster + /// root) so AddressSpaceApplier can materialise parents-before-children. Sorted by NodeId + /// (ordinal, so a parent precedes each child). Init-only, defaults empty so every existing constructor + /// + call site keeps compiling unchanged. + /// + public IReadOnlyList RawContainers { get; init; } = Array.Empty(); + + /// + /// The Raw subtree's tag Variable nodes — one per raw — keyed + /// (realm=Raw, s=<RawPath>). The RawPath IS the node identity + the single value source + /// the driver binds; every referencing UNS variable fans out from it. Carries DataType / writable / + /// historize + array shape, the optional native-alarm intent (attached at the RAW tag — + /// ConditionId = RawPath), and the list of referencing equipment UNS folder paths (so WP4 wires the + /// multi-notifier alarm). Init-only, defaults empty. + /// + public IReadOnlyList RawTags { get; init; } = Array.Empty(); + + /// + /// The UNS subtree's reference Variable nodes — one per — keyed + /// (realm=Uns, s=<Area>/<Line>/<Equipment>/<EffectiveName>). Each carries + /// its backing raw tag's RawPath (the Organizes UNS→Raw target + the fan-out source) plus the + /// inherited DataType / writable from that raw tag. UNS nodes never bind a driver directly — they fan + /// out from the raw node. Init-only, defaults empty. + /// + public IReadOnlyList UnsReferenceVariables { get; init; } = Array.Empty(); } +/// Which Raw-tree container a is (all are Object/Folder +/// nodes; the kind lets the applier pick the OPC UA type + reference). +public enum RawNodeKind +{ + /// A driver-organizing . + Folder, + + /// A . + Driver, + + /// A . + Device, + + /// A tag-organizing . + TagGroup, +} + +/// +/// One Raw-subtree container node (Folder / Driver / Device / TagGroup). is its +/// RawPath (== the ns=Raw s= id); is the parent container's +/// RawPath ( = directly under the cluster root). is always +/// — carried explicitly so the applier chooses the namespace at the +/// call site rather than inferring it. +/// +/// The container's RawPath (its ns=Raw s= identifier). +/// The parent container's RawPath, or for a cluster-root node. +/// The friendly browse name (the container's leaf Name). +/// Which container this is. +/// The address-space realm (always ). +public sealed record RawContainerNode( + string NodeId, + string? ParentNodeId, + string DisplayName, + RawNodeKind Kind, + AddressSpaceRealm Realm = AddressSpaceRealm.Raw); + +/// +/// One Raw-subtree tag Variable node. is the tag's RawPath — the single +/// identity string at every seam (the ns=Raw s= id, the driver fan-out/write key, the +/// historian default tagname, the native-alarm ConditionId). is the +/// owning TagGroup's RawPath (or the Device's when the tag sits directly under the device). +/// mirrors Tag.AccessLevel == ReadWrite. / +/// / / / +/// are parsed from Tag.TagConfig via +/// (the single byte-parity authority). +/// is the (possibly empty) set of UNS equipment-folder paths (Area/Line/Equipment) that reference +/// this tag — WP4 wires one notifier per path so the raw tag's single alarm condition fans to every +/// referencing equipment. is always . +/// A null means the historian tagname defaults to the RawPath (resolved +/// later, not here). +/// +public sealed record RawTagPlan( + string TagId, + string NodeId, + string? ParentNodeId, + string DriverInstanceId, + string Name, + string DataType, + bool Writable, + EquipmentTagAlarmInfo? Alarm, + IReadOnlyList ReferencingEquipmentPaths, + bool IsHistorized = false, + string? HistorianTagname = null, + bool IsArray = false, + uint? ArrayLength = null, + AddressSpaceRealm Realm = AddressSpaceRealm.Raw) +{ + /// + // Structural equality: the auto-generated record equality compares ReferencingEquipmentPaths (an + // interface-typed list) BY REFERENCE, which would flag every raw tag as "changed" on every parse + // (fresh list instances). Compare it element-wise so a no-op redeploy diffs empty (mirrors + // EquipmentVirtualTagPlan). + public bool Equals(RawTagPlan? other) => + other is not null && + TagId == other.TagId && + NodeId == other.NodeId && + ParentNodeId == other.ParentNodeId && + DriverInstanceId == other.DriverInstanceId && + Name == other.Name && + DataType == other.DataType && + Writable == other.Writable && + EqualityComparer.Default.Equals(Alarm, other.Alarm) && + IsHistorized == other.IsHistorized && + HistorianTagname == other.HistorianTagname && + IsArray == other.IsArray && + ArrayLength == other.ArrayLength && + Realm == other.Realm && + ReferencingEquipmentPaths.SequenceEqual(other.ReferencingEquipmentPaths, StringComparer.Ordinal); + + /// + public override int GetHashCode() + { + var hash = new HashCode(); + hash.Add(TagId); hash.Add(NodeId); hash.Add(ParentNodeId); hash.Add(DriverInstanceId); + hash.Add(Name); hash.Add(DataType); hash.Add(Writable); hash.Add(Alarm); + hash.Add(IsHistorized); hash.Add(HistorianTagname); hash.Add(IsArray); hash.Add(ArrayLength); + hash.Add(Realm); + foreach (var p in ReferencingEquipmentPaths) hash.Add(p, StringComparer.Ordinal); + return hash.ToHashCode(); + } +} + +/// +/// One UNS-subtree reference Variable node projected from a . +/// is the slash-joined UNS path Area/Line/Equipment/EffectiveName (the +/// ns=UNS s= id); is DisplayNameOverride else the backing +/// raw tag's Name. is the backing raw tag's RawPath — the +/// Organizes UNS→Raw reference target AND the fan-out value source (the UNS node binds no driver; +/// it mirrors the raw node). / are inherited from the +/// backing raw tag. is the stable diff identity (a display-name-override +/// edit keeps the same id while the NodeId/effective-name changes; a backing-tag rename keeps the same id +/// + NodeId while re-points). is always +/// . +/// +public sealed record UnsReferenceVariable( + string UnsTagReferenceId, + string EquipmentId, + string NodeId, + string EffectiveName, + string BackingRawPath, + string DataType, + bool Writable, + AddressSpaceRealm Realm = AddressSpaceRealm.Uns); + public sealed record UnsAreaProjection(string UnsAreaId, string DisplayName); public sealed record UnsLineProjection(string UnsLineId, string UnsAreaId, string DisplayName); @@ -301,12 +457,17 @@ public static class AddressSpaceComposer /// /// Composes the address space build plan from the v3 configuration entities. - /// v3 DARK address space (Batch 1): equipment-tag variable nodes do NOT materialize — - /// EquipmentTags is deliberately empty (the raw + UNS variable nodes are lit up in Batch 4's - /// dual namespace). The composer carries the UNS folder hierarchy (Area/Line/Equipment) + - /// per-equipment VirtualTags + ScriptedAlarms only. Equipment no longer binds a driver/device, so - /// 's driver/device hooks are always null. This is the pure reference - /// mirror of DeploymentArtifact.ParseComposition (byte-parity target for the corpus tests). + /// v3 dual namespace (Batch 4): the composer emits BOTH subtrees. The + /// + + /// light up the device-oriented ns=Raw tree (Folder→Driver→Device→TagGroup→Tag, each tag keyed + /// s=<RawPath>); the light up + /// the equipment-oriented ns=UNS tree (one Variable per , keyed + /// s=<Area>/<Line>/<Equipment>/<EffectiveName>, carrying its backing RawPath + /// for the Organizes reference + fan-out). Native-alarm intents attach at the RAW tag + /// (ConditionId = RawPath) with the list of referencing equipment paths. The legacy + /// set stays EMPTY (the retired + /// equipment-namespace tag path); UNS folders + per-equipment VirtualTags + ScriptedAlarms are + /// unchanged. /// /// The UNS areas. /// The UNS lines. @@ -366,15 +527,19 @@ public static class AddressSpaceComposer .Select(a => new ScriptedAlarmPlan(a.ScriptedAlarmId, a.EquipmentId, a.PredicateScriptId, a.MessageTemplate)) .ToList(); - // v3 DARK: no equipment-tag variable plans this batch (Batch 4 owns UNS↔Raw fan-out). + // v3: the retired equipment-namespace tag path stays empty — raw + UNS variable nodes now carry + // every value (see RawTags / UnsReferenceVariables below). var equipmentTags = Array.Empty(); - // Per-equipment {{equip}}/ reference map (effectiveName → backing-tag RawPath), built - // from the equipment's UnsTagReferences + the raw tags + the raw topology via the shared - // RawPathResolver — the same identity authority the artifact-decode mirror + the draft validator - // use, so the three agree byte-for-byte on the resolved RawPath. - var referenceMapByEquip = BuildEquipmentReferenceMap( - unsTagReferences, tags, rawFolders, driverInstances, devices, tagGroups); + // The shared raw topology (RawPathResolver over the folder/driver/device/group ancestry + the + // container RawPath maps). One authority for the Raw subtree emit, the UNS backing RawPaths, and the + // {{equip}} reference map — so the three agree byte-for-byte on every RawPath. + var topology = RawTopology.Build(rawFolders, driverInstances, devices, tagGroups); + + // Per-equipment {{equip}}/ reference map (effectiveName → backing-tag RawPath), built via + // the shared EquipmentReferenceMap over the SAME resolver — the same identity authority the + // artifact-decode mirror + the draft validator use. + var referenceMapByEquip = BuildEquipmentReferenceMap(unsTagReferences, tags, topology.Resolver); var emptyRefMap = (IReadOnlyDictionary) new Dictionary(StringComparer.Ordinal); @@ -451,45 +616,38 @@ public static class AddressSpaceComposer Enabled: a.Enabled)); } + // Raw subtree (device-oriented) + UNS subtree (equipment-oriented) — the Batch-4 dual namespace. + var (rawContainers, rawTags) = BuildRawSubtree( + rawFolders, driverInstances, devices, tagGroups, tags, unsTagReferences, + unsAreas, unsLines, equipment, topology); + var unsReferenceVariables = BuildUnsReferenceVariables( + unsTagReferences, tags, unsAreas, unsLines, equipment, topology); + return new AddressSpaceComposition(areas, lines, nodes, plans, alarms) { EquipmentTags = equipmentTags, EquipmentVirtualTags = equipmentVirtualTags, EquipmentScriptedAlarms = equipmentScriptedAlarms, + RawContainers = rawContainers, + RawTags = rawTags, + UnsReferenceVariables = unsReferenceVariables, }; } /// Build the per-equipment {{equip}}/<RefName> reference map from the v3 entities - /// via the shared + — the entity-side - /// mirror of DeploymentArtifact.BuildEquipmentReferenceMap (JSON side). Empty when no references - /// are supplied (every test / earlier caller that omits the reference data). + /// via the shared over the SHARED — the + /// entity-side mirror of DeploymentArtifact.BuildEquipmentReferenceMap (JSON side). Empty when no + /// references are supplied (every test / earlier caller that omits the reference data). private static IReadOnlyDictionary> BuildEquipmentReferenceMap( IReadOnlyList? unsTagReferences, IReadOnlyList? tags, - IReadOnlyList? rawFolders, - IReadOnlyList driverInstances, - IReadOnlyList? devices, - IReadOnlyList? tagGroups) + RawPathResolver resolver) { var refs = unsTagReferences ?? Array.Empty(); var tagRows = tags ?? Array.Empty(); if (refs.Count == 0 || tagRows.Count == 0) return new Dictionary>(StringComparer.Ordinal); - var folders = (rawFolders ?? Array.Empty()) - .GroupBy(f => f.RawFolderId, StringComparer.Ordinal) - .ToDictionary(g => g.Key, g => ((string?)g.First().ParentRawFolderId, g.First().Name), StringComparer.Ordinal); - var drivers = driverInstances - .GroupBy(d => d.DriverInstanceId, StringComparer.Ordinal) - .ToDictionary(g => g.Key, g => ((string?)g.First().RawFolderId, g.First().Name), StringComparer.Ordinal); - var deviceMap = (devices ?? Array.Empty()) - .GroupBy(d => d.DeviceId, StringComparer.Ordinal) - .ToDictionary(g => g.Key, g => (g.First().DriverInstanceId, g.First().Name), StringComparer.Ordinal); - var groups = (tagGroups ?? Array.Empty()) - .GroupBy(t => t.TagGroupId, StringComparer.Ordinal) - .ToDictionary(g => g.Key, g => ((string?)g.First().ParentTagGroupId, g.First().Name), StringComparer.Ordinal); - var resolver = new RawPathResolver(folders, drivers, deviceMap, groups); - var tagsById = tagRows .GroupBy(t => t.TagId, StringComparer.Ordinal) .ToDictionary( @@ -503,4 +661,363 @@ public static class AddressSpaceComposer tagsById, resolver); } + + /// + /// Emit the Raw subtree: the container nodes (Folder→Driver→Device→TagGroup) as + /// Object/Folder nodes, and the tag Variable nodes keyed (realm=Raw, s=RawPath). + /// A node whose RawPath cannot be resolved (broken/unknown ancestry, or an invalid segment) is + /// SKIPPED rather than faulting the whole compose (mirrors 's + /// null-not-throw policy). Both lists are sorted by NodeId ordinal so a parent precedes each child. + /// + private static (IReadOnlyList Containers, IReadOnlyList Tags) BuildRawSubtree( + IReadOnlyList? rawFolders, + IReadOnlyList driverInstances, + IReadOnlyList? devices, + IReadOnlyList? tagGroups, + IReadOnlyList? tags, + IReadOnlyList? unsTagReferences, + IReadOnlyList unsAreas, + IReadOnlyList unsLines, + IReadOnlyList equipment, + RawTopology topology) + { + var containers = new List(); + + foreach (var f in rawFolders ?? Array.Empty()) + { + if (!topology.FolderPaths.TryGetValue(f.RawFolderId, out var path)) continue; + var parent = f.ParentRawFolderId is not null + ? topology.FolderPaths.GetValueOrDefault(f.ParentRawFolderId) + : null; + containers.Add(new RawContainerNode(path, parent, f.Name, RawNodeKind.Folder)); + } + + foreach (var d in driverInstances) + { + if (!topology.DriverPaths.TryGetValue(d.DriverInstanceId, out var path)) continue; + var parent = d.RawFolderId is not null + ? topology.FolderPaths.GetValueOrDefault(d.RawFolderId) + : null; + containers.Add(new RawContainerNode(path, parent, d.Name, RawNodeKind.Driver)); + } + + foreach (var dev in devices ?? Array.Empty()) + { + if (!topology.DevicePaths.TryGetValue(dev.DeviceId, out var path)) continue; + var parent = topology.DriverPaths.GetValueOrDefault(dev.DriverInstanceId); + containers.Add(new RawContainerNode(path, parent, dev.Name, RawNodeKind.Device)); + } + + foreach (var g in tagGroups ?? Array.Empty()) + { + if (!topology.GroupPaths.TryGetValue(g.TagGroupId, out var path)) continue; + var parent = g.ParentTagGroupId is not null + ? topology.GroupPaths.GetValueOrDefault(g.ParentTagGroupId) + : topology.DevicePaths.GetValueOrDefault(g.DeviceId); + containers.Add(new RawContainerNode(path, parent, g.Name, RawNodeKind.TagGroup)); + } + + // TagId → the set of referencing equipment UNS folder paths (Area/Line/Equipment), for the + // native-alarm multi-notifier. Distinct + ordinal-sorted for determinism. + var equipPathByTag = BuildReferencingEquipmentPathsByTag(unsTagReferences, unsAreas, unsLines, equipment); + + var rawTags = new List(); + foreach (var t in tags ?? Array.Empty()) + { + var nodeId = topology.Resolver.TryBuildTagPath(t.DeviceId, t.TagGroupId, t.Name); + if (nodeId is null) continue; + var parent = t.TagGroupId is not null + ? topology.GroupPaths.GetValueOrDefault(t.TagGroupId) + : topology.DevicePaths.GetValueOrDefault(t.DeviceId); + var driverInstanceId = topology.DriverIdByDevice.GetValueOrDefault(t.DeviceId, string.Empty); + var intent = TagConfigIntent.Parse(t.TagConfig); + var alarm = intent.Alarm is { } a + ? new EquipmentTagAlarmInfo(a.AlarmType, a.Severity, a.HistorizeToAveva) + : null; + var refEquipPaths = equipPathByTag.GetValueOrDefault(t.TagId, Array.Empty()); + + rawTags.Add(new RawTagPlan( + TagId: t.TagId, + NodeId: nodeId, + ParentNodeId: parent, + DriverInstanceId: driverInstanceId, + Name: t.Name, + DataType: t.DataType, + Writable: t.AccessLevel == TagAccessLevel.ReadWrite, + Alarm: alarm, + ReferencingEquipmentPaths: refEquipPaths, + IsHistorized: intent.IsHistorized, + HistorianTagname: intent.HistorianTagname, + IsArray: intent.IsArray, + ArrayLength: intent.ArrayLength)); + } + + containers.Sort((x, y) => string.CompareOrdinal(x.NodeId, y.NodeId)); + rawTags.Sort((x, y) => string.CompareOrdinal(x.NodeId, y.NodeId)); + return (containers, rawTags); + } + + /// + /// Emit the UNS reference Variables: one per whose backing raw tag + + /// equipment path resolve, keyed (realm=Uns, s=Area/Line/Equipment/EffectiveName) and carrying + /// the backing RawPath (Organizes target + fan-out) plus the inherited DataType / writable. A + /// reference whose backing tag is unknown, whose RawPath is unresolvable, or whose UNS path segments + /// are invalid is SKIPPED. Sorted by NodeId ordinal. + /// + private static IReadOnlyList BuildUnsReferenceVariables( + IReadOnlyList? unsTagReferences, + IReadOnlyList? tags, + IReadOnlyList unsAreas, + IReadOnlyList unsLines, + IReadOnlyList equipment, + RawTopology topology) + { + var refs = unsTagReferences ?? Array.Empty(); + if (refs.Count == 0) return Array.Empty(); + + var tagsById = (tags ?? Array.Empty()) + .GroupBy(t => t.TagId, StringComparer.Ordinal) + .ToDictionary(g => g.Key, g => g.First(), StringComparer.Ordinal); + var equipFolderPath = BuildEquipmentFolderPaths(unsAreas, unsLines, equipment); + + var vars = new List(); + foreach (var r in refs.OrderBy(x => x.UnsTagReferenceId, StringComparer.Ordinal)) + { + if (!tagsById.TryGetValue(r.TagId, out var tag)) continue; + if (!equipFolderPath.TryGetValue(r.EquipmentId, out var equipPath)) continue; + var rawPath = topology.Resolver.TryBuildTagPath(tag.DeviceId, tag.TagGroupId, tag.Name); + if (rawPath is null) continue; + var effectiveName = string.IsNullOrEmpty(r.DisplayNameOverride) ? tag.Name : r.DisplayNameOverride!; + + string nodeId; + try + { + nodeId = V3NodeIds.UnsChild(equipPath, effectiveName); + } + catch (ArgumentException) + { + continue; // invalid effective-name segment — dropped (deploy gate rejects invalid names) + } + + vars.Add(new UnsReferenceVariable( + UnsTagReferenceId: r.UnsTagReferenceId, + EquipmentId: r.EquipmentId, + NodeId: nodeId, + EffectiveName: effectiveName, + BackingRawPath: rawPath, + DataType: tag.DataType, + Writable: tag.AccessLevel == TagAccessLevel.ReadWrite)); + } + + vars.Sort((x, y) => string.CompareOrdinal(x.NodeId, y.NodeId)); + return vars; + } + + /// + /// EquipmentId → UNS folder path (Area/Line/Equipment, slash-joined via + /// ) for every equipment whose Area/Line ancestry resolves + whose three + /// segments are valid. Equipment with a broken ancestry or an invalid segment is absent. + /// + private static IReadOnlyDictionary BuildEquipmentFolderPaths( + IReadOnlyList unsAreas, + IReadOnlyList unsLines, + IReadOnlyList equipment) + { + var areaName = unsAreas + .GroupBy(a => a.UnsAreaId, StringComparer.Ordinal) + .ToDictionary(g => g.Key, g => g.First().Name, StringComparer.Ordinal); + var line = unsLines + .GroupBy(l => l.UnsLineId, StringComparer.Ordinal) + .ToDictionary(g => g.Key, g => (g.First().UnsAreaId, g.First().Name), StringComparer.Ordinal); + + var byEquip = new Dictionary(StringComparer.Ordinal); + foreach (var e in equipment) + { + if (!line.TryGetValue(e.UnsLineId, out var ln)) continue; + if (!areaName.TryGetValue(ln.UnsAreaId, out var aName)) continue; + try + { + byEquip[e.EquipmentId] = V3NodeIds.Uns(aName, ln.Name, e.Name); + } + catch (ArgumentException) + { + // invalid Area/Line/Equipment segment — dropped (deploy gate rejects invalid names). + } + } + return byEquip; + } + + /// + /// TagId → distinct, ordinal-sorted list of referencing equipment UNS folder paths + /// (Area/Line/Equipment). Drives the native-alarm multi-notifier (WP4): the raw tag's single + /// alarm condition fans one event to each referencing equipment's UNS folder. A tag with no + /// resolvable reference is absent (⇒ an empty list at the call site). + /// + private static IReadOnlyDictionary> BuildReferencingEquipmentPathsByTag( + IReadOnlyList? unsTagReferences, + IReadOnlyList unsAreas, + IReadOnlyList unsLines, + IReadOnlyList equipment) + { + var refs = unsTagReferences ?? Array.Empty(); + if (refs.Count == 0) return new Dictionary>(StringComparer.Ordinal); + + var equipFolderPath = BuildEquipmentFolderPaths(unsAreas, unsLines, equipment); + var byTag = new Dictionary>(StringComparer.Ordinal); + foreach (var r in refs) + { + if (!equipFolderPath.TryGetValue(r.EquipmentId, out var equipPath)) continue; + if (!byTag.TryGetValue(r.TagId, out var set)) + byTag[r.TagId] = set = new SortedSet(StringComparer.Ordinal); + set.Add(equipPath); + } + return byTag.ToDictionary( + kv => kv.Key, + kv => (IReadOnlyList)kv.Value.ToList(), + StringComparer.Ordinal); + } + + /// + /// The shared Raw-tree topology: a over the folder/driver/device/group + /// ancestry plus the resolved container RawPath maps (memoised) + the device→driver id map. Built + /// once per compose and reused for the Raw subtree, the UNS backing RawPaths, and the {{equip}} + /// reference map so every RawPath is produced by the single authority. + /// + private sealed class RawTopology + { + public required RawPathResolver Resolver { get; init; } + + /// RawFolderId → RawPath (absent when the ancestry is broken). + public required IReadOnlyDictionary FolderPaths { get; init; } + + /// DriverInstanceId → RawPath (absent when the ancestry is broken). + public required IReadOnlyDictionary DriverPaths { get; init; } + + /// DeviceId → RawPath (absent when the ancestry is broken). + public required IReadOnlyDictionary DevicePaths { get; init; } + + /// TagGroupId → RawPath (absent when the ancestry is broken). + public required IReadOnlyDictionary GroupPaths { get; init; } + + /// DeviceId → DriverInstanceId — the raw tag's owning driver (for RawTagPlan). + public required IReadOnlyDictionary DriverIdByDevice { get; init; } + + public static RawTopology Build( + IReadOnlyList? rawFolders, + IReadOnlyList driverInstances, + IReadOnlyList? devices, + IReadOnlyList? tagGroups) + { + var folderRows = (rawFolders ?? Array.Empty()) + .GroupBy(f => f.RawFolderId, StringComparer.Ordinal) + .ToDictionary(g => g.Key, g => ((string?)g.First().ParentRawFolderId, g.First().Name), StringComparer.Ordinal); + var driverRows = driverInstances + .GroupBy(d => d.DriverInstanceId, StringComparer.Ordinal) + .ToDictionary(g => g.Key, g => ((string?)g.First().RawFolderId, g.First().Name), StringComparer.Ordinal); + var deviceRows = (devices ?? Array.Empty()) + .GroupBy(d => d.DeviceId, StringComparer.Ordinal) + .ToDictionary(g => g.Key, g => (g.First().DriverInstanceId, g.First().Name), StringComparer.Ordinal); + var groupRows = (tagGroups ?? Array.Empty()) + .GroupBy(t => t.TagGroupId, StringComparer.Ordinal) + .ToDictionary(g => g.Key, g => ((string?)g.First().ParentTagGroupId, g.First().Name), StringComparer.Ordinal); + + var resolver = new RawPathResolver(folderRows, driverRows, deviceRows, groupRows); + + // Container RawPaths — reuse the resolver for driver prefix + memoise folder/device/group chains. + var folderPaths = new Dictionary(StringComparer.Ordinal); + foreach (var id in folderRows.Keys) + ResolveFolder(id, folderRows, folderPaths, 0); + + var driverPaths = new Dictionary(StringComparer.Ordinal); + foreach (var id in driverRows.Keys) + { + var p = resolver.TryBuildDriverPrefix(id); + if (p is not null) driverPaths[id] = p; + } + + var devicePaths = new Dictionary(StringComparer.Ordinal); + foreach (var (id, dev) in deviceRows) + { + if (!driverPaths.TryGetValue(dev.DriverInstanceId, out var driverPrefix)) continue; + try { devicePaths[id] = RawPaths.Combine(driverPrefix, dev.Name); } + catch (ArgumentException) { /* invalid device name — dropped */ } + } + + var groupPaths = new Dictionary(StringComparer.Ordinal); + var groupDeviceById = (tagGroups ?? Array.Empty()) + .GroupBy(t => t.TagGroupId, StringComparer.Ordinal) + .ToDictionary(g => g.Key, g => g.First().DeviceId, StringComparer.Ordinal); + foreach (var id in groupRows.Keys) + ResolveGroup(id, groupRows, groupDeviceById, devicePaths, groupPaths, 0); + + var driverIdByDevice = deviceRows.ToDictionary(kv => kv.Key, kv => kv.Value.DriverInstanceId, StringComparer.Ordinal); + + return new RawTopology + { + Resolver = resolver, + FolderPaths = folderPaths, + DriverPaths = driverPaths, + DevicePaths = devicePaths, + GroupPaths = groupPaths, + DriverIdByDevice = driverIdByDevice, + }; + } + + private const int MaxDepth = 256; + + private static string? ResolveFolder( + string id, + IReadOnlyDictionary rows, + Dictionary memo, + int depth) + { + if (depth > MaxDepth) return null; + if (memo.TryGetValue(id, out var cached)) return cached; + if (!rows.TryGetValue(id, out var row)) return null; + var parentPath = row.ParentId is null ? null : ResolveFolder(row.ParentId, rows, memo, depth + 1); + if (row.ParentId is not null && parentPath is null) return null; // broken parent chain + try + { + var path = RawPaths.Combine(parentPath, row.Name); + memo[id] = path; + return path; + } + catch (ArgumentException) { return null; } + } + + private static string? ResolveGroup( + string id, + IReadOnlyDictionary rows, + IReadOnlyDictionary groupDeviceById, + IReadOnlyDictionary devicePaths, + Dictionary memo, + int depth) + { + if (depth > MaxDepth) return null; + if (memo.TryGetValue(id, out var cached)) return cached; + if (!rows.TryGetValue(id, out var row)) return null; + + string? parentPath; + if (row.ParentId is not null) + { + parentPath = ResolveGroup(row.ParentId, rows, groupDeviceById, devicePaths, memo, depth + 1); + if (parentPath is null) return null; // broken parent chain + } + else + { + // Root group: parent is the owning device's RawPath. + if (!groupDeviceById.TryGetValue(id, out var deviceId) + || !devicePaths.TryGetValue(deviceId, out parentPath)) + return null; + } + + try + { + var path = RawPaths.Combine(parentPath, row.Name); + memo[id] = path; + return path; + } + catch (ArgumentException) { return null; } + } + } } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpacePlan.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpacePlan.cs index b75901a8..8b6cdd26 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpacePlan.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpacePlan.cs @@ -69,6 +69,46 @@ public sealed record AddressSpacePlan( /// public IReadOnlyList RenamedFolders { get; init; } = Array.Empty(); + /// + /// v3 Batch 4 — the Raw subtree's container diff (Folder/Driver/Device/TagGroup nodes), + /// keyed by (the RawPath). A rename of any container changes + /// its RawPath (and every descendant's), so it manifests as remove(old)+add(new) — an OPC UA NodeId + /// is immutable identity. Init-only, defaulting empty, so existing construction sites compile + /// unchanged (consistent with the EquipmentTag diff sets above). + /// + public IReadOnlyList AddedRawContainers { get; init; } = Array.Empty(); + /// + public IReadOnlyList RemovedRawContainers { get; init; } = Array.Empty(); + /// + public IReadOnlyList ChangedRawContainers { get; init; } = Array.Empty(); + + /// + /// v3 Batch 4 — the Raw subtree's tag diff, keyed by (the + /// RawPath). A raw tag rename changes its RawPath ⇒ remove(old NodeId)+add(new NodeId) here + /// (the immutable-NodeId rule); an attribute-only edit (historize / writable / array / alarm) keeps + /// the same NodeId ⇒ a . Init-only, defaulting empty. + /// + public IReadOnlyList AddedRawTags { get; init; } = Array.Empty(); + /// + public IReadOnlyList RemovedRawTags { get; init; } = Array.Empty(); + /// + public IReadOnlyList ChangedRawTags { get; init; } = Array.Empty(); + + /// + /// v3 Batch 4 — the UNS subtree's reference-Variable diff, keyed by + /// (the stable row id, NOT the NodeId). Keying on + /// the row id is what makes a backing-raw-tag rename a re-point (same reference row, same + /// effective name ⇒ same UNS NodeId, but — the + /// Organizes target + fan-out source — moves) rather than a remove+add; and a + /// display-name-override edit a (same row id, NodeId + effective name + /// change) with NO raw-side change. Init-only, defaulting empty. + /// + public IReadOnlyList AddedUnsReferenceVariables { get; init; } = Array.Empty(); + /// + public IReadOnlyList RemovedUnsReferenceVariables { get; init; } = Array.Empty(); + /// + public IReadOnlyList ChangedUnsReferenceVariables { get; init; } = Array.Empty(); + /// Gets a value indicating whether the composition plan contains no changes. public bool IsEmpty => AddedEquipment.Count == 0 && RemovedEquipment.Count == 0 && ChangedEquipment.Count == 0 && @@ -76,6 +116,9 @@ public sealed record AddressSpacePlan( AddedAlarms.Count == 0 && RemovedAlarms.Count == 0 && ChangedAlarms.Count == 0 && AddedEquipmentTags.Count == 0 && RemovedEquipmentTags.Count == 0 && ChangedEquipmentTags.Count == 0 && AddedEquipmentVirtualTags.Count == 0 && RemovedEquipmentVirtualTags.Count == 0 && ChangedEquipmentVirtualTags.Count == 0 && + AddedRawContainers.Count == 0 && RemovedRawContainers.Count == 0 && ChangedRawContainers.Count == 0 && + AddedRawTags.Count == 0 && RemovedRawTags.Count == 0 && ChangedRawTags.Count == 0 && + AddedUnsReferenceVariables.Count == 0 && RemovedUnsReferenceVariables.Count == 0 && ChangedUnsReferenceVariables.Count == 0 && RenamedFolders.Count == 0; public sealed record EquipmentDelta(EquipmentNode Previous, EquipmentNode Current); @@ -84,6 +127,16 @@ public sealed record AddressSpacePlan( public sealed record EquipmentTagDelta(EquipmentTagPlan Previous, EquipmentTagPlan Current); public sealed record EquipmentVirtualTagDelta(EquipmentVirtualTagPlan Previous, EquipmentVirtualTagPlan Current); + /// A Raw container node present in both snapshots (same RawPath) with ≥1 differing field. + public sealed record RawContainerDelta(RawContainerNode Previous, RawContainerNode Current); + /// A Raw tag present in both snapshots (same RawPath) with ≥1 differing attribute (historize / + /// writable / array / alarm / referencing-equipment set). A rename is NOT a delta — it is remove+add. + public sealed record RawTagDelta(RawTagPlan Previous, RawTagPlan Current); + /// A UNS reference variable present in both snapshots (same UnsTagReferenceId) with ≥1 differing + /// field — a backing-tag re-point (BackingRawPath moved) or a display-name-override edit (NodeId + + /// effective name changed). + public sealed record UnsReferenceDelta(UnsReferenceVariable Previous, UnsReferenceVariable Current); + /// One renamed UNS Area / Line folder: the stable folder /// (the area's UnsAreaId or line's UnsLineId, the exact /// NodeId MaterialiseHierarchy placed the folder at) and the @@ -139,6 +192,32 @@ public static class AddressSpacePlanner t => t.VirtualTagId, (a, b) => new AddressSpacePlan.EquipmentVirtualTagDelta(a, b)); + // Raw subtree — containers keyed by RawPath (NodeId). A rename changes the RawPath, so it surfaces + // as remove(old)+add(new) here (an OPC UA NodeId is immutable identity). Attribute-only container + // changes (none today — a container carries only DisplayName == leaf name, which lives in the + // RawPath) fall to Changed, kept for completeness / future fields. + var (addedRawContainers, removedRawContainers, changedRawContainers) = DiffById( + previous.RawContainers, next.RawContainers, + c => c.NodeId, + (a, b) => new AddressSpacePlan.RawContainerDelta(a, b)); + + // Raw subtree — tags keyed by RawPath (NodeId). A rename ⇒ remove(old NodeId)+add(new NodeId); an + // attribute-only edit (historize / writable / array / alarm) ⇒ Changed. RawTagPlan overrides record + // equality to compare ReferencingEquipmentPaths element-wise so a no-op redeploy diffs empty. + var (addedRawTags, removedRawTags, changedRawTags) = DiffById( + previous.RawTags, next.RawTags, + t => t.NodeId, + (a, b) => new AddressSpacePlan.RawTagDelta(a, b)); + + // UNS subtree — reference variables keyed by the STABLE UnsTagReferenceId (NOT the NodeId). Keying on + // the row id is what makes a backing-raw-tag rename a re-point (same row + effective name ⇒ same UNS + // NodeId, but BackingRawPath moves) rather than a remove+add, and a display-name-override edit a + // Changed delta (same row id, NodeId + effective name changed) with no raw-side entry. + var (addedUnsRefs, removedUnsRefs, changedUnsRefs) = DiffById( + previous.UnsReferenceVariables, next.UnsReferenceVariables, + v => v.UnsTagReferenceId, + (a, b) => new AddressSpacePlan.UnsReferenceDelta(a, b)); + // UNS Area / Line renames: a folder whose stable id is unchanged but whose // DisplayName differs. Diffed by stable id (UnsAreaId / UnsLineId) so an Area/Line whose ONLY // change is its friendly name is no longer a silent no-op at the IsEmpty gate. The folder NodeId @@ -159,6 +238,15 @@ public static class AddressSpacePlanner AddedEquipmentVirtualTags = addedVTags, RemovedEquipmentVirtualTags = removedVTags, ChangedEquipmentVirtualTags = changedVTags, + AddedRawContainers = addedRawContainers, + RemovedRawContainers = removedRawContainers, + ChangedRawContainers = changedRawContainers, + AddedRawTags = addedRawTags, + RemovedRawTags = removedRawTags, + ChangedRawTags = changedRawTags, + AddedUnsReferenceVariables = addedUnsRefs, + RemovedUnsReferenceVariables = removedUnsRefs, + ChangedUnsReferenceVariables = changedUnsRefs, RenamedFolders = renamedFolders, }; } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/EquipmentNamespaceMaterializationTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/EquipmentNamespaceMaterializationTests.cs index 03fd19ca..5e862830 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/EquipmentNamespaceMaterializationTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/EquipmentNamespaceMaterializationTests.cs @@ -4,10 +4,12 @@ using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Commons.Interfaces; using ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin; +using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; using ZB.MOM.WW.OtOpcUa.Configuration; using ZB.MOM.WW.OtOpcUa.Configuration.Entities; using ZB.MOM.WW.OtOpcUa.Configuration.Enums; using ZB.MOM.WW.OtOpcUa.Configuration.Validation; +using ZB.MOM.WW.OtOpcUa.OpcUaServer; using ZB.MOM.WW.OtOpcUa.Runtime.Drivers; namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests; @@ -15,15 +17,21 @@ namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests; /// /// End-to-end deploy of a UNS folder hierarchy through the real ConfigComposer: seed a /// 1-area / 1-line / 1-equipment UNS plus a v3 raw-tag chain (RawFolder → DriverInstance → Device → -/// Tag), StartDeployment, then assert the deployment's persisted artifact decodes (via +/// Tag) + a projecting the raw tag into the equipment, StartDeployment, +/// then assert the deployment's persisted artifact decodes (via /// ). /// -/// v3 DARK address space (Batch 1): the composer + artifact deliberately emit an EMPTY -/// equipment-tag plan set — raw-tag variable nodes are lit up in Batch 4's dual-namespace UNS↔Raw -/// fan-out. So the live assertion here is the DARK contract: the Area/Line/Equipment folder nodes -/// decode with their friendly UNS names, and EquipmentTags is empty EVEN THOUGH a raw tag was -/// seeded. The full equipment-signal materialization (raw tag → EquipmentTag with FullName) is -/// preserved as a Batch-4-pending Skipped test below (the plan explicitly says keep it, don't delete). +/// v3 dual namespace (Batch 4): the AddressSpaceComposer un-darkens BOTH subtrees. The +/// case pins the +/// artifact-decode invariant that the retired equipment-namespace EquipmentTags set stays EMPTY +/// (values now flow through the raw + UNS variable nodes). The +/// case drives the +/// real AddressSpaceComposer over the SAME seeded config (loaded back from the deploy DB) and pins +/// the Batch-4 dual-tree: the seeded raw tag surfaces as a Raw-realm Variable keyed by its RawPath, and the +/// UnsTagReference surfaces as a UNS-realm Variable carrying that RawPath (the Organizes target + fan-out). +/// Note: the composer is what WP1 lights up; the artifact-decode seam +/// (DeploymentArtifact.ParseComposition) is migrated by WP3, so the dual-tree assertion runs through +/// the composer directly rather than the full deploy → artifact round-trip. /// /// /// The OPC UA address-space browse is exercised separately against a real SDK node manager in @@ -33,12 +41,6 @@ namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests; /// public sealed class EquipmentNamespaceMaterializationTests { - private const string Batch4Pending = - "v3 dark address space: equipment-tag variable plans (raw tag → EquipmentTag with FullName) do " + - "not materialize until Batch 4 (dual-namespace UNS↔Raw fan-out). The composer + artifact emit an " + - "empty equipment-tag plan set this batch; re-enable this full-materialization assertion (re-authored " + - "against the UnsTagReference fan-out) when Batch 4 lights the raw/UNS variable nodes."; - private static CancellationToken Ct => TestContext.Current.CancellationToken; // Equipment.EquipmentId must equal DraftValidator.DeriveEquipmentId(EquipmentUuid) — the @@ -95,47 +97,49 @@ public sealed class EquipmentNamespaceMaterializationTests composition.EquipmentTags.ShouldBeEmpty(); } - /// BATCH-4-PENDING: the full equipment-signal materialization — the seeded raw tag decodes - /// (via the real ConfigComposer → ParseComposition) into an EquipmentTag carrying its FullName. Dark - /// until Batch 4's UNS↔Raw fan-out; preserved (not deleted) so re-enabling is a one-line change once - /// the equipment-tag plan set is lit up. - [Fact(Skip = Batch4Pending)] - public async Task Deploying_an_equipment_namespace_carries_the_signal_into_the_artifact() + /// BATCH-4 dual namespace: the real over the seeded config + /// (loaded back from the deploy DB) emits BOTH subtrees — the seeded raw tag as a Raw-realm Variable + /// keyed by its RawPath, and the UnsTagReference as a UNS-realm Variable carrying that RawPath (the + /// Organizes UNS→Raw target + fan-out source) and inheriting the raw tag's DataType. Drives the composer + /// directly (not the artifact-decode round-trip, which WP3 migrates). + [Fact] + public async Task Composing_the_seeded_config_emits_the_raw_tag_and_uns_reference_variables() { await using var harness = await TwoNodeClusterHarness.StartAsync(); await harness.SeedDefaultClusterAsync("c1"); await SeedUnsHierarchyAsync(harness); - await using var scope = harness.NodeA.Services.CreateAsyncScope(); - var client = scope.ServiceProvider.GetRequiredService(); + await using var db = await CreateDbAsync(harness); + var composition = AddressSpaceComposer.Compose( + await db.UnsAreas.AsNoTracking().ToListAsync(Ct), + await db.UnsLines.AsNoTracking().ToListAsync(Ct), + await db.Equipment.AsNoTracking().ToListAsync(Ct), + await db.DriverInstances.AsNoTracking().ToListAsync(Ct), + await db.ScriptedAlarms.AsNoTracking().ToListAsync(Ct), + unsTagReferences: await db.UnsTagReferences.AsNoTracking().ToListAsync(Ct), + tags: await db.Tags.AsNoTracking().ToListAsync(Ct), + rawFolders: await db.RawFolders.AsNoTracking().ToListAsync(Ct), + devices: await db.Devices.AsNoTracking().ToListAsync(Ct), + tagGroups: await db.TagGroups.AsNoTracking().ToListAsync(Ct)); - var result = await client.StartDeploymentAsync(createdBy: "alice@test", Ct); - result.Outcome.ShouldBe(StartDeploymentOutcome.Accepted, $"Deploy not accepted: {result.Message}"); - var deploymentId = result.DeploymentId!.Value.Value; + // Raw subtree: the seeded raw tag is a Raw-realm Variable keyed by its RawPath (Plant/Modbus/dev-1/Speed). + var rawTag = composition.RawTags.ShouldHaveSingleItem(); + rawTag.TagId.ShouldBe("tag-speed"); + rawTag.Realm.ShouldBe(AddressSpaceRealm.Raw); + rawTag.NodeId.ShouldBe("Plant/Modbus/dev-1/Speed"); + rawTag.DataType.ShouldBe("Float"); - var artifact = Array.Empty(); - await WaitForAsync(async () => - { - await using var db = await CreateDbAsync(harness); - var d = await db.Deployments.AsNoTracking() - .FirstOrDefaultAsync(x => x.DeploymentId == deploymentId, Ct); - if (d is { ArtifactBlob.Length: > 0 }) - { - artifact = d.ArtifactBlob; - return true; - } - return false; - }, TimeSpan.FromSeconds(15)); + // UNS subtree: the UnsTagReference is a UNS-realm Variable carrying the backing RawPath + inherited type. + var unsVar = composition.UnsReferenceVariables.ShouldHaveSingleItem(); + unsVar.Realm.ShouldBe(AddressSpaceRealm.Uns); + unsVar.EquipmentId.ShouldBe(EquipmentId); + unsVar.NodeId.ShouldBe("filling/line-1/station-1/Speed"); + unsVar.EffectiveName.ShouldBe("Speed"); + unsVar.BackingRawPath.ShouldBe("Plant/Modbus/dev-1/Speed"); // Organizes UNS→Raw + fan-out + unsVar.DataType.ShouldBe("Float"); - var composition = DeploymentArtifact.ParseComposition(artifact); - - // Batch 4: the raw tag surfaces as an EquipmentTag with FullName pulled from TagConfig. - var tag = composition.EquipmentTags.ShouldHaveSingleItem(); - tag.TagId.ShouldBe("tag-speed"); - tag.EquipmentId.ShouldBe(EquipmentId); - tag.Name.ShouldBe("Speed"); - tag.DataType.ShouldBe("Float"); - tag.FullName.ShouldBe("40001"); + // The retired equipment-namespace tag path stays empty (values flow through raw + UNS now). + composition.EquipmentTags.ShouldBeEmpty(); } private static async Task SeedUnsHierarchyAsync(TwoNodeClusterHarness harness) @@ -170,6 +174,11 @@ public sealed class EquipmentNamespaceMaterializationTests Name = "Speed", DataType = "Float", AccessLevel = TagAccessLevel.Read, TagConfig = "{\"FullName\":\"40001\"}", }); + // v3 Batch 4: the equipment projects the raw tag by reference (reference-only UNS). + db.UnsTagReferences.Add(new UnsTagReference + { + UnsTagReferenceId = "ref-speed", EquipmentId = EquipmentId, TagId = "tag-speed", + }); await db.SaveChangesAsync(Ct); } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceChangeClassifierDualNamespaceTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceChangeClassifierDualNamespaceTests.cs new file mode 100644 index 00000000..28f758df --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceChangeClassifierDualNamespaceTests.cs @@ -0,0 +1,103 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; + +namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests; + +/// +/// v3 Batch 4 (B4-WP1) — the classifier folds the Raw + UNS subtree diff sets into its routing policy: +/// raw/uns adds count as adds (PureAdd), removes as removes (PureRemove), and any Changed raw/uns delta +/// routes to Rebuild (the safe default — WP3's applier owns surgical refinement). +/// +public sealed class AddressSpaceChangeClassifierDualNamespaceTests +{ + private static readonly RawContainerNode Folder = new("Plant", null, "Plant", RawNodeKind.Folder); + + private static RawTagPlan RawTag(string nodeId) => + new("t-1", nodeId, "Plant/Modbus1/PLC-A", "drv-1", "Speed", "Float", + Writable: false, Alarm: null, ReferencingEquipmentPaths: Array.Empty()); + + private static UnsReferenceVariable UnsRef(string nodeId) => + new("ref-1", "eq-1", nodeId, "Speed", "Plant/Modbus1/PLC-A/Speed", "Float", Writable: false); + + private static AddressSpacePlan Empty() => new( + Array.Empty(), Array.Empty(), Array.Empty(), + Array.Empty(), Array.Empty(), Array.Empty(), + Array.Empty(), Array.Empty(), Array.Empty()); + + [Fact] + public void Added_raw_tag_only_is_PureAdd() + { + var plan = Empty() with { AddedRawTags = new[] { RawTag("Plant/Modbus1/PLC-A/Speed") } }; + AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.PureAdd); + } + + [Fact] + public void Added_raw_container_only_is_PureAdd() + { + var plan = Empty() with { AddedRawContainers = new[] { Folder } }; + AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.PureAdd); + } + + [Fact] + public void Added_uns_reference_only_is_PureAdd() + { + var plan = Empty() with { AddedUnsReferenceVariables = new[] { UnsRef("filling/line-1/station-1/Speed") } }; + AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.PureAdd); + } + + [Fact] + public void Removed_raw_tag_only_is_PureRemove() + { + var plan = Empty() with { RemovedRawTags = new[] { RawTag("Plant/Modbus1/PLC-A/Speed") } }; + AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.PureRemove); + } + + [Fact] + public void Removed_uns_reference_only_is_PureRemove() + { + var plan = Empty() with { RemovedUnsReferenceVariables = new[] { UnsRef("filling/line-1/station-1/Speed") } }; + AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.PureRemove); + } + + [Fact] + public void Raw_rename_add_and_remove_is_AddRemoveMix() + { + var plan = Empty() with + { + RemovedRawTags = new[] { RawTag("Plant/Modbus1/PLC-A/Speed") }, + AddedRawTags = new[] { RawTag("Plant/Modbus1/PLC-A/Velocity") }, + }; + AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.AddRemoveMix); + } + + [Fact] + public void Changed_raw_tag_is_Rebuild() + { + var plan = Empty() with + { + ChangedRawTags = new[] + { + new AddressSpacePlan.RawTagDelta( + RawTag("Plant/Modbus1/PLC-A/Speed"), + RawTag("Plant/Modbus1/PLC-A/Speed") with { IsHistorized = true }), + }, + }; + AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.Rebuild); + } + + [Fact] + public void Uns_reference_repoint_is_Rebuild() + { + var plan = Empty() with + { + ChangedUnsReferenceVariables = new[] + { + new AddressSpacePlan.UnsReferenceDelta( + UnsRef("filling/line-1/station-1/Speed"), + UnsRef("filling/line-1/station-1/Speed") with { BackingRawPath = "Plant/Modbus1/PLC-A/Velocity" }), + }, + }; + AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.Rebuild); + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceComposerDualNamespaceTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceComposerDualNamespaceTests.cs new file mode 100644 index 00000000..c22cdf79 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceComposerDualNamespaceTests.cs @@ -0,0 +1,208 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; +using ZB.MOM.WW.OtOpcUa.Configuration.Entities; +using ZB.MOM.WW.OtOpcUa.Configuration.Enums; + +namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests; + +/// +/// v3 Batch 4 (B4-WP1) — the composer un-darkens BOTH subtrees. Verifies the Raw subtree +/// (Folder→Driver→Device→TagGroup container nodes + raw-tag Variables keyed s=<RawPath>, +/// realm ) and the UNS subtree (one Variable per +/// keyed s=<Area>/<Line>/<Equipment>/<EffectiveName>, +/// realm , carrying its backing RawPath for the Organizes ref + +/// fan-out). Native-alarm intents attach at the RAW tag and carry the referencing equipment paths. +/// +public sealed class AddressSpaceComposerDualNamespaceTests +{ + // A raw chain: RawFolder "Plant" → Driver "Modbus1" → Device "PLC-A" → Group "Motors" → tags. + // Speed sits under the group; Status directly under the device; Levels is an array tag. + private static AddressSpaceComposition ComposeFullFixture() + { + var folder = new RawFolder { RawFolderId = "raw-plant", ClusterId = "c1", Name = "Plant" }; + var driver = new DriverInstance + { + DriverInstanceId = "drv-modbus", ClusterId = "c1", RawFolderId = "raw-plant", + Name = "Modbus1", DriverType = "Modbus", DriverConfig = "{}", + }; + var device = new Device { DeviceId = "dev-1", DriverInstanceId = "drv-modbus", Name = "PLC-A", DeviceConfig = "{}" }; + var group = new TagGroup { TagGroupId = "g1", DeviceId = "dev-1", Name = "Motors" }; + + var speed = new Tag + { + TagId = "t-speed", DeviceId = "dev-1", TagGroupId = "g1", Name = "Speed", + DataType = "Float", AccessLevel = TagAccessLevel.ReadWrite, + TagConfig = "{\"isHistorized\":true,\"historianTagname\":\"speed_hist\"," + + "\"alarm\":{\"alarmType\":\"LimitAlarm\",\"severity\":700}}", + }; + var status = new Tag + { + TagId = "t-status", DeviceId = "dev-1", Name = "Status", + DataType = "Boolean", AccessLevel = TagAccessLevel.Read, TagConfig = "{}", + }; + var levels = new Tag + { + TagId = "t-levels", DeviceId = "dev-1", Name = "Levels", + DataType = "Int32", AccessLevel = TagAccessLevel.Read, + TagConfig = "{\"isArray\":true,\"arrayLength\":8}", + }; + + 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", UnsLineId = "line-1", Name = "station-1", MachineCode = "STATION_001" }; + + var refSpeed = new UnsTagReference { UnsTagReferenceId = "ref-speed", EquipmentId = "eq-1", TagId = "t-speed" }; + var refStatus = new UnsTagReference + { + UnsTagReferenceId = "ref-status", EquipmentId = "eq-1", TagId = "t-status", + DisplayNameOverride = "MachineStatus", + }; + + return AddressSpaceComposer.Compose( + new[] { area }, new[] { line }, new[] { equip }, + new[] { driver }, Array.Empty(), + unsTagReferences: new[] { refSpeed, refStatus }, + tags: new[] { speed, status, levels }, + rawFolders: new[] { folder }, + devices: new[] { device }, + tagGroups: new[] { group }); + } + + /// The Raw subtree emits Folder/Driver/Device/TagGroup container nodes keyed by RawPath, each + /// realm=Raw, with parents-before-children ordering + correct parent NodeIds. + [Fact] + public void Raw_container_nodes_materialise_with_rawpath_nodeids_and_parents() + { + var c = ComposeFullFixture(); + + c.RawContainers.ShouldAllBe(n => n.Realm == AddressSpaceRealm.Raw); + + var folder = c.RawContainers.Single(n => n.Kind == RawNodeKind.Folder); + folder.NodeId.ShouldBe("Plant"); + folder.ParentNodeId.ShouldBeNull(); // cluster root + folder.DisplayName.ShouldBe("Plant"); + + var driver = c.RawContainers.Single(n => n.Kind == RawNodeKind.Driver); + driver.NodeId.ShouldBe("Plant/Modbus1"); + driver.ParentNodeId.ShouldBe("Plant"); + + var device = c.RawContainers.Single(n => n.Kind == RawNodeKind.Device); + device.NodeId.ShouldBe("Plant/Modbus1/PLC-A"); + device.ParentNodeId.ShouldBe("Plant/Modbus1"); + + var group = c.RawContainers.Single(n => n.Kind == RawNodeKind.TagGroup); + group.NodeId.ShouldBe("Plant/Modbus1/PLC-A/Motors"); + group.ParentNodeId.ShouldBe("Plant/Modbus1/PLC-A"); + + // Sorted by NodeId ordinal ⇒ a parent always precedes each of its children. + c.RawContainers.Select(n => n.NodeId) + .ShouldBe(new[] { "Plant", "Plant/Modbus1", "Plant/Modbus1/PLC-A", "Plant/Modbus1/PLC-A/Motors" }); + } + + /// Raw tags are Variables keyed s=<RawPath> (realm=Raw), carrying DataType / + /// writable / historize + historian tagname / array shape, and hang under their group (or device). + [Fact] + public void Raw_tags_materialise_as_variables_keyed_by_rawpath_with_carried_attributes() + { + var c = ComposeFullFixture(); + + c.RawTags.ShouldAllBe(t => t.Realm == AddressSpaceRealm.Raw); + + var speed = c.RawTags.Single(t => t.TagId == "t-speed"); + speed.NodeId.ShouldBe("Plant/Modbus1/PLC-A/Motors/Speed"); + speed.ParentNodeId.ShouldBe("Plant/Modbus1/PLC-A/Motors"); + speed.DriverInstanceId.ShouldBe("drv-modbus"); + speed.DataType.ShouldBe("Float"); + speed.Writable.ShouldBeTrue(); + speed.IsHistorized.ShouldBeTrue(); + speed.HistorianTagname.ShouldBe("speed_hist"); + + var status = c.RawTags.Single(t => t.TagId == "t-status"); + status.NodeId.ShouldBe("Plant/Modbus1/PLC-A/Status"); // no group ⇒ under the device + status.ParentNodeId.ShouldBe("Plant/Modbus1/PLC-A"); + status.Writable.ShouldBeFalse(); + status.IsHistorized.ShouldBeFalse(); + + var levels = c.RawTags.Single(t => t.TagId == "t-levels"); + levels.IsArray.ShouldBeTrue(); + levels.ArrayLength.ShouldBe(8u); + } + + /// A native-alarm intent attaches at the RAW tag (ConditionId = RawPath) and carries the list + /// of referencing equipment UNS folder paths — one alarm at the raw tag, not one per equipment. + [Fact] + public void Native_alarm_attaches_at_raw_tag_with_referencing_equipment_paths() + { + var c = ComposeFullFixture(); + + var speed = c.RawTags.Single(t => t.TagId == "t-speed"); + speed.Alarm.ShouldNotBeNull(); + speed.Alarm!.AlarmType.ShouldBe("LimitAlarm"); + speed.Alarm.Severity.ShouldBe(700); + speed.ReferencingEquipmentPaths.ShouldBe(new[] { "filling/line-1/station-1" }); + + // A non-alarm tag carries no condition + its own referencing-equipment list. + var status = c.RawTags.Single(t => t.TagId == "t-status"); + status.Alarm.ShouldBeNull(); + status.ReferencingEquipmentPaths.ShouldBe(new[] { "filling/line-1/station-1" }); + + // An unreferenced tag has an empty referencing-equipment set. + var levels = c.RawTags.Single(t => t.TagId == "t-levels"); + levels.ReferencingEquipmentPaths.ShouldBeEmpty(); + } + + /// Each UnsTagReference emits a UNS Variable keyed + /// s=<Area>/<Line>/<Equipment>/<EffectiveName> (realm=Uns), carrying its backing + /// RawPath (the Organizes target + fan-out) and inheriting DataType + writable from the raw tag. Effective + /// name = DisplayNameOverride else the backing raw tag's Name. + [Fact] + public void Uns_reference_variables_carry_backing_rawpath_and_inherit_type_and_access() + { + var c = ComposeFullFixture(); + + c.UnsReferenceVariables.ShouldAllBe(v => v.Realm == AddressSpaceRealm.Uns); + + var speedRef = c.UnsReferenceVariables.Single(v => v.UnsTagReferenceId == "ref-speed"); + speedRef.EffectiveName.ShouldBe("Speed"); // no override ⇒ backing raw tag Name + speedRef.NodeId.ShouldBe("filling/line-1/station-1/Speed"); + speedRef.BackingRawPath.ShouldBe("Plant/Modbus1/PLC-A/Motors/Speed"); // Organizes UNS→Raw + fan-out + speedRef.DataType.ShouldBe("Float"); + speedRef.Writable.ShouldBeTrue(); // inherited from the ReadWrite raw tag + + var statusRef = c.UnsReferenceVariables.Single(v => v.UnsTagReferenceId == "ref-status"); + statusRef.EffectiveName.ShouldBe("MachineStatus"); // display-name override wins + statusRef.NodeId.ShouldBe("filling/line-1/station-1/MachineStatus"); + statusRef.BackingRawPath.ShouldBe("Plant/Modbus1/PLC-A/Status"); + statusRef.DataType.ShouldBe("Boolean"); + statusRef.Writable.ShouldBeFalse(); // inherited from the Read raw tag + } + + /// A composition with no raw/UNS inputs leaves all three new subtree sets empty (the legacy + /// convenience overloads + earlier callers keep working). + [Fact] + public void No_raw_or_uns_inputs_leaves_both_subtrees_empty() + { + var c = AddressSpaceComposer.Compose( + equipment: Array.Empty(), + driverInstances: Array.Empty(), + scriptedAlarms: Array.Empty()); + + c.RawContainers.ShouldBeEmpty(); + c.RawTags.ShouldBeEmpty(); + c.UnsReferenceVariables.ShouldBeEmpty(); + } + + /// The Raw + UNS emit is deterministic — repeated calls produce element-identical output + /// (RawTagPlan/UnsReferenceVariable compare by value, so ShouldBe is a content comparison). + [Fact] + public void Dual_subtree_emit_is_deterministic() + { + var a = ComposeFullFixture(); + var b = ComposeFullFixture(); + + a.RawContainers.ShouldBe(b.RawContainers); + a.RawTags.ShouldBe(b.RawTags); + a.UnsReferenceVariables.ShouldBe(b.UnsReferenceVariables); + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpacePlannerDualNamespaceTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpacePlannerDualNamespaceTests.cs new file mode 100644 index 00000000..b63807f9 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpacePlannerDualNamespaceTests.cs @@ -0,0 +1,234 @@ +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; + +/// +/// v3 Batch 4 (B4-WP1) — the planner diffs per realm. Raw containers + raw tags diff by RawPath +/// (NodeId), so a rename is remove(old)+add(new); UNS reference variables diff by the stable +/// UnsTagReferenceId, so a backing-tag rename is a re-point (BackingRawPath moves, NodeId stable) +/// and a display-name-override edit is a UNS-only change with no raw delta. +/// The pinned invariant: a raw-tag rename = remove+add in Raw AND re-point in UNS. +/// +public sealed class AddressSpacePlannerDualNamespaceTests +{ + private const string RawFolderName = "Plant"; + private const string DriverName = "Modbus1"; + private const string DeviceName = "PLC-A"; + + // Compose a fixture with one raw tag (given name) under Plant/Modbus1/PLC-A and one UNS reference to it + // (with an optional display-name override so the effective name can be held stable across a raw rename). + private static AddressSpaceComposition Compose(string tagName, string? displayOverride) + { + var folder = new RawFolder { RawFolderId = "raw-plant", ClusterId = "c1", Name = RawFolderName }; + var driver = new DriverInstance + { + DriverInstanceId = "drv-modbus", ClusterId = "c1", RawFolderId = "raw-plant", + Name = DriverName, DriverType = "Modbus", DriverConfig = "{}", + }; + var device = new Device { DeviceId = "dev-1", DriverInstanceId = "drv-modbus", Name = DeviceName, DeviceConfig = "{}" }; + var tag = new Tag + { + TagId = "t-speed", DeviceId = "dev-1", Name = tagName, + DataType = "Float", AccessLevel = TagAccessLevel.ReadWrite, TagConfig = "{}", + }; + 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", UnsLineId = "line-1", Name = "station-1", MachineCode = "STATION_001" }; + var reference = new UnsTagReference + { + UnsTagReferenceId = "ref-1", EquipmentId = "eq-1", TagId = "t-speed", + DisplayNameOverride = displayOverride, + }; + + return AddressSpaceComposer.Compose( + new[] { area }, new[] { line }, new[] { equip }, + new[] { driver }, Array.Empty(), + unsTagReferences: new[] { reference }, + tags: new[] { tag }, + rawFolders: new[] { folder }, + devices: new[] { device }); + } + + /// A newly-added raw tag surfaces in AddedRawTags (keyed by RawPath); the plan is non-empty. + [Fact] + public void Added_raw_tag_goes_to_AddedRawTags() + { + var prev = Compose("Speed", displayOverride: null); + var next = Compose("Speed", displayOverride: null); + // prev without the tag: strip RawTags/UNS by composing an empty raw side. + var empty = AddressSpaceComposer.Compose( + Array.Empty(), Array.Empty(), Array.Empty(), + Array.Empty(), Array.Empty()); + + var plan = AddressSpacePlanner.Compute(empty, next); + + plan.IsEmpty.ShouldBeFalse(); + plan.AddedRawTags.Select(t => t.NodeId).ShouldContain("Plant/Modbus1/PLC-A/Speed"); + plan.RemovedRawTags.ShouldBeEmpty(); + _ = prev; + } + + /// A disappeared raw tag surfaces in RemovedRawTags. + [Fact] + public void Removed_raw_tag_goes_to_RemovedRawTags() + { + var prev = Compose("Speed", displayOverride: null); + var empty = AddressSpaceComposer.Compose( + Array.Empty(), Array.Empty(), Array.Empty(), + Array.Empty(), Array.Empty()); + + var plan = AddressSpacePlanner.Compute(prev, empty); + + plan.RemovedRawTags.Select(t => t.NodeId).ShouldContain("Plant/Modbus1/PLC-A/Speed"); + plan.AddedRawTags.ShouldBeEmpty(); + } + + /// A newly-added UNS reference surfaces in AddedUnsReferenceVariables. + [Fact] + public void Added_uns_reference_goes_to_AddedUnsReferenceVariables() + { + var empty = AddressSpaceComposer.Compose( + Array.Empty(), Array.Empty(), Array.Empty(), + Array.Empty(), Array.Empty()); + var next = Compose("Speed", displayOverride: null); + + var plan = AddressSpacePlanner.Compute(empty, next); + + plan.AddedUnsReferenceVariables.Single().UnsTagReferenceId.ShouldBe("ref-1"); + plan.RemovedUnsReferenceVariables.ShouldBeEmpty(); + } + + /// A disappeared UNS reference surfaces in RemovedUnsReferenceVariables. + [Fact] + public void Removed_uns_reference_goes_to_RemovedUnsReferenceVariables() + { + var prev = Compose("Speed", displayOverride: null); + var empty = AddressSpaceComposer.Compose( + Array.Empty(), Array.Empty(), Array.Empty(), + Array.Empty(), Array.Empty()); + + var plan = AddressSpacePlanner.Compute(prev, empty); + + plan.RemovedUnsReferenceVariables.Single().UnsTagReferenceId.ShouldBe("ref-1"); + plan.AddedUnsReferenceVariables.ShouldBeEmpty(); + } + + /// THE PIN: a raw-tag rename = remove+add in Raw AND re-point in UNS. With a display-name + /// override holding the effective name stable, the reference row is unchanged (same UnsTagReferenceId, + /// same UNS NodeId) but its backing NodeId moved ⇒ the UNS variable's Organizes target (BackingRawPath) + /// re-points via a ChangedUnsReferenceVariables delta. + [Fact] + public void Raw_rename_is_remove_add_in_raw_and_repoint_in_uns() + { + var prev = Compose("Speed", displayOverride: "Spd"); + var next = Compose("Velocity", displayOverride: "Spd"); // raw tag renamed; effective name held stable + + var plan = AddressSpacePlanner.Compute(prev, next); + + // Raw realm: remove the old RawPath, add the new one (immutable NodeId ⇒ not a Changed delta). + plan.RemovedRawTags.Select(t => t.NodeId).ShouldBe(new[] { "Plant/Modbus1/PLC-A/Speed" }); + plan.AddedRawTags.Select(t => t.NodeId).ShouldBe(new[] { "Plant/Modbus1/PLC-A/Velocity" }); + plan.ChangedRawTags.ShouldBeEmpty(); + + // UNS realm: the reference row is unchanged in identity + NodeId, but re-points to the new RawPath. + plan.AddedUnsReferenceVariables.ShouldBeEmpty(); + plan.RemovedUnsReferenceVariables.ShouldBeEmpty(); + var repoint = plan.ChangedUnsReferenceVariables.ShouldHaveSingleItem(); + repoint.Previous.UnsTagReferenceId.ShouldBe("ref-1"); + repoint.Current.UnsTagReferenceId.ShouldBe("ref-1"); + repoint.Previous.NodeId.ShouldBe(repoint.Current.NodeId); // effective name stable ⇒ NodeId stable + repoint.Previous.BackingRawPath.ShouldBe("Plant/Modbus1/PLC-A/Speed"); + repoint.Current.BackingRawPath.ShouldBe("Plant/Modbus1/PLC-A/Velocity"); + + // The raw container topology (Folder/Driver/Device) is untouched by a tag rename. + plan.AddedRawContainers.ShouldBeEmpty(); + plan.RemovedRawContainers.ShouldBeEmpty(); + plan.ChangedRawContainers.ShouldBeEmpty(); + } + + /// A display-name-override change is a UNS-only change (no raw delta): same reference row id, + /// the effective name + UNS NodeId move, the backing RawPath is unchanged. + [Fact] + public void Display_name_override_change_is_uns_only_no_raw_change() + { + var prev = Compose("Speed", displayOverride: "Spd"); + var next = Compose("Speed", displayOverride: "Speedy"); // only the override changed + + var plan = AddressSpacePlanner.Compute(prev, next); + + // No raw-side change whatsoever. + plan.AddedRawTags.ShouldBeEmpty(); + plan.RemovedRawTags.ShouldBeEmpty(); + plan.ChangedRawTags.ShouldBeEmpty(); + plan.AddedRawContainers.ShouldBeEmpty(); + plan.RemovedRawContainers.ShouldBeEmpty(); + plan.ChangedRawContainers.ShouldBeEmpty(); + + // UNS: same reference row id, effective name + NodeId changed, backing RawPath unchanged. + var delta = plan.ChangedUnsReferenceVariables.ShouldHaveSingleItem(); + delta.Previous.UnsTagReferenceId.ShouldBe("ref-1"); + delta.Previous.EffectiveName.ShouldBe("Spd"); + delta.Current.EffectiveName.ShouldBe("Speedy"); + delta.Previous.NodeId.ShouldNotBe(delta.Current.NodeId); + delta.Previous.BackingRawPath.ShouldBe(delta.Current.BackingRawPath); + plan.AddedUnsReferenceVariables.ShouldBeEmpty(); + plan.RemovedUnsReferenceVariables.ShouldBeEmpty(); + } + + /// An attribute-only raw-tag edit (historize toggle) keeps the same RawPath ⇒ a + /// ChangedRawTags delta (not remove+add). + [Fact] + public void Raw_tag_attribute_edit_keeps_nodeid_and_routes_to_ChangedRawTags() + { + var prev = Compose("Speed", displayOverride: null); + // next: same identity + name, but toggle historize on the raw tag. + var folder = new RawFolder { RawFolderId = "raw-plant", ClusterId = "c1", Name = RawFolderName }; + var driver = new DriverInstance + { + DriverInstanceId = "drv-modbus", ClusterId = "c1", RawFolderId = "raw-plant", + Name = DriverName, DriverType = "Modbus", DriverConfig = "{}", + }; + var device = new Device { DeviceId = "dev-1", DriverInstanceId = "drv-modbus", Name = DeviceName, DeviceConfig = "{}" }; + var tag = new Tag + { + TagId = "t-speed", DeviceId = "dev-1", Name = "Speed", + DataType = "Float", AccessLevel = TagAccessLevel.ReadWrite, TagConfig = "{\"isHistorized\":true}", + }; + var next = AddressSpaceComposer.Compose( + Array.Empty(), Array.Empty(), Array.Empty(), + new[] { driver }, Array.Empty(), + tags: new[] { tag }, rawFolders: new[] { folder }, devices: new[] { device }); + + var prevRawOnly = AddressSpaceComposer.Compose( + Array.Empty(), Array.Empty(), Array.Empty(), + new[] { driver }, Array.Empty(), + tags: new[] { new Tag { TagId = "t-speed", DeviceId = "dev-1", Name = "Speed", DataType = "Float", AccessLevel = TagAccessLevel.ReadWrite, TagConfig = "{}" } }, + rawFolders: new[] { folder }, devices: new[] { device }); + + var plan = AddressSpacePlanner.Compute(prevRawOnly, next); + + plan.AddedRawTags.ShouldBeEmpty(); + plan.RemovedRawTags.ShouldBeEmpty(); + var changed = plan.ChangedRawTags.ShouldHaveSingleItem(); + changed.Previous.NodeId.ShouldBe(changed.Current.NodeId); + changed.Previous.IsHistorized.ShouldBeFalse(); + changed.Current.IsHistorized.ShouldBeTrue(); + _ = prev; + } + + /// Identical compositions diff to an empty plan across both realms (fresh list instances must + /// not spuriously flag RawTags/UnsReferenceVariables as changed). + [Fact] + public void Identical_dual_namespace_compositions_diff_to_empty() + { + var prev = Compose("Speed", displayOverride: "Spd"); + var next = Compose("Speed", displayOverride: "Spd"); + + var plan = AddressSpacePlanner.Compute(prev, next); + + plan.IsEmpty.ShouldBeTrue(); + } +}