From 118933d5d515b356aa79cd39f141c6412b9e6831 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 22 Oct 2026 10:29:54 -0400 Subject: [PATCH] refactor(opcua): delete the dead v3 equipment-tag materialisation (B5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review 01 U2. The composer never emits EquipmentTags in v3 (device values live in the Raw subtree and reach equipment as UnsReferenceVariables), so the equipment-tag plan diff sets (Added/Removed/ChangedEquipmentTags, EquipmentTagDelta), the planner's diff over them, the classifier's TagDeltaIsSurgicalEligible and its Rebuild clause, the applier's MaterialiseEquipmentTags pass, surgical ChangedEquipmentTags loop, removed-tag teardown loop and add-announcement term, and the publish actor's equipment-tag pass were unreachable, maintained dead weight. IsEmpty keeps its semantics (an otherwise-empty plan is still Empty). Kept, deliberately: AddressSpaceComposition.EquipmentTags and the EquipmentTagPlan record — Runtime's DeploymentArtifact (outside this lane) still assigns the empty set and the artifact parity tests assert it; follow-up F2 removes both together. Tests: the equipment-tag vehicle tests for PureRemove / AddRemoveMix / the rebuild ratchet are re-expressed over raw tags (same applier paths); the equipment-tag surgical and classifier tests are deleted (their raw / UNS-reference equivalents already exist); SubscriptionSurvivalTests now materialises its tags through MaterialiseRawSubtree. Refs #732 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT --- .../AddressSpaceApplier.cs | 154 +--- .../AddressSpaceChangeClassifier.cs | 42 +- .../AddressSpacePlan.cs | 37 +- .../OpcUa/OpcUaPublishActor.cs | 20 +- .../SubscriptionSurvivalTests.cs | 87 +- .../AddressSpaceApplierFailureSurfaceTests.cs | 26 +- .../AddressSpaceApplierHierarchyTests.cs | 15 +- .../AddressSpaceApplierTests.cs | 781 ++---------------- .../AddressSpaceChangeClassifierTests.cs | 75 +- .../AddressSpacePlannerTests.cs | 61 -- 10 files changed, 159 insertions(+), 1139 deletions(-) diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs index ce85a7ff..9d42e409 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs @@ -105,23 +105,21 @@ public sealed class AddressSpaceApplier if (!SafeWriteAlarmCondition(alarm.ScriptedAlarmId, RemovedConditionState, ts, AddressSpaceRealm.Uns)) failedNodes++; removedCount++; } - // Removed equipment tags / VirtualTags are plain variable nodes (no Part 9 condition to write - // before tear-down), but they ARE real removals — count them so AddressSpaceApplyOutcome.RemovedNodes - // is accurate on a removed-tag-only deploy, which now reaches the rebuild path below. v3 Batch 4: - // removed raw containers/tags + UNS reference variables are likewise real removals. - removedCount += plan.RemovedEquipmentTags.Count + plan.RemovedEquipmentVirtualTags.Count + + // Removed VirtualTags are plain variable nodes (no Part 9 condition to write before tear-down), but + // they ARE real removals — count them so AddressSpaceApplyOutcome.RemovedNodes is accurate on a + // removed-vtag-only deploy. v3 Batch 4: removed raw containers/tags + UNS reference variables are + // likewise real removals. + removedCount += plan.RemovedEquipmentVirtualTags.Count + plan.RemovedRawContainers.Count + plan.RemovedRawTags.Count + plan.RemovedUnsReferenceVariables.Count; var changedCount = plan.ChangedEquipment.Count + plan.ChangedDrivers.Count + plan.ChangedAlarms.Count + - plan.ChangedEquipmentTags.Count + plan.ChangedEquipmentVirtualTags.Count + plan.ChangedRawContainers.Count + plan.ChangedRawTags.Count + plan.ChangedUnsReferenceVariables.Count + // A UNS Area/Line rename is an in-place change to an existing folder node. plan.RenamedFolders.Count; var addedCount = plan.AddedEquipment.Count + plan.AddedDrivers.Count + plan.AddedAlarms.Count + - plan.AddedEquipmentTags.Count + plan.AddedEquipmentVirtualTags.Count + plan.AddedRawContainers.Count + plan.AddedRawTags.Count + plan.AddedUnsReferenceVariables.Count; @@ -143,7 +141,7 @@ public sealed class AddressSpaceApplier // order even when an id is reused across the remove + add sets (the recreated node is a fresh // NodeState, correct — it is a different tag). Any remove false/throw ⇒ full-rebuild fallback. // • Rebuild ⇒ the default-closed safety valve for any node-affecting change (ChangedEquipment / - // ChangedAlarms / non-surgical ChangedEquipmentTags / node-relevant ChangedEquipmentVirtualTags) + // ChangedAlarms / node-relevant ChangedEquipmentVirtualTags / non-surgical raw-tag deltas) // — the classifier's rule 2, which also catches any future plan field that makes a changed // record unequal, so the worst outcome of a misclassification is today's full rebuild. // ChangedDrivers is node-inert (routes through DriverHostActor's spawn plan) — the classifier leaves @@ -154,7 +152,6 @@ public sealed class AddressSpaceApplier // afterward by the publish actor's Materialise passes + announce). var hasRemovePass = kind is AddressSpaceChangeKind.PureRemove or AddressSpaceChangeKind.AddRemoveMix; - var surgicalTagDeltas = plan.ChangedEquipmentTags.Where(AddressSpaceChangeClassifier.TagDeltaIsSurgicalEligible).ToList(); // UNS Area / Line renames are surgically applicable (in-place DisplayName swap) // when no structural rebuild fires. When a rebuild DOES fire (any add/remove/structural change), // MaterialiseHierarchy re-creates every folder with the new display names, so the renames are @@ -174,7 +171,7 @@ public sealed class AddressSpaceApplier rebuildFailed = !SafeRebuild(); rebuilt = true; } - else if (surgicalTagDeltas.Count > 0 || renamedFolders.Count > 0 || hasRemovePass || + else if (renamedFolders.Count > 0 || hasRemovePass || surgicalRawTagDeltas.Count > 0 || unsRefDeltas.Count > 0) { if (_sink is ISurgicalAddressSpaceSink surgical) @@ -192,25 +189,6 @@ public sealed class AddressSpaceApplier } if (!ok) { allApplied = false; break; } } - foreach (var d in allApplied ? surgicalTagDeltas : Enumerable.Empty()) - { - // Compute the node id + writable + historian + shape EXACTLY as MaterialiseEquipmentTags - // would so the in-place update matches what a rebuild would have produced. Array tags are - // forced read-only (same as EnsureVariable: the driver write path doesn't handle arrays). - var nodeId = UnsEquipmentVar(d.Current.EquipmentId, d.Current.FolderPath, d.Current.Name); - var writable = d.Current.Writable && !d.Current.IsArray; - var historian = d.Current.IsHistorized - ? (string.IsNullOrWhiteSpace(d.Current.HistorianTagname) ? d.Current.FullName : d.Current.HistorianTagname) - : null; - bool ok; - try { ok = surgical.UpdateTagAttributes(nodeId, writable, historian, d.Current.DataType, d.Current.IsArray, d.Current.ArrayLength, AddressSpaceRealm.Uns); } - catch (Exception ex) - { - _logger.LogError(ex, "AddressSpaceApplier: surgical UpdateTagAttributes threw for {Node}", nodeId); - ok = false; - } - if (!ok) { allApplied = false; break; } - } // v3 RAW tags — the same in-place attribute update, in the RAW realm. Adding or dropping ONE // UnsTagReference flips its backing raw tag to Changed purely through ReferencingEquipmentPaths, // and before this path existed that tore down every node on the server for an edit that creates @@ -318,8 +296,8 @@ public sealed class AddressSpaceApplier } _logger.LogInformation( - "AddressSpaceApplier: applied plan (kind={Kind}, added={Added}, removed={Removed}, changed={Changed}, surgicalTags={Surgical}, surgicalRawTags={SurgicalRaw}, unsRefDeltas={UnsRefs}, renamedFolders={Renamed}, rebuild={Rebuild})", - kind, addedCount, removedCount, changedCount, rebuilt ? 0 : surgicalTagDeltas.Count, + "AddressSpaceApplier: applied plan (kind={Kind}, added={Added}, removed={Removed}, changed={Changed}, surgicalRawTags={SurgicalRaw}, unsRefDeltas={UnsRefs}, renamedFolders={Renamed}, rebuild={Rebuild})", + kind, addedCount, removedCount, changedCount, rebuilt ? 0 : surgicalRawTagDeltas.Count, rebuilt ? 0 : unsRefDeltas.Count, rebuilt ? 0 : renamedFolders.Count, rebuilt); @@ -340,10 +318,9 @@ public sealed class AddressSpaceApplier /// Apply a PureRemove plan by tearing down ONLY the affected nodes IN PLACE (no full /// rebuild). Removed equipment "own" their child tags/vtags/alarms, so any removed child whose /// EquipmentId is itself in is SUBSUMED by the - /// subtree removal and skipped individually. For surviving-equipment removals: a removed value + /// subtree removal and skipped individually. For surviving-equipment removals: a removed VirtualTag /// variable gets a terminal Bad WriteValue then ; - /// a removed alarm-bearing tag (previously it got no terminal condition write) and a - /// removed scripted alarm get a terminal write then + /// a removed scripted alarm gets a terminal write then /// ; each removed equipment gets one /// . The terminal write is what an /// in-flight MonitoredItem observes (a final Bad); after the node is gone, re-subscription gets @@ -361,25 +338,6 @@ public sealed class AddressSpaceApplier plan.RemovedEquipment.Select(e => e.EquipmentId), StringComparer.Ordinal); bool NotSubsumed(string equipmentId) => !removedEquipmentIds.Contains(equipmentId); - // Removed equipment tags — value variables OR alarm-bearing conditions — for SURVIVING equipment. - foreach (var tag in plan.RemovedEquipmentTags.Where(t => NotSubsumed(t.EquipmentId))) - { - var nodeId = UnsEquipmentVar(tag.EquipmentId, tag.FolderPath, tag.Name); - if (tag.Alarm is not null) - { - // Alarm-bearing tag → a Part 9 condition node. Terminal RemovedConditionState (today-gap - // closed), then remove the condition in place. - if (!SafeWriteAlarmCondition(nodeId, RemovedConditionState, ts, AddressSpaceRealm.Uns)) failedNodes++; - if (!SafeRemoveAlarmCondition(surgical, nodeId, AddressSpaceRealm.Uns)) return false; - } - else - { - // Plain value variable → terminal Bad so an in-flight MonitoredItem sees the removal. - SafeWriteValue(nodeId, null, OpcUaStatus.Bad, ts, AddressSpaceRealm.Uns); - if (!SafeRemoveVariable(surgical, nodeId, AddressSpaceRealm.Uns)) return false; - } - } - // Removed VirtualTags (always plain value variables) for surviving equipment. foreach (var v in plan.RemovedEquipmentVirtualTags.Where(t => NotSubsumed(t.EquipmentId))) { @@ -548,7 +506,6 @@ public sealed class AddressSpaceApplier ArgumentNullException.ThrowIfNull(plan); var ids = new HashSet(StringComparer.Ordinal); - foreach (var t in plan.AddedEquipmentTags) ids.Add(MaterialiseParent(t.EquipmentId, t.FolderPath)); foreach (var v in plan.AddedEquipmentVirtualTags) ids.Add(MaterialiseParent(v.EquipmentId, v.FolderPath)); foreach (var a in plan.AddedAlarms) ids.Add(a.EquipmentId); // v3 Batch 4 / review I5 — an added UNS reference variable is materialised directly under its @@ -1072,96 +1029,9 @@ public sealed class AddressSpaceApplier return failed; } - /// - /// 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. - /// Idempotent. This is a sink-based pass, NOT a reuse of EquipmentNodeWalker: no - /// sink-backed IAddressSpaceBuilder adapter exists, and the walker re-creates the - /// whole Area/Line/Equipment tree with browse-path NodeIds, incompatible with this path's - /// logical-Id NodeIds and the already-materialised equipment folders. This pass adds ONLY - /// variables (and any per-tag sub-folder); owns the - /// equipment folders and this pass never re-creates them. The sink's EnsureVariable - /// takes a plain string dataType (not a DriverAttributeInfo). - /// Subset overload (2026-08-15 surgical-deploys). narrows WHAT is - /// materialised to a plan's added tags; stays the FULL composition for - /// context. A null subset means "the whole composition" (the rebuild path). - /// - /// The FULL composition — the context even when a subset is passed. - /// Materialise only these tags; null ⇒ the whole composition's. - /// The count of swallowed sink failures in this pass (0 on a clean apply) — the publish actor - /// sums it into the degraded-apply surface (archreview 01/S-1). - public int MaterialiseEquipmentTags( - AddressSpaceComposition composition, - IReadOnlyList? only = null) - { - ArgumentNullException.ThrowIfNull(composition); - var equipmentTags = only ?? composition.EquipmentTags; - if (equipmentTags.Count == 0) return 0; - - var failed = 0; - // Sub-folders first — a tag's FolderPath becomes one folder UNDER its equipment folder - // (deduped per distinct equipment+path). Tags with no FolderPath hang directly under the - // equipment folder, which MaterialiseHierarchy already created (never re-create - // the equipment folder here). - var foldersCreated = new HashSet(StringComparer.Ordinal); - foreach (var tag in equipmentTags) - { - if (string.IsNullOrWhiteSpace(tag.FolderPath)) continue; - var folderNodeId = UnsSubFolder(tag.EquipmentId, tag.FolderPath); - if (!foldersCreated.Add(folderNodeId)) continue; - if (!SafeEnsureFolder(folderNodeId, parentNodeId: tag.EquipmentId, displayName: tag.FolderPath, realm: AddressSpaceRealm.Uns)) failed++; - } - - // Variables: NodeId is FOLDER-SCOPED ("/"), NOT the raw FullName — a driver - // ref (e.g. a Modbus register) is not unique across identical machines, so FullName-as-NodeId - // 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. - // Per-variable idempotency relies on the sink's own EnsureVariable. - foreach (var tag in equipmentTags) - { - var parent = string.IsNullOrWhiteSpace(tag.FolderPath) - ? tag.EquipmentId - : UnsSubFolder(tag.EquipmentId, tag.FolderPath); - var nodeId = UnsEquipmentVar(tag.EquipmentId, tag.FolderPath, tag.Name); - if (tag.Alarm is not null) - { - // Native alarm tag → a real Part 9 condition node (reuses the scripted-alarm path), - // NOT a value variable. Parent is the sub-folder when set, else the equipment folder. - if (!SafeMaterialiseAlarmCondition(nodeId, parent, tag.Name, tag.Alarm.AlarmType, tag.Alarm.Severity, isNative: true, realm: AddressSpaceRealm.Uns)) failed++; - } - else - { - // Phase C: a historized tag materialises Historizing + HistoryRead. Resolve the effective - // historian tagname HERE (default-vs-override): a null/blank override falls back to the - // driver-side FullName; null means the tag is not historized at all. - string? historianTagname = tag.IsHistorized - ? (string.IsNullOrWhiteSpace(tag.HistorianTagname) ? tag.FullName : tag.HistorianTagname) - : null; - // Retired equipment-tag path (structurally empty in v3): array tags stay forced read-only - // here. The live array-write capability is carried on RawTagPlan / - // UnsReferenceVariable only — see RawNodeWritable. - var writable = tag.Writable && !tag.IsArray; - if (!SafeEnsureVariable(nodeId, parent, tag.Name, tag.DataType, writable, AddressSpaceRealm.Uns, historianTagname, tag.IsArray, tag.ArrayLength)) failed++; - } - } - - _logger.LogInformation( - "AddressSpaceApplier: equipment tags materialised (tags={Tags}, equipment={Equipment}, subset={Subset}, failed={Failed})", - equipmentTags.Count, - equipmentTags.Select(t => t.EquipmentId).Distinct(StringComparer.Ordinal).Count(), - only is not null, - failed); - return failed; - } - - /// /// Materialise Equipment-namespace VirtualTags from a composition snapshot — the VirtualTag - /// analogue of . For each , + /// computed-signal pass. For each , /// ensure its optional FolderPath sub-folder under the existing equipment folder (in /// practice FolderPath is empty for VirtualTags, so this is usually a no-op), then ensure /// a Variable inside it. Like the tag pass, the variable's NodeId is FOLDER-SCOPED diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceChangeClassifier.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceChangeClassifier.cs index a64f3055..8d3b97d0 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceChangeClassifier.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceChangeClassifier.cs @@ -8,7 +8,7 @@ public enum AddressSpaceChangeKind /// — nothing to do. Empty, - /// Only surgical-eligible changed equipment/raw tags, surgical UNS-reference deltas, + /// Only surgical-eligible changed raw tags, surgical UNS-reference deltas, /// node-irrelevant vtag changes, folder renames, and/or node-inert driver deltas — the existing F10b /// in-place path. No adds, no removes. AttributeOnly, @@ -27,7 +27,7 @@ public enum AddressSpaceChangeKind AddRemoveMix, /// Anything with a node-affecting change (ChangedEquipment, ChangedAlarms, ChangedRawContainers, - /// non-surgical ChangedEquipmentTags, alarm-bearing/identity-changing ChangedRawTags, node-relevant + /// alarm-bearing/identity-changing ChangedRawTags, node-relevant /// ChangedEquipmentVirtualTags) — the unclassifiable-safe default. Full rebuild. Rebuild, } @@ -53,8 +53,7 @@ public static class AddressSpaceChangeClassifier if (plan.IsEmpty) return AddressSpaceChangeKind.Empty; // 2 — any node-affecting CHANGE forces a full rebuild (default-closed). ChangedEquipment / - // ChangedAlarms / ChangedRawContainers are always node-affecting; a changed equipment tag is - // node-affecting UNLESS it is surgical-eligible, a changed raw tag UNLESS it is + // ChangedAlarms / ChangedRawContainers are always node-affecting; a changed raw tag UNLESS it is // RawTagDeltaIsSurgicalEligible, and a changed vtag UNLESS it is node-irrelevant. Evaluated BEFORE the // add/remove split so a non-surgical change alongside pure adds still rebuilds. // @@ -94,7 +93,6 @@ public static class AddressSpaceChangeClassifier plan.ChangedAlarms.Count > 0 || plan.ChangedRawContainers.Count > 0 || plan.ChangedRawTags.Any(d => !RawTagDeltaIsSurgicalEligible(d)) || - plan.ChangedEquipmentTags.Any(d => !TagDeltaIsSurgicalEligible(d)) || plan.ChangedEquipmentVirtualTags.Any(d => !VtagDeltaIsNodeIrrelevant(d))) { return AddressSpaceChangeKind.Rebuild; @@ -105,12 +103,12 @@ 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 + + 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 + + plan.RemovedEquipmentVirtualTags.Count + plan.RemovedRawContainers.Count + plan.RemovedRawTags.Count + plan.RemovedUnsReferenceVariables.Count > 0; @@ -141,36 +139,6 @@ public static class AddressSpaceChangeClassifier Historize = d.Current.Historize, }).Equals(d.Current); - // F10b: a CHANGED equipment tag whose ONLY differences are Writable / IsHistorized / - // HistorianTagname / DataType / IsArray / ArrayLength (a plain value variable — no alarm condition node) - // can be updated IN PLACE on the existing node via ISurgicalAddressSpaceSink.UpdateTagAttributes, - // avoiding a full rebuild (preserving subscriptions). The presentation-shape fields (DataType / IsArray / - // ArrayLength) join the whitelist now that the surgical sink swaps DataType + ValueRank + ArrayDimensions - // in place (and raises a GeneralModelChangeEvent). FullName / DriverInstanceId / Name / identity / alarm - // differences still fall through to a rebuild — FullName/DriverInstanceId re-route the node to a different - // driver point, Name re-derives the NodeId, and an alarm flip turns the node into a Part 9 condition. The - // override-unequal default also covers any future field. - // REACH (live-verified): the shape path only fires for drivers whose TagConfig carries a stable - // top-level "FullName" (Galaxy = tag_name.AttributeName; OpcUaClient = the node id) — there a DataType/array - // edit leaves FullName untouched ⇒ surgical. For structured-TagConfig protocol drivers (Modbus/S7/AbCip/…) - // TagConfigIntent.Parse falls back to the RAW TagConfig blob as FullName, so a DataType/ - // array edit also mutates that blob ⇒ FullName differs ⇒ this returns false ⇒ full rebuild. That is the - // correct safe default (a protocol driver's subscription needs re-spawning for a new shape anyway). - /// Whether a changed equipment tag can be updated in place on its existing node instead of rebuilding. - /// The equipment-tag delta to classify. - /// when the tag has no alarm and differs only in the in-place-updatable attributes; otherwise . - internal static bool TagDeltaIsSurgicalEligible(AddressSpacePlan.EquipmentTagDelta d) => - d.Previous.Alarm is null && d.Current.Alarm is null && - (d.Previous with - { - Writable = d.Current.Writable, - IsHistorized = d.Current.IsHistorized, - HistorianTagname = d.Current.HistorianTagname, - DataType = d.Current.DataType, - IsArray = d.Current.IsArray, - ArrayLength = d.Current.ArrayLength, - }).Equals(d.Current); - // v3 surgical raw-tag eligibility: a NON-alarm raw tag whose only differences are the in-place-updatable // attributes (Writable / IsHistorized / HistorianTagname / DataType / IsArray / ArrayLength / ArrayWritable) // and/or its ReferencingEquipmentPaths. ArrayWritable only feeds the node's writability — the diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpacePlan.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpacePlan.cs index 8b6cdd26..466a4a37 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpacePlan.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpacePlan.cs @@ -23,28 +23,15 @@ public sealed record AddressSpacePlan( IReadOnlyList RemovedAlarms, IReadOnlyList ChangedAlarms) { - /// - /// Equipment-namespace tag diff sets, keyed by . Added as - /// init-only members (defaulting empty) rather than positional parameters so existing - /// AddressSpacePlan construction sites compile unchanged — consistent with how - /// was added. Without these, an - /// incremental deploy that changes ONLY equipment tags produced an empty plan and - /// OpcUaPublishActor.HandleRebuild short-circuited before materialising them. - /// - public IReadOnlyList AddedEquipmentTags { get; init; } = Array.Empty(); - /// - public IReadOnlyList RemovedEquipmentTags { get; init; } = Array.Empty(); - /// - public IReadOnlyList ChangedEquipmentTags { get; init; } = Array.Empty(); - /// /// Equipment-namespace VirtualTag diff sets, keyed by . - /// The value-side analogue of : a VirtualTag carries an + /// A VirtualTag carries an /// Expression evaluated over DependencyRefs, so a deploy that changes ONLY /// VirtualTags (e.g. a new computed signal or an edited formula) must still produce a /// non-empty plan and drive a rebuild — without these the diff was blind to VirtualTags and - /// such a deploy silently no-op'd. Added as init-only members (defaulting empty) for the same - /// compile-compatibility reason as . + /// such a deploy silently no-op'd. Added as init-only members (defaulting empty) so existing + /// construction sites compile unchanged. (The retired v3 equipment-tag diff sets — review 01 U2 — are gone: + /// device values live in the Raw subtree and reach equipment as s.) /// public IReadOnlyList AddedEquipmentVirtualTags { get; init; } = Array.Empty(); /// @@ -65,7 +52,7 @@ public sealed record AddressSpacePlan( /// uses), preserving every client's subscriptions; a sink lacking the surgical capability or a /// missing folder falls back to a full rebuild. Added as an init-only member (defaulting empty) so /// every existing AddressSpacePlan construction site compiles unchanged — consistent with - /// the EquipmentTag / EquipmentVirtualTag diff sets above. + /// the EquipmentVirtualTag diff sets above. /// public IReadOnlyList RenamedFolders { get; init; } = Array.Empty(); @@ -74,7 +61,7 @@ public sealed record AddressSpacePlan( /// 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). + /// unchanged. /// public IReadOnlyList AddedRawContainers { get; init; } = Array.Empty(); /// @@ -114,7 +101,6 @@ public sealed record AddressSpacePlan( 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 && - 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 && @@ -124,7 +110,6 @@ public sealed record AddressSpacePlan( 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 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. @@ -177,12 +162,7 @@ public static class AddressSpacePlanner a => a.ScriptedAlarmId, (a, b) => new AddressSpacePlan.AlarmDelta(a, b)); - var (addedEqTags, removedEqTags, changedEqTags) = DiffById( - previous.EquipmentTags, next.EquipmentTags, - t => t.TagId, - (a, b) => new AddressSpacePlan.EquipmentTagDelta(a, b)); - - // VirtualTags diff by VirtualTagId, mirroring the EquipmentTags pass. EquipmentVirtualTagPlan + // VirtualTags diff by VirtualTagId. EquipmentVirtualTagPlan // overrides record equality to compare ALL fields by value — scalars (Expression/DataType/ // Name/FolderPath) plus DependencyRefs element-wise (SequenceEqual). So a no-op redeploy (fresh // list instances, identical contents) correctly diffs to empty; only a real content change is @@ -232,9 +212,6 @@ public static class AddressSpacePlanner addedDrv, removedDrv, changedDrv, addedAlarm, removedAlarm, changedAlarm) { - AddedEquipmentTags = addedEqTags, - RemovedEquipmentTags = removedEqTags, - ChangedEquipmentTags = changedEqTags, AddedEquipmentVirtualTags = addedVTags, RemovedEquipmentVirtualTags = removedVTags, ChangedEquipmentVirtualTags = changedVTags, 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 68c23d4d..207585c9 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs @@ -725,8 +725,8 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers } /// - /// Run the five node-materialising applier passes — Raw subtree, UNS references, scripted alarms, - /// equipment tags, equipment VirtualTags — either over the FULL composition (after a rebuild) or over + /// Run the four node-materialising applier passes — Raw subtree, UNS references, scripted alarms, + /// equipment VirtualTags — either over the FULL composition (after a rebuild) or over /// just this plan's delta (surgical deploys). MaterialiseHierarchy is NOT one of them: /// the caller always runs it in full, because parents must exist before any add. /// @@ -809,21 +809,7 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers if (addedAlarms.Count > 0) failedNodes += applier.MaterialiseScriptedAlarms(composition, addedAlarms); } - // 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 are pushed by DriverHostActor.ForwardBatchToMux after - // each subscription cycle; variables show BadWaitingForInitialData only until the first - // publish interval fires. - if (fullComposition) - { - failedNodes += applier.MaterialiseEquipmentTags(composition); - } - else if (plan.AddedEquipmentTags.Count > 0) - { - failedNodes += applier.MaterialiseEquipmentTags(composition, plan.AddedEquipmentTags); - } - - // Equipment-namespace VirtualTags get their own pass right after the equipment tags: + // Equipment-namespace VirtualTags get their own pass right after the scripted alarms: // ensures each computed signal's Variable (and any FolderPath sub-folder) exists under its // equipment folder with a folder-scoped NodeId. VirtualTagHostActor.OnResult pushes live // values once the first dependency update arrives; until then variables show BadWaitingForInitialData. diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/SubscriptionSurvivalTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/SubscriptionSurvivalTests.cs index b8701f69..31b597ef 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/SubscriptionSurvivalTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/SubscriptionSurvivalTests.cs @@ -26,7 +26,7 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests; /// announce (T4b) land. /// Run note: this is a heavy in-process server+client integration test — run it in the /// serial integration pass (it is NOT part of the lightweight unit sweep). The -/// namespace URI is ; the client resolves the +/// namespace URI is ; the client resolves the /// folder-scoped variable NodeIds against it. /// public sealed class SubscriptionSurvivalTests @@ -68,15 +68,15 @@ public sealed class SubscriptionSurvivalTests // --- Materialise tag A (equipment eq-1, tag "A") and seed it a Good value. --- var withA = CompositionWith( - new EquipmentTagPlan("tag-a", "eq-1", "drv", FolderPath: "", Name: "A", DataType: "Int32", FullName: "1", Writable: false, Alarm: null)); + RawTag("tag-a", "A")); applier.MaterialiseHierarchy(withA); - applier.MaterialiseEquipmentTags(withA); - var nodeIdAString = V3NodeIds.Uns("eq-1", "A"); - sink.WriteValue(nodeIdAString, 41, Commons.OpcUa.OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns); + applier.MaterialiseRawSubtree(withA); + var nodeIdAString = "Plant/dev1/A"; + sink.WriteValue(nodeIdAString, 41, Commons.OpcUa.OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw); // --- Open a client session + subscription + monitored item on A. --- using var session = await OpenSessionAsync($"opc.tcp://127.0.0.1:{port}/OtOpcUa", ct); - var ns = (ushort)session.NamespaceUris.GetIndex(OtOpcUaNodeManager.DefaultNamespaceUri); + var ns = (ushort)session.NamespaceUris.GetIndex(V3NodeIds.RawNamespaceUri); ns.ShouldBeGreaterThan((ushort)0); var nodeIdA = new NodeId(nodeIdAString, ns); @@ -106,22 +106,22 @@ public sealed class SubscriptionSurvivalTests // --- Drive a PURE-ADD apply adding tag B (previous={A}, next={A,B}). --- var withAB = CompositionWith( - new EquipmentTagPlan("tag-a", "eq-1", "drv", FolderPath: "", Name: "A", DataType: "Int32", FullName: "1", Writable: false, Alarm: null), - new EquipmentTagPlan("tag-b", "eq-1", "drv", FolderPath: "", Name: "B", DataType: "Int32", FullName: "2", Writable: false, Alarm: null)); + RawTag("tag-a", "A"), + RawTag("tag-b", "B")); var plan = AddressSpacePlanner.Compute(withA, withAB); - plan.AddedEquipmentTags.Count.ShouldBe(1); + plan.AddedRawTags.Count.ShouldBe(1); var outcome = applier.Apply(plan); outcome.RebuildCalled.ShouldBeFalse(); // PureAdd — no teardown // Materialise passes (as the publish actor runs them) then announce — over the FULL composition. applier.MaterialiseHierarchy(withAB); - applier.MaterialiseEquipmentTags(withAB); + applier.MaterialiseRawSubtree(withAB); applier.AnnounceAddedNodes(plan); // --- A's monitored item must still be alive: a new write to A is delivered. --- lock (gate) received.Clear(); - sink.WriteValue(nodeIdAString, 42, Commons.OpcUa.OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns); + sink.WriteValue(nodeIdAString, 42, Commons.OpcUa.OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw); await WaitUntilAsync(() => { lock (gate) return received.Any(v => Equals(v.Value, 42)); }, TimeSpan.FromSeconds(5)); lock (gate) @@ -131,8 +131,8 @@ public sealed class SubscriptionSurvivalTests } // --- B is browsable + readable (the add landed). --- - var nodeIdB = new NodeId(V3NodeIds.Uns("eq-1", "B"), ns); - sink.WriteValue(nodeIdB.Identifier.ToString()!, 7, Commons.OpcUa.OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns); + var nodeIdB = new NodeId("Plant/dev1/B", ns); + sink.WriteValue(nodeIdB.Identifier.ToString()!, 7, Commons.OpcUa.OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw); var bValue = await session.ReadValueAsync(nodeIdB, ct); bValue.StatusCode.ShouldNotBe(StatusCodes.BadNodeIdUnknown); bValue.Value.ShouldBe(7); @@ -180,17 +180,17 @@ public sealed class SubscriptionSurvivalTests // Materialise A + B and seed both Good. var withAB = CompositionWith( - new EquipmentTagPlan("tag-a", "eq-1", "drv", FolderPath: "", Name: "A", DataType: "Int32", FullName: "1", Writable: false, Alarm: null), - new EquipmentTagPlan("tag-b", "eq-1", "drv", FolderPath: "", Name: "B", DataType: "Int32", FullName: "2", Writable: false, Alarm: null)); + RawTag("tag-a", "A"), + RawTag("tag-b", "B")); applier.MaterialiseHierarchy(withAB); - applier.MaterialiseEquipmentTags(withAB); - var nodeIdAString = V3NodeIds.Uns("eq-1", "A"); - var nodeIdBString = V3NodeIds.Uns("eq-1", "B"); - sink.WriteValue(nodeIdAString, 10, Commons.OpcUa.OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns); - sink.WriteValue(nodeIdBString, 20, Commons.OpcUa.OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns); + applier.MaterialiseRawSubtree(withAB); + var nodeIdAString = "Plant/dev1/A"; + var nodeIdBString = "Plant/dev1/B"; + sink.WriteValue(nodeIdAString, 10, Commons.OpcUa.OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw); + sink.WriteValue(nodeIdBString, 20, Commons.OpcUa.OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw); using var session = await OpenSessionAsync($"opc.tcp://127.0.0.1:{port}/OtOpcUa", ct); - var ns = (ushort)session.NamespaceUris.GetIndex(OtOpcUaNodeManager.DefaultNamespaceUri); + var ns = (ushort)session.NamespaceUris.GetIndex(V3NodeIds.RawNamespaceUri); var nodeIdA = new NodeId(nodeIdAString, ns); var nodeIdB = new NodeId(nodeIdBString, ns); @@ -211,9 +211,9 @@ public sealed class SubscriptionSurvivalTests // Drive a PURE-REMOVE apply removing B (previous={A,B}, next={A}). var withA = CompositionWith( - new EquipmentTagPlan("tag-a", "eq-1", "drv", FolderPath: "", Name: "A", DataType: "Int32", FullName: "1", Writable: false, Alarm: null)); + RawTag("tag-a", "A")); var plan = AddressSpacePlanner.Compute(withAB, withA); - plan.RemovedEquipmentTags.Count.ShouldBe(1); + plan.RemovedRawTags.Count.ShouldBe(1); lock (gate) { receivedA.Clear(); receivedB.Clear(); } var outcome = applier.Apply(plan); @@ -223,7 +223,7 @@ public sealed class SubscriptionSurvivalTests await WaitUntilAsync(() => { lock (gate) return receivedB.Any(v => StatusCode.IsBad(v.StatusCode)); }, TimeSpan.FromSeconds(5)); // A stays alive: a fresh write to A is delivered. - sink.WriteValue(nodeIdAString, 11, Commons.OpcUa.OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns); + sink.WriteValue(nodeIdAString, 11, Commons.OpcUa.OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw); await WaitUntilAsync(() => { lock (gate) return receivedA.Any(v => Equals(v.Value, 11)); }, TimeSpan.FromSeconds(5)); // B is gone: a re-read surfaces BadNodeIdUnknown. The 1.5.378 node-cache read path @@ -273,15 +273,15 @@ public sealed class SubscriptionSurvivalTests var applier = new AddressSpaceApplier(sink, NullLogger.Instance); var withAB = CompositionWith( - new EquipmentTagPlan("tag-a", "eq-1", "drv", FolderPath: "", Name: "A", DataType: "Int32", FullName: "1", Writable: false, Alarm: null), - new EquipmentTagPlan("tag-b", "eq-1", "drv", FolderPath: "", Name: "B", DataType: "Int32", FullName: "2", Writable: false, Alarm: null)); + RawTag("tag-a", "A"), + RawTag("tag-b", "B")); applier.MaterialiseHierarchy(withAB); - applier.MaterialiseEquipmentTags(withAB); - var nodeIdAString = V3NodeIds.Uns("eq-1", "A"); - sink.WriteValue(nodeIdAString, 10, Commons.OpcUa.OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns); + applier.MaterialiseRawSubtree(withAB); + var nodeIdAString = "Plant/dev1/A"; + sink.WriteValue(nodeIdAString, 10, Commons.OpcUa.OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw); using var session = await OpenSessionAsync($"opc.tcp://127.0.0.1:{port}/OtOpcUa", ct); - var ns = (ushort)session.NamespaceUris.GetIndex(OtOpcUaNodeManager.DefaultNamespaceUri); + var ns = (ushort)session.NamespaceUris.GetIndex(V3NodeIds.RawNamespaceUri); var nodeIdA = new NodeId(nodeIdAString, ns); var receivedA = new List(); @@ -297,32 +297,32 @@ public sealed class SubscriptionSurvivalTests // MIXED apply: remove B, add C (previous={A,B}, next={A,C}). var withAC = CompositionWith( - new EquipmentTagPlan("tag-a", "eq-1", "drv", FolderPath: "", Name: "A", DataType: "Int32", FullName: "1", Writable: false, Alarm: null), - new EquipmentTagPlan("tag-c", "eq-1", "drv", FolderPath: "", Name: "C", DataType: "Int32", FullName: "3", Writable: false, Alarm: null)); + RawTag("tag-a", "A"), + RawTag("tag-c", "C")); var plan = AddressSpacePlanner.Compute(withAB, withAC); - plan.RemovedEquipmentTags.Count.ShouldBe(1); - plan.AddedEquipmentTags.Count.ShouldBe(1); + plan.RemovedRawTags.Count.ShouldBe(1); + plan.AddedRawTags.Count.ShouldBe(1); lock (gate) receivedA.Clear(); var outcome = applier.Apply(plan); outcome.RebuildCalled.ShouldBeFalse(); // AddRemoveMix — removes in place, no rebuild // Publish-actor sequence: removes ran inside Apply; now materialise the adds + announce. - applier.MaterialiseEquipmentTags(withAC); + applier.MaterialiseRawSubtree(withAC); applier.AnnounceAddedNodes(plan); // A stays alive. - sink.WriteValue(nodeIdAString, 11, Commons.OpcUa.OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns); + sink.WriteValue(nodeIdAString, 11, Commons.OpcUa.OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw); await WaitUntilAsync(() => { lock (gate) return receivedA.Any(v => Equals(v.Value, 11)); }, TimeSpan.FromSeconds(5)); // B is gone. (1.5.378 node-cache read throws for an unknown node — see above.) - var nodeIdB = new NodeId(V3NodeIds.Uns("eq-1", "B"), ns); + var nodeIdB = new NodeId("Plant/dev1/B", ns); var bReadEx = await Should.ThrowAsync( async () => await session.ReadValueAsync(nodeIdB, ct)); bReadEx.StatusCode.ShouldBe(StatusCodes.BadNodeIdUnknown); // C is browsable + readable. - var nodeIdC = new NodeId(V3NodeIds.Uns("eq-1", "C"), ns); - sink.WriteValue(nodeIdC.Identifier.ToString()!, 9, Commons.OpcUa.OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns); + var nodeIdC = new NodeId("Plant/dev1/C", ns); + sink.WriteValue(nodeIdC.Identifier.ToString()!, 9, Commons.OpcUa.OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw); var cRead = await session.ReadValueAsync(nodeIdC, ct); cRead.StatusCode.ShouldNotBe((StatusCode)StatusCodes.BadNodeIdUnknown); cRead.Value.ShouldBe(9); @@ -333,7 +333,11 @@ public sealed class SubscriptionSurvivalTests } } - private static AddressSpaceComposition CompositionWith(params EquipmentTagPlan[] tags) => + private static RawTagPlan RawTag(string tagId, string name) => + new(tagId, $"Plant/dev1/{name}", ParentNodeId: "Plant/dev1", "drv", Name: name, DataType: "Int32", Writable: false, + Alarm: null, ReferencingEquipmentPaths: Array.Empty()); + + private static AddressSpaceComposition CompositionWith(params RawTagPlan[] tags) => new( UnsAreas: Array.Empty(), UnsLines: Array.Empty(), @@ -341,7 +345,8 @@ public sealed class SubscriptionSurvivalTests DriverInstancePlans: Array.Empty(), ScriptedAlarmPlans: Array.Empty()) { - EquipmentTags = tags, + RawContainers = new[] { new RawContainerNode("Plant/dev1", null, "dev1", RawNodeKind.Device) }, + RawTags = tags, }; private static async Task OpenSessionAsync(string endpointUrl, CancellationToken ct) diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierFailureSurfaceTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierFailureSurfaceTests.cs index a94b824b..1282a731 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierFailureSurfaceTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierFailureSurfaceTests.cs @@ -75,30 +75,26 @@ public sealed class AddressSpaceApplierFailureSurfaceTests // ---------------- T3: Materialise* passes return failed-node counts ---------------- - /// Every EnsureVariable throwing in MaterialiseEquipmentTags is tallied into the pass's int return. + /// Every EnsureVariable throwing in MaterialiseRawSubtree is tallied into the pass's int return. [Fact] - public void MaterialiseEquipmentTags_WhenEnsureVariableThrows_ReturnsFailedCount() + public void MaterialiseRawSubtree_WhenEnsureVariableThrows_ReturnsFailedCount() { var sink = new ConfigurableThrowingSink { ThrowOnEnsureVariable = true }; var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - var composition = CompositionWithTags( - new EquipmentTagPlan("t1", "eq-1", "drv", FolderPath: "", Name: "A", DataType: "Float", FullName: "40001", Writable: false, Alarm: null), - new EquipmentTagPlan("t2", "eq-1", "drv", FolderPath: "", Name: "B", DataType: "Float", FullName: "40002", Writable: false, Alarm: null), - new EquipmentTagPlan("t3", "eq-1", "drv", FolderPath: "", Name: "C", DataType: "Float", FullName: "40003", Writable: false, Alarm: null)); + var composition = CompositionWithRawTags(RawTag("t1", "A"), RawTag("t2", "B"), RawTag("t3", "C")); - applier.MaterialiseEquipmentTags(composition).ShouldBe(3); + applier.MaterialiseRawSubtree(composition).ShouldBe(3); } - /// All five Materialise* passes return 0 failures on a clean sink — the happy-path regression. + /// All four Materialise* passes return 0 failures on a clean sink — the happy-path regression. [Fact] public void Materialise_HappyPath_AllPassesReturnZero() { var sink = new ConfigurableThrowingSink(); var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - var tags = CompositionWithTags( - new EquipmentTagPlan("t1", "eq-1", "drv", FolderPath: "", Name: "A", DataType: "Float", FullName: "40001", Writable: false, Alarm: null)); + var tags = CompositionWithRawTags(RawTag("t1", "A")); var vtags = CompositionWithVirtualTags( new EquipmentVirtualTagPlan("v1", "eq-1", FolderPath: "", Name: "VA", DataType: "Float", Expression: "1", DependencyRefs: Array.Empty(), Historize: false)); var alarms = CompositionWithScriptedAlarms( @@ -106,17 +102,21 @@ public sealed class AddressSpaceApplierFailureSurfaceTests Array.Empty(), HistorizeToAveva: false, Retain: true, Enabled: true)); applier.MaterialiseHierarchy(tags).ShouldBe(0); - applier.MaterialiseEquipmentTags(tags).ShouldBe(0); + applier.MaterialiseRawSubtree(tags).ShouldBe(0); applier.MaterialiseEquipmentVirtualTags(vtags).ShouldBe(0); applier.MaterialiseScriptedAlarms(alarms).ShouldBe(0); } // ---------------- fixtures ---------------- - private static AddressSpaceComposition CompositionWithTags(params EquipmentTagPlan[] tags) => + private static RawTagPlan RawTag(string id, string name) => + new(id, $"Plant/dev1/{name}", ParentNodeId: null, "drv", Name: name, DataType: "Float", Writable: false, Alarm: null, + ReferencingEquipmentPaths: Array.Empty()); + + private static AddressSpaceComposition CompositionWithRawTags(params RawTagPlan[] tags) => new(Array.Empty(), Array.Empty(), Array.Empty()) { - EquipmentTags = tags, + RawTags = tags, }; private static AddressSpaceComposition CompositionWithVirtualTags(params EquipmentVirtualTagPlan[] vtags) => diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierHierarchyTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierHierarchyTests.cs index 39d26056..4528812e 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierHierarchyTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierHierarchyTests.cs @@ -110,13 +110,13 @@ public sealed class AddressSpaceApplierHierarchyTests : IDisposable sdkServer.NodeManager!.FolderCount.ShouldBe(5); } - /// Verifies MaterialiseEquipmentTags is idempotent against a real SDK node manager: + /// Verifies MaterialiseRawSubtree is idempotent against a real SDK node manager: /// applying the same composition twice yields a single Variable node (no duplicates). This is the /// restart-safety guarantee — HandleRebuild runs on both the apply path and the /// DriverHostActor.RestoreApplied bootstrap path (same RebuildAddressSpace message), so a node /// restart re-runs this pass and must not double-materialise. [Fact] - public async Task MaterialiseEquipmentTags_against_real_SDK_node_manager_is_idempotent() + public async Task MaterialiseRawSubtree_against_real_SDK_node_manager_is_idempotent() { var applicationUri = $"urn:OtOpcUa.EquipmentTags:{Guid.NewGuid():N}"; var started = await OpcUaHostStart.StartAsync( @@ -145,16 +145,17 @@ public sealed class AddressSpaceApplierHierarchyTests : IDisposable DriverInstancePlans: Array.Empty(), ScriptedAlarmPlans: Array.Empty()) { - EquipmentTags = new[] + RawTags = new[] { - new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null), + new RawTagPlan("tag-1", "Plant/dev1/Speed", ParentNodeId: null, "drv", Name: "Speed", DataType: "Float", + Writable: false, Alarm: null, ReferencingEquipmentPaths: Array.Empty()), }, }; - // Equipment folder first (the variable's parent), then the tag pass — applied twice. + // The raw pass — applied twice. applier.MaterialiseHierarchy(composition); - applier.MaterialiseEquipmentTags(composition); - applier.MaterialiseEquipmentTags(composition); + applier.MaterialiseRawSubtree(composition); + applier.MaterialiseRawSubtree(composition); sdkServer.NodeManager!.VariableCount.ShouldBe(1); // single variable despite the double-apply } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierTests.cs index f3e61370..2a60cf29 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierTests.cs @@ -12,30 +12,6 @@ public sealed class AddressSpaceApplierTests - /// R2-07 T2 — added equipment tags in an otherwise-empty plan are a PureAdd: the applier - /// SKIPS the rebuild (the idempotent Materialise passes create the new variables; existing subscriptions - /// survive). (Supersedes the pre-R2-07 "added tags ⇒ rebuild" pin.) - [Fact] - public void Added_equipment_tags_are_pure_add_and_skip_rebuild() - { - var sink = new RecordingSink(); - var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - - var plan = EmptyPlan with - { - AddedEquipmentTags = new[] - { - new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null), - }, - }; - - var outcome = applier.Apply(plan); - - outcome.RebuildCalled.ShouldBeFalse(); - outcome.AddedNodes.ShouldBe(1); - sink.RebuildCalls.ShouldBe(0); - } - /// R2-07 T2 — added Equipment VirtualTags in an otherwise-empty plan are a PureAdd: the applier /// SKIPS the rebuild (parity with the equipment-tag path). (Supersedes the pre-R2-07 "added vtags ⇒ /// rebuild" pin.) @@ -61,27 +37,22 @@ public sealed class AddressSpaceApplierTests sink.RebuildCalls.ShouldBe(0); } - /// R2-07 T2 — a PureAdd plan carrying a coincident surgical tag delta (only Writable flips on - /// an existing tag) SKIPS the rebuild AND applies the in-place tag update via the surgical sink. The - /// classifier routes adds+surgical-change to PureAdd (rule 2 clears because the change is - /// surgical-eligible); the applier still runs the F10b surgical pass. + /// R2-07 T11 (raw-tag vehicle, review 01 U2 retired the equipment-tag one) — a remove-one-raw-tag + /// plan is a PureRemove: the applier writes a terminal Bad to the removed variable then removes it in place + /// via RemoveVariableNode in the RAW realm — NO rebuild. An alarm-bearing raw tag gets the terminal + /// RemovedConditionState and a condition-node removal instead. [Fact] - public void Pure_add_with_coincident_surgical_tag_delta_skips_rebuild_and_updates_in_place() + public void Removed_raw_tags_write_terminal_state_and_remove_in_place_without_rebuild() { var sink = new RecordingSink(); var applier = new AddressSpaceApplier(sink, NullLogger.Instance); var plan = EmptyPlan with { - AddedEquipmentTags = new[] + RemovedRawTags = new[] { - new EquipmentTagPlan("tag-new", "eq-1", "drv", FolderPath: "", Name: "New", DataType: "Float", FullName: "40009", Writable: false, Alarm: null), - }, - ChangedEquipmentTags = new[] - { - new AddressSpacePlan.EquipmentTagDelta( - new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null), - new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: true, Alarm: null)), + RawTag("tag-1", "Plant/dev1/Speed"), + RawTag("tag-alm", "Plant/dev1/OverTemp", alarm: new EquipmentTagAlarmInfo("OffNormalAlarm", 700)), }, }; @@ -89,125 +60,16 @@ public sealed class AddressSpaceApplierTests outcome.RebuildCalled.ShouldBeFalse(); sink.RebuildCalls.ShouldBe(0); - var call = sink.SurgicalCalls.ShouldHaveSingleItem(); - call.NodeId.ShouldBe(V3NodeIds.Uns("eq-1", "Speed")); - call.Writable.ShouldBeTrue(); - } - - /// R2-07 T2 — during a PureAdd apply, if a coincident surgical tag update reports the node - /// missing (returns false), the applier falls back to a full rebuild (the F10b safety valve is - /// preserved through the classifier routing). - [Fact] - public void Pure_add_with_surgical_returns_false_falls_back_to_rebuild() - { - var sink = new RecordingSink { SurgicalReturns = false }; - var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - - var plan = EmptyPlan with - { - AddedEquipmentTags = new[] - { - new EquipmentTagPlan("tag-new", "eq-1", "drv", FolderPath: "", Name: "New", DataType: "Float", FullName: "40009", Writable: false, Alarm: null), - }, - ChangedEquipmentTags = new[] - { - new AddressSpacePlan.EquipmentTagDelta( - new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null), - new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: true, Alarm: null)), - }, - }; - - var outcome = applier.Apply(plan); - - outcome.RebuildCalled.ShouldBeTrue(); // fell back to a full rebuild - sink.RebuildCalls.ShouldBe(1); - sink.SurgicalCalls.ShouldHaveSingleItem(); // the surgical update was attempted first - } - - /// R2-07 T11 — a remove-one-tag plan is a PureRemove: the applier writes a terminal Bad to the - /// removed variable then removes it in place via RemoveVariableNode — NO rebuild. (Supersedes the T2 - /// phase pin.) - [Fact] - public void Removed_equipment_tag_writes_terminal_bad_and_removes_variable_without_rebuild() - { - var sink = new RecordingSink(); - var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - - var plan = EmptyPlan with - { - RemovedEquipmentTags = new[] - { - new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null), - }, - }; - - var outcome = applier.Apply(plan); - - outcome.RebuildCalled.ShouldBeFalse(); - sink.RebuildCalls.ShouldBe(0); - var nodeId = V3NodeIds.Uns("eq-1", "Speed"); - // Terminal Bad written to the removed variable BEFORE the removal. - sink.ValueWrites.ShouldContain((nodeId, OpcUaStatus.Bad, AddressSpaceRealm.Uns)); - sink.RemoveCalls.ShouldHaveSingleItem().ShouldBe(("var", nodeId, AddressSpaceRealm.Uns)); - } - - /// R2-07 T11 — a removed alarm-bearing tag writes the terminal RemovedConditionState (closing - /// the pre-R2-07 today-gap where a removed alarm tag got no condition write) then removes the CONDITION - /// node in place (not a value variable). - [Fact] - public void Removed_alarm_bearing_tag_writes_removed_condition_state_and_removes_condition() - { - var sink = new RecordingSink(); - var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - - var plan = EmptyPlan with - { - RemovedEquipmentTags = new[] - { - new EquipmentTagPlan("tag-alm", "eq-1", "drv", FolderPath: "", Name: "OverTemp", DataType: "Boolean", FullName: "00001", Writable: false, - Alarm: new EquipmentTagAlarmInfo("OffNormalAlarm", 700)), - }, - }; - - var outcome = applier.Apply(plan); - - outcome.RebuildCalled.ShouldBeFalse(); - var nodeId = V3NodeIds.Uns("eq-1", "OverTemp"); - // Terminal RemovedConditionState (inactive/acked/confirmed) written to the condition node. + sink.ValueWrites.ShouldContain(("Plant/dev1/Speed", OpcUaStatus.Bad, AddressSpaceRealm.Raw)); var write = sink.AlarmWrites.ShouldHaveSingleItem(); - write.NodeId.ShouldBe(nodeId); + write.NodeId.ShouldBe("Plant/dev1/OverTemp"); (!write.State.Active && write.State.Acknowledged && write.State.Confirmed).ShouldBeTrue(); - // Removed as a condition node, not a value variable. - sink.RemoveCalls.ShouldHaveSingleItem().ShouldBe(("alarm", nodeId, AddressSpaceRealm.Uns)); - sink.ValueWrites.ShouldNotContain(w => w.NodeId == nodeId); - } - - /// R2-07 T11 — a removed child (tag/vtag/alarm) whose equipment is ALSO removed is SUBSUMED by - /// the equipment-subtree removal: only RemoveEquipmentSubtree fires, no individual child remove. - [Fact] - public void Removed_child_under_removed_equipment_is_subsumed_by_subtree_removal() - { - var sink = new RecordingSink(); - var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - - var plan = EmptyPlan with + sink.RemoveCalls.ShouldBe(new[] { - RemovedEquipment = new[] { new EquipmentNode("eq-1", "One", "line-1") }, - RemovedEquipmentTags = new[] - { - new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null), - }, - RemovedEquipmentVirtualTags = new[] - { - new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "Eff", DataType: "Float", Expression: "a", DependencyRefs: new[] { "a" }), - }, - }; - - var outcome = applier.Apply(plan); - - outcome.RebuildCalled.ShouldBeFalse(); - // ONLY the subtree removal fires — no individual var removal for the subsumed child tag/vtag. - sink.RemoveCalls.ShouldHaveSingleItem().ShouldBe(("equipment", "eq-1", AddressSpaceRealm.Uns)); + ("var", "Plant/dev1/Speed", AddressSpaceRealm.Raw), + ("alarm", "Plant/dev1/OverTemp", AddressSpaceRealm.Raw), + }); + outcome.RemovedNodes.ShouldBe(2); } /// R2-07 T11 — if a surgical remove reports the node unknown (RemoveReturns=false), the applier @@ -220,11 +82,7 @@ public sealed class AddressSpaceApplierTests var plan = EmptyPlan with { - RemovedEquipmentTags = new[] - { - new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "A", DataType: "Float", FullName: "1", Writable: false, Alarm: null), - new EquipmentTagPlan("tag-2", "eq-1", "drv", FolderPath: "", Name: "B", DataType: "Float", FullName: "2", Writable: false, Alarm: null), - }, + RemovedRawTags = new[] { RawTag("tag-1", "Plant/dev1/A"), RawTag("tag-2", "Plant/dev1/B") }, }; var outcome = applier.Apply(plan); @@ -243,13 +101,7 @@ public sealed class AddressSpaceApplierTests var sink = new PlainRecordingSink(); var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - var plan = EmptyPlan with - { - RemovedEquipmentTags = new[] - { - new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "A", DataType: "Float", FullName: "1", Writable: false, Alarm: null), - }, - }; + var plan = EmptyPlan with { RemovedRawTags = new[] { RawTag("tag-1", "Plant/dev1/A") } }; var outcome = applier.Apply(plan); @@ -259,7 +111,7 @@ public sealed class AddressSpaceApplierTests /// R2-07 T13 — an add+remove plan is an AddRemoveMix: the applier removes the old node IN PLACE /// (RemoveVariableNode) and does NOT rebuild; the add is materialised afterward by the publish actor's - /// passes. (Supersedes the T2 phase pin.) + /// passes. [Fact] public void Add_and_remove_is_mixed_removes_in_place_without_rebuild() { @@ -268,14 +120,8 @@ public sealed class AddressSpaceApplierTests var plan = EmptyPlan with { - AddedEquipmentTags = new[] - { - new EquipmentTagPlan("tag-new", "eq-1", "drv", FolderPath: "", Name: "New", DataType: "Float", FullName: "40009", Writable: false, Alarm: null), - }, - RemovedEquipmentTags = new[] - { - new EquipmentTagPlan("tag-old", "eq-1", "drv", FolderPath: "", Name: "Old", DataType: "Float", FullName: "40001", Writable: false, Alarm: null), - }, + AddedRawTags = new[] { RawTag("tag-new", "Plant/dev1/New") }, + RemovedRawTags = new[] { RawTag("tag-old", "Plant/dev1/Old") }, }; var outcome = applier.Apply(plan); @@ -283,40 +129,11 @@ public sealed class AddressSpaceApplierTests outcome.RebuildCalled.ShouldBeFalse(); sink.RebuildCalls.ShouldBe(0); // The removed node is torn down in place; the add is left to the publish actor's materialise passes. - sink.RemoveCalls.ShouldHaveSingleItem().ShouldBe(("var", V3NodeIds.Uns("eq-1", "Old"), AddressSpaceRealm.Uns)); + sink.RemoveCalls.ShouldHaveSingleItem().ShouldBe(("var", "Plant/dev1/Old", AddressSpaceRealm.Raw)); outcome.AddedNodes.ShouldBe(1); outcome.RemovedNodes.ShouldBe(1); } - /// R2-07 T13 — an id REUSED across the remove + add sets in one deploy (a tag removed at the - /// same folder-scoped NodeId another tag is added at): the applier removes the old node in place (the - /// recreate is the publish actor's job, a fresh NodeState). No rebuild. - [Fact] - public void Add_and_remove_reusing_same_node_id_removes_the_old_node_without_rebuild() - { - var sink = new RecordingSink(); - var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - - var nodeId = V3NodeIds.Uns("eq-1", "Slot"); - var plan = EmptyPlan with - { - // Different TagId, but both resolve to the SAME folder-scoped NodeId (eq-1/Slot). - RemovedEquipmentTags = new[] - { - new EquipmentTagPlan("tag-old", "eq-1", "drv", FolderPath: "", Name: "Slot", DataType: "Float", FullName: "40001", Writable: false, Alarm: null), - }, - AddedEquipmentTags = new[] - { - new EquipmentTagPlan("tag-new", "eq-1", "drv", FolderPath: "", Name: "Slot", DataType: "Int32", FullName: "40002", Writable: false, Alarm: null), - }, - }; - - var outcome = applier.Apply(plan); - - outcome.RebuildCalled.ShouldBeFalse(); - sink.RemoveCalls.ShouldHaveSingleItem().ShouldBe(("var", nodeId, AddressSpaceRealm.Uns)); - } - /// R2-07 T13 — a remove failure inside an AddRemoveMix still trips the rebuild ratchet. [Fact] public void Add_and_remove_with_remove_returns_false_falls_back_to_rebuild() @@ -326,14 +143,8 @@ public sealed class AddressSpaceApplierTests var plan = EmptyPlan with { - AddedEquipmentTags = new[] - { - new EquipmentTagPlan("tag-new", "eq-1", "drv", FolderPath: "", Name: "New", DataType: "Float", FullName: "40009", Writable: false, Alarm: null), - }, - RemovedEquipmentTags = new[] - { - new EquipmentTagPlan("tag-old", "eq-1", "drv", FolderPath: "", Name: "Old", DataType: "Float", FullName: "40001", Writable: false, Alarm: null), - }, + AddedRawTags = new[] { RawTag("tag-new", "Plant/dev1/New") }, + RemovedRawTags = new[] { RawTag("tag-old", "Plant/dev1/Old") }, }; var outcome = applier.Apply(plan); @@ -342,38 +153,28 @@ public sealed class AddressSpaceApplierTests sink.RebuildCalls.ShouldBe(1); } - /// H1a — a deploy that ONLY changes an existing equipment tag in a NON-surgical way (here the - /// driver-side FullName re-routes to a different point) must rebuild the address space. The planner - /// diffs the tag into ChangedEquipmentTags with no Added/Removed of anything else; the applier must - /// still drive exactly one rebuild so the running server drops the stale node and re-materialises it. - /// (Surgically-applicable tag changes — Writable/Historizing/DataType/array-shape — take the in-place path - /// instead; those are covered by the F10b + FB-7 surgical tests.) + /// R2-07 T11 — a removed child (tag/vtag/alarm) whose equipment is ALSO removed is SUBSUMED by + /// the equipment-subtree removal: only RemoveEquipmentSubtree fires, no individual child remove. [Fact] - public void Changed_equipment_tags_only_trigger_rebuild() + public void Removed_child_under_removed_equipment_is_subsumed_by_subtree_removal() { var sink = new RecordingSink(); var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - var previous = CompositionWithTags( - new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null)); - // Same tag id, but the driver-side FullName flipped — a non-surgical change, so the applier rebuilds. - var next = CompositionWithTags( - new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40002", Writable: false, Alarm: null)); - - var plan = AddressSpacePlanner.Compute(previous, next); - - // Guard the arrange: ONLY ChangedEquipmentTags is populated. - plan.ChangedEquipmentTags.Count.ShouldBe(1); - plan.AddedEquipmentTags.ShouldBeEmpty(); - plan.RemovedEquipmentTags.ShouldBeEmpty(); - plan.AddedEquipment.ShouldBeEmpty(); - plan.RemovedEquipment.ShouldBeEmpty(); + var plan = EmptyPlan with + { + RemovedEquipment = new[] { new EquipmentNode("eq-1", "One", "line-1") }, + RemovedEquipmentVirtualTags = new[] + { + new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "Eff", DataType: "Float", Expression: "a", DependencyRefs: new[] { "a" }), + }, + }; var outcome = applier.Apply(plan); - outcome.RebuildCalled.ShouldBeTrue(); - outcome.ChangedNodes.ShouldBe(1); - sink.RebuildCalls.ShouldBe(1); + outcome.RebuildCalled.ShouldBeFalse(); + // ONLY the subtree removal fires — no individual var removal for the subsumed child vtag. + sink.RemoveCalls.ShouldHaveSingleItem().ShouldBe(("equipment", "eq-1", AddressSpaceRealm.Uns)); } /// F10b (backlog #11) — a deploy that ONLY edits an existing VirtualTag's Expression @@ -553,26 +354,18 @@ public sealed class AddressSpaceApplierTests var applier = new AddressSpaceApplier(sink, NullLogger.Instance); var previous = new AddressSpaceComposition( - Array.Empty(), Array.Empty(), Array.Empty()) + new[] { new EquipmentNode("eq-1", "One", "line-1") }, Array.Empty(), Array.Empty()) { - EquipmentTags = new[] - { - new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null), - }, EquipmentVirtualTags = new[] { new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "Efficiency", DataType: "Float64", Expression: "ctx.GetTag(\"a\") * 60", DependencyRefs: new[] { "a" }), }, }; - // Expression-only vtag edit (node-irrelevant) AND a non-surgical tag change (FullName re-route). + // Expression-only vtag edit (node-irrelevant) AND a node-affecting equipment change (rename). var next = new AddressSpaceComposition( - Array.Empty(), Array.Empty(), Array.Empty()) + new[] { new EquipmentNode("eq-1", "One renamed", "line-1") }, Array.Empty(), Array.Empty()) { - EquipmentTags = new[] - { - new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40002", Writable: false, Alarm: null), - }, EquipmentVirtualTags = new[] { new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "Efficiency", DataType: "Float64", @@ -582,9 +375,9 @@ public sealed class AddressSpaceApplierTests var plan = AddressSpacePlanner.Compute(previous, next); - // Both a node-irrelevant vtag change AND a node-affecting tag change are present. + // Both a node-irrelevant vtag change AND a node-affecting equipment change are present. plan.ChangedEquipmentVirtualTags.Count.ShouldBe(1); - plan.ChangedEquipmentTags.Count.ShouldBe(1); + plan.ChangedEquipment.Count.ShouldBe(1); var outcome = applier.Apply(plan); @@ -670,7 +463,6 @@ public sealed class AddressSpaceApplierTests plan.ChangedEquipmentVirtualTags.Count.ShouldBe(2); plan.AddedEquipmentVirtualTags.ShouldBeEmpty(); plan.RemovedEquipmentVirtualTags.ShouldBeEmpty(); - plan.ChangedEquipmentTags.ShouldBeEmpty(); plan.ChangedEquipment.ShouldBeEmpty(); var outcome = applier.Apply(plan); @@ -732,7 +524,6 @@ public sealed class AddressSpaceApplierTests plan.RemovedDrivers.ShouldBeEmpty(); plan.ChangedEquipment.ShouldBeEmpty(); plan.ChangedAlarms.ShouldBeEmpty(); - plan.ChangedEquipmentTags.ShouldBeEmpty(); plan.ChangedEquipmentVirtualTags.ShouldBeEmpty(); var outcome = applier.Apply(plan); @@ -748,7 +539,7 @@ public sealed class AddressSpaceApplierTests /// to removedCount — AddressSpaceApplyOutcome.RemovedNodes reported 0, a misleading audit /// entry. This pins both the rebuild and the accurate count. [Fact] - public void Removed_equipment_tags_and_virtual_tags_only_rebuild_and_are_counted() + public void Removed_virtual_tags_only_remove_in_place_and_are_counted() { var sink = new RecordingSink(); var applier = new AddressSpaceApplier(sink, NullLogger.Instance); @@ -756,10 +547,6 @@ public sealed class AddressSpaceApplierTests var previous = new AddressSpaceComposition( Array.Empty(), Array.Empty(), Array.Empty()) { - EquipmentTags = new[] - { - new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null), - }, EquipmentVirtualTags = new[] { new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "Efficiency", DataType: "Float64", @@ -771,440 +558,23 @@ public sealed class AddressSpaceApplierTests var plan = AddressSpacePlanner.Compute(previous, next); - // Guard the arrange: ONLY the two Removed sets are populated. - plan.RemovedEquipmentTags.Count.ShouldBe(1); + // Guard the arrange: ONLY the removed-vtag set is populated. plan.RemovedEquipmentVirtualTags.Count.ShouldBe(1); - plan.AddedEquipmentTags.ShouldBeEmpty(); - plan.ChangedEquipmentTags.ShouldBeEmpty(); plan.RemovedEquipment.ShouldBeEmpty(); plan.RemovedAlarms.ShouldBeEmpty(); var outcome = applier.Apply(plan); - // R2-07 T11 — a removed-tag + removed-vtag plan is a PureRemove: both variables are torn down in - // place (RemoveVariableNode ×2), NO full rebuild; both removals are still counted. + // R2-07 T11 — a removed-vtag plan is a PureRemove: the variable is torn down in place + // (RemoveVariableNode), NO full rebuild; the removal is still counted. outcome.RebuildCalled.ShouldBeFalse(); sink.RebuildCalls.ShouldBe(0); - outcome.RemovedNodes.ShouldBe(2); // both removals counted - sink.RemoveCalls.Count.ShouldBe(2); - sink.RemoveCalls.ShouldContain(("var", V3NodeIds.Uns("eq-1", "Speed"), AddressSpaceRealm.Uns)); - sink.RemoveCalls.ShouldContain(("var", V3NodeIds.Uns("eq-1", "Efficiency"), AddressSpaceRealm.Uns)); + outcome.RemovedNodes.ShouldBe(1); + sink.RemoveCalls.ShouldHaveSingleItem().ShouldBe(("var", V3NodeIds.Uns("eq-1", "Efficiency"), AddressSpaceRealm.Uns)); } // ----- F10b: surgical in-place tag-attribute writes (Writable / IsHistorized / HistorianTagname) ----- - /// F10b — a deploy that ONLY flips an existing equipment tag's Writable bit (a plain, - /// non-array, non-alarm value variable with stable identity) must SKIP the rebuild and apply the change - /// IN PLACE via ISurgicalAddressSpaceSink.UpdateTagAttributes, preserving every client's - /// subscriptions. Exactly one surgical call lands with the NEW Writable value; the edit still counts as - /// a change (ChangedNodes == 1). - [Fact] - public void Changed_tag_writable_only_skips_rebuild_and_updates_in_place() - { - var sink = new RecordingSink(); - var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - - var previous = CompositionWithTags( - new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null)); - // Same TagId/identity; only Writable flips false → true. - var next = CompositionWithTags( - new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: true, Alarm: null)); - - var plan = AddressSpacePlanner.Compute(previous, next); - plan.ChangedEquipmentTags.Count.ShouldBe(1); - plan.AddedEquipmentTags.ShouldBeEmpty(); - plan.RemovedEquipmentTags.ShouldBeEmpty(); - - var outcome = applier.Apply(plan); - - outcome.RebuildCalled.ShouldBeFalse(); - sink.RebuildCalls.ShouldBe(0); // NO RebuildAddressSpace — subscriptions preserved - var call = sink.SurgicalCalls.ShouldHaveSingleItem(); - call.NodeId.ShouldBe(V3NodeIds.Uns("eq-1", "Speed")); - call.Writable.ShouldBeTrue(); // the NEW Writable value - call.Historian.ShouldBeNull(); // not historized - outcome.ChangedNodes.ShouldBe(1); - } - - /// F10b — flipping IsHistorized false → true (no override) updates in place with the - /// historian tagname defaulting to FullName; flipping true → false updates in place with a null - /// historian tagname. Both skip the rebuild. - [Fact] - public void Changed_tag_is_historized_toggle_skips_rebuild_and_resolves_historian() - { - // false → true (no override) ⇒ historian defaults to FullName. - var sinkOn = new RecordingSink(); - var applierOn = new AddressSpaceApplier(sinkOn, NullLogger.Instance); - var planOn = AddressSpacePlanner.Compute( - CompositionWithTags(new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", - FullName: "T.A", Writable: false, Alarm: null, IsHistorized: false, HistorianTagname: null)), - CompositionWithTags(new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", - FullName: "T.A", Writable: false, Alarm: null, IsHistorized: true, HistorianTagname: null))); - planOn.ChangedEquipmentTags.Count.ShouldBe(1); - - var outcomeOn = applierOn.Apply(planOn); - - outcomeOn.RebuildCalled.ShouldBeFalse(); - sinkOn.RebuildCalls.ShouldBe(0); - sinkOn.SurgicalCalls.ShouldHaveSingleItem().Historian.ShouldBe("T.A"); // default ⇒ FullName - - // true → false ⇒ historian null. - var sinkOff = new RecordingSink(); - var applierOff = new AddressSpaceApplier(sinkOff, NullLogger.Instance); - var planOff = AddressSpacePlanner.Compute( - CompositionWithTags(new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", - FullName: "T.A", Writable: false, Alarm: null, IsHistorized: true, HistorianTagname: null)), - CompositionWithTags(new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", - FullName: "T.A", Writable: false, Alarm: null, IsHistorized: false, HistorianTagname: null))); - planOff.ChangedEquipmentTags.Count.ShouldBe(1); - - var outcomeOff = applierOff.Apply(planOff); - - outcomeOff.RebuildCalled.ShouldBeFalse(); - sinkOff.RebuildCalls.ShouldBe(0); - sinkOff.SurgicalCalls.ShouldHaveSingleItem().Historian.ShouldBeNull(); // not historized ⇒ null - } - - /// F10b — changing ONLY the HistorianTagname override on an already-historized tag - /// skips the rebuild and updates in place, passing the NEW override verbatim. - [Fact] - public void Changed_tag_historian_tagname_only_skips_rebuild() - { - var sink = new RecordingSink(); - var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - - var previous = CompositionWithTags( - new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", - FullName: "T.A", Writable: false, Alarm: null, IsHistorized: true, HistorianTagname: "WW.Old")); - var next = CompositionWithTags( - new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", - FullName: "T.A", Writable: false, Alarm: null, IsHistorized: true, HistorianTagname: "WW.New")); - - var plan = AddressSpacePlanner.Compute(previous, next); - plan.ChangedEquipmentTags.Count.ShouldBe(1); - - var outcome = applier.Apply(plan); - - outcome.RebuildCalled.ShouldBeFalse(); - sink.RebuildCalls.ShouldBe(0); - sink.SurgicalCalls.ShouldHaveSingleItem().Historian.ShouldBe("WW.New"); // override verbatim - } - - /// FB-7 — a tag delta whose DataType changed is now surgical-eligible: the sink swaps the - /// node's DataType in place (and raises a GeneralModelChangeEvent), so the applier SKIPS the rebuild and - /// makes exactly one surgical call carrying the NEW DataType. Here Writable also flips, which the same - /// in-place update applies. Subscriptions are preserved. - [Fact] - public void Changed_tag_data_type_change_skips_rebuild_and_updates_in_place() - { - var sink = new RecordingSink(); - var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - - var previous = CompositionWithTags( - new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null)); - // DataType flips Float → Int32 AND Writable flips false → true — both are now surgically applied. - var next = CompositionWithTags( - new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Int32", FullName: "40001", Writable: true, Alarm: null)); - - var plan = AddressSpacePlanner.Compute(previous, next); - plan.ChangedEquipmentTags.Count.ShouldBe(1); - - var outcome = applier.Apply(plan); - - outcome.RebuildCalled.ShouldBeFalse(); - sink.RebuildCalls.ShouldBe(0); // NO rebuild — subscriptions preserved - var call = sink.SurgicalCalls.ShouldHaveSingleItem(); - call.NodeId.ShouldBe(V3NodeIds.Uns("eq-1", "Speed")); - call.DataType.ShouldBe("Int32"); // the NEW DataType - call.Writable.ShouldBeTrue(); // the NEW Writable, applied in the same call - call.IsArray.ShouldBeFalse(); - outcome.ChangedNodes.ShouldBe(1); - } - - /// FB-7 — a tag delta whose IsArray flag flips scalar → array is now surgical-eligible: - /// the sink swaps ValueRank + ArrayDimensions in place, so the applier skips the rebuild and the surgical - /// call carries the new array shape. An array tag is forced read-only (matching EnsureVariable), so the - /// surgical Writable is false even though the tag stays non-writable here. - [Fact] - public void Changed_tag_is_array_change_skips_rebuild_and_updates_in_place() - { - var sink = new RecordingSink(); - var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - - var previous = CompositionWithTags( - new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Buffer", DataType: "Int16", - FullName: "40001", Writable: false, Alarm: null, IsArray: false, ArrayLength: null)); - var next = CompositionWithTags( - new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Buffer", DataType: "Int16", - FullName: "40001", Writable: false, Alarm: null, IsArray: true, ArrayLength: 16u)); - - var plan = AddressSpacePlanner.Compute(previous, next); - plan.ChangedEquipmentTags.Count.ShouldBe(1); - - var outcome = applier.Apply(plan); - - outcome.RebuildCalled.ShouldBeFalse(); - sink.RebuildCalls.ShouldBe(0); - var call = sink.SurgicalCalls.ShouldHaveSingleItem(); - call.IsArray.ShouldBeTrue(); // the NEW array shape - call.ArrayLength.ShouldBe(16u); - call.DataType.ShouldBe("Int16"); // element type unchanged - call.Writable.ShouldBeFalse(); // array tag forced read-only - } - - /// F10b safe-default — a tag delta whose driver-side FullName changed is NOT - /// surgical-eligible (it re-routes the node to a different driver point), so the applier rebuilds. - [Fact] - public void Changed_tag_full_name_change_rebuilds() - { - var sink = new RecordingSink(); - var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - - var previous = CompositionWithTags( - new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null)); - var next = CompositionWithTags( - new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40002", Writable: false, Alarm: null)); - - var plan = AddressSpacePlanner.Compute(previous, next); - plan.ChangedEquipmentTags.Count.ShouldBe(1); - - var outcome = applier.Apply(plan); - - outcome.RebuildCalled.ShouldBeTrue(); - sink.RebuildCalls.ShouldBe(1); - sink.SurgicalCalls.ShouldBeEmpty(); - } - - /// F10b safe-default — a tag delta whose Name changed is NOT surgical-eligible (the - /// folder-scoped NodeId + BrowseName derive from Name), so the applier rebuilds. - [Fact] - public void Changed_tag_name_change_rebuilds() - { - var sink = new RecordingSink(); - var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - - var previous = CompositionWithTags( - new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null)); - var next = CompositionWithTags( - new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "SpeedRpm", DataType: "Float", FullName: "40001", Writable: false, Alarm: null)); - - var plan = AddressSpacePlanner.Compute(previous, next); - plan.ChangedEquipmentTags.Count.ShouldBe(1); - - var outcome = applier.Apply(plan); - - outcome.RebuildCalled.ShouldBeTrue(); - sink.RebuildCalls.ShouldBe(1); - sink.SurgicalCalls.ShouldBeEmpty(); - } - - /// F10b safe-default — a tag delta where an alarm appears (Alarm null → non-null) is NOT - /// surgical-eligible: the tag flips from a plain value variable to a Part 9 condition node, which only - /// a rebuild can materialise. No surgical call is made. - [Fact] - public void Changed_tag_alarm_presence_change_rebuilds() - { - var sink = new RecordingSink(); - var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - - var previous = CompositionWithTags( - new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "OverTemp", DataType: "Boolean", FullName: "00001", Writable: false, Alarm: null)); - var next = CompositionWithTags( - new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "OverTemp", DataType: "Boolean", FullName: "00001", Writable: false, - Alarm: new EquipmentTagAlarmInfo("OffNormalAlarm", 700))); - - var plan = AddressSpacePlanner.Compute(previous, next); - plan.ChangedEquipmentTags.Count.ShouldBe(1); - - var outcome = applier.Apply(plan); - - outcome.RebuildCalled.ShouldBeTrue(); - sink.RebuildCalls.ShouldBe(1); - sink.SurgicalCalls.ShouldBeEmpty(); - } - - /// F10b guard — a tag that ALREADY carries an alarm (Alarm is not null on BOTH previous - /// and current, identical alarm info) is NOT surgical-eligible even when only Writable changes. - /// The explicit Alarm is null guard in TagDeltaIsSurgicalEligible prevents a false - /// positive: without it the with { Writable = … } override (which does NOT touch Alarm) - /// would leave both sides with equal Alarm values and the delta would WRONGLY look surgical. - /// An alarm-bearing tag is a Part 9 condition node, not a plain value variable, so any change to it - /// must go through a full rebuild. - [Fact] - public void Changed_alarm_bearing_tag_writable_only_still_rebuilds() - { - var sink = new RecordingSink(); - var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - - // Both previous and current carry an identical, non-null alarm — the tag is an alarm-bearing node. - var alarm = new EquipmentTagAlarmInfo("OffNormalAlarm", 700); - var previous = CompositionWithTags( - new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "OverTemp", DataType: "Boolean", - FullName: "00001", Writable: false, Alarm: alarm)); - // Only Writable flips; alarm is unchanged (and still non-null on both sides). - var next = CompositionWithTags( - new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "OverTemp", DataType: "Boolean", - FullName: "00001", Writable: true, Alarm: alarm)); - - var plan = AddressSpacePlanner.Compute(previous, next); - - // Guard the arrange: the planner sees a changed tag (Writable differs) and nothing else. - plan.ChangedEquipmentTags.Count.ShouldBe(1); - plan.AddedEquipmentTags.ShouldBeEmpty(); - plan.RemovedEquipmentTags.ShouldBeEmpty(); - - var outcome = applier.Apply(plan); - - // The alarm-bearing tag is NOT surgical-eligible — it must rebuild and make NO surgical call. - outcome.RebuildCalled.ShouldBeTrue(); - sink.RebuildCalls.ShouldBe(1); - sink.SurgicalCalls.ShouldBeEmpty(); // NO surgical write on an alarm-bearing tag - } - - /// F10b multi-delta — when a plan contains TWO distinct surgical-eligible tag deltas (plain - /// value variables, no alarm, both changing only Writable), the applier must apply BOTH in - /// place (two UpdateTagAttributes calls) without triggering any rebuild. Proves the surgical - /// path iterates all eligible deltas rather than stopping after the first. - [Fact] - public void Two_surgical_eligible_tag_deltas_both_apply_in_place_no_rebuild() - { - var sink = new RecordingSink(); - var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - - // Two distinct plain (no-alarm) tags, both will flip Writable — both are surgical-eligible. - var previous = new AddressSpaceComposition( - Array.Empty(), Array.Empty(), Array.Empty()) - { - EquipmentTags = new[] - { - new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null), - new EquipmentTagPlan("tag-2", "eq-1", "drv", FolderPath: "", Name: "Pressure", DataType: "Float", FullName: "40002", Writable: false, Alarm: null), - }, - }; - // Only Writable flips on both tags; everything else is identical. - var next = new AddressSpaceComposition( - Array.Empty(), Array.Empty(), Array.Empty()) - { - EquipmentTags = new[] - { - new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: true, Alarm: null), - new EquipmentTagPlan("tag-2", "eq-1", "drv", FolderPath: "", Name: "Pressure", DataType: "Float", FullName: "40002", Writable: true, Alarm: null), - }, - }; - - var plan = AddressSpacePlanner.Compute(previous, next); - - // Guard the arrange: exactly two changed tags, nothing else. - plan.ChangedEquipmentTags.Count.ShouldBe(2); - plan.AddedEquipmentTags.ShouldBeEmpty(); - plan.RemovedEquipmentTags.ShouldBeEmpty(); - plan.AddedEquipment.ShouldBeEmpty(); - plan.RemovedEquipment.ShouldBeEmpty(); - - var outcome = applier.Apply(plan); - - // No rebuild — both deltas are surgical-eligible. - outcome.RebuildCalled.ShouldBeFalse(); - sink.RebuildCalls.ShouldBe(0); - - // Exactly two surgical calls — one per eligible delta. - sink.SurgicalCalls.Count.ShouldBe(2); - - // Both expected node ids must appear (order is not guaranteed). - var nodeId1 = V3NodeIds.Uns("eq-1", "Speed"); - var nodeId2 = V3NodeIds.Uns("eq-1", "Pressure"); - sink.SurgicalCalls.ShouldContain(c => c.NodeId == nodeId1 && c.Writable); - sink.SurgicalCalls.ShouldContain(c => c.NodeId == nodeId2 && c.Writable); - - outcome.ChangedNodes.ShouldBe(2); - } - - /// R2-07 T2 — a surgical-eligible tag delta MIXED with an added equipment is now a PureAdd: - /// the added equipment is created by the Materialise passes and the surgical tag change is applied IN - /// PLACE, so NO rebuild fires and exactly one surgical call lands. (Supersedes the pre-R2-07 - /// "surgical-mixed-with-add ⇒ rebuild" pin — an add no longer forces a rebuild.) - [Fact] - public void Surgical_eligible_tag_delta_mixed_with_added_equipment_is_pure_add_and_skips_rebuild() - { - var sink = new RecordingSink(); - var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - - var previous = new AddressSpaceComposition( - Array.Empty(), Array.Empty(), Array.Empty()) - { - EquipmentTags = new[] - { - new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null), - }, - }; - // Surgical-eligible Writable flip on the tag AND a brand-new equipment node. - var next = new AddressSpaceComposition( - new[] { new EquipmentNode("eq-new", "New", "line-1") }, Array.Empty(), Array.Empty()) - { - EquipmentTags = new[] - { - new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: true, Alarm: null), - }, - }; - - var plan = AddressSpacePlanner.Compute(previous, next); - plan.ChangedEquipmentTags.Count.ShouldBe(1); - plan.AddedEquipment.Count.ShouldBe(1); - - var outcome = applier.Apply(plan); - - outcome.RebuildCalled.ShouldBeFalse(); - sink.RebuildCalls.ShouldBe(0); - sink.SurgicalCalls.ShouldHaveSingleItem().Writable.ShouldBeTrue(); - } - - /// F10b fallback — a sink that does NOT implement cannot - /// apply the in-place update, so even a surgical-eligible (Writable-only) tag delta drives a full - /// rebuild (safe default). - [Fact] - public void Surgical_eligible_delta_on_non_surgical_sink_rebuilds() - { - var sink = new PlainRecordingSink(); - var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - - var previous = CompositionWithTags( - new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null)); - var next = CompositionWithTags( - new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: true, Alarm: null)); - - var plan = AddressSpacePlanner.Compute(previous, next); - plan.ChangedEquipmentTags.Count.ShouldBe(1); - - var outcome = applier.Apply(plan); - - outcome.RebuildCalled.ShouldBeTrue(); - sink.RebuildCalls.ShouldBe(1); - } - - /// F10b fallback — when the surgical sink reports the node MISSING - /// (UpdateTagAttributes returns false), the applier falls back to a full rebuild. The surgical - /// call is still attempted (recorded once) before the fallback fires. - [Fact] - public void Surgical_sink_returning_false_node_missing_falls_back_to_rebuild() - { - var sink = new RecordingSink { SurgicalReturns = false }; - var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - - var previous = CompositionWithTags( - new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null)); - var next = CompositionWithTags( - new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: true, Alarm: null)); - - var plan = AddressSpacePlanner.Compute(previous, next); - plan.ChangedEquipmentTags.Count.ShouldBe(1); - - var outcome = applier.Apply(plan); - - outcome.RebuildCalled.ShouldBeTrue(); - sink.RebuildCalls.ShouldBe(1); // fell back to a full rebuild - sink.SurgicalCalls.ShouldHaveSingleItem(); // the surgical update was attempted first - } - // ----- OpcUaServer-001: in-place UNS Area / Line folder rename (no rebuild) ----- /// OpcUaServer-001 — a deploy whose ONLY change is a UNS Area rename must SKIP the rebuild and @@ -1337,42 +707,6 @@ public sealed class AddressSpaceApplierTests // ----- R2-07 T3: pure-add NodeAdded announcements ----- - /// Two tags added under the SAME equipment (no FolderPath) dedup to ONE announcement for the - /// equipment id. - [Fact] - public void ComputeAddAnnouncements_two_tags_under_same_equipment_dedup_to_one() - { - var applier = new AddressSpaceApplier(new RecordingSink(), NullLogger.Instance); - - var plan = EmptyPlan with - { - AddedEquipmentTags = new[] - { - new EquipmentTagPlan("t1", "eq-1", "drv", FolderPath: "", Name: "A", DataType: "Float", FullName: "1", Writable: false, Alarm: null), - new EquipmentTagPlan("t2", "eq-1", "drv", FolderPath: "", Name: "B", DataType: "Float", FullName: "2", Writable: false, Alarm: null), - }, - }; - - applier.ComputeAddAnnouncements(plan).ShouldBe(new[] { "eq-1" }); - } - - /// A tag with a FolderPath announces its SUB-FOLDER (the materialise parent), not the equipment. - [Fact] - public void ComputeAddAnnouncements_tag_with_folder_path_announces_subfolder() - { - var applier = new AddressSpaceApplier(new RecordingSink(), NullLogger.Instance); - - var plan = EmptyPlan with - { - AddedEquipmentTags = new[] - { - new EquipmentTagPlan("t1", "eq-1", "drv", FolderPath: "Diag", Name: "A", DataType: "Float", FullName: "1", Writable: false, Alarm: null), - }, - }; - - applier.ComputeAddAnnouncements(plan).ShouldBe(new[] { V3NodeIds.Uns("eq-1", "Diag") }); - } - /// An added scripted alarm announces its equipment folder (where its condition node parents). [Fact] public void ComputeAddAnnouncements_added_alarm_announces_equipment() @@ -1445,11 +779,11 @@ public sealed class AddressSpaceApplierTests var plan = EmptyPlan with { AddedEquipment = new[] { new EquipmentNode("eq-2", "Two", "line-7") }, - AddedEquipmentTags = new[] + AddedEquipmentVirtualTags = new[] { - new EquipmentTagPlan("t1", "eq-1", "drv", FolderPath: "", Name: "A", DataType: "Float", FullName: "1", Writable: false, Alarm: null), - new EquipmentTagPlan("t2", "eq-1", "drv", FolderPath: "", Name: "B", DataType: "Float", FullName: "2", Writable: false, Alarm: null), - new EquipmentTagPlan("t3", "eq-1", "drv", FolderPath: "Diag", Name: "C", DataType: "Float", FullName: "3", Writable: false, Alarm: null), + new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "A", DataType: "Float", Expression: "1", DependencyRefs: Array.Empty()), + new EquipmentVirtualTagPlan("vt-2", "eq-1", FolderPath: "", Name: "B", DataType: "Float", Expression: "2", DependencyRefs: Array.Empty()), + new EquipmentVirtualTagPlan("vt-3", "eq-1", FolderPath: "Diag", Name: "C", DataType: "Float", Expression: "3", DependencyRefs: Array.Empty()), }, }; @@ -1855,13 +1189,6 @@ public sealed class AddressSpaceApplierTests Array.Empty(), lines, Array.Empty(), Array.Empty(), Array.Empty()); - private static AddressSpaceComposition CompositionWithTags(params EquipmentTagPlan[] tags) => - new( - Array.Empty(), Array.Empty(), Array.Empty()) - { - EquipmentTags = tags, - }; - private static AddressSpaceComposition CompositionWithVirtualTags(params EquipmentVirtualTagPlan[] vtags) => new( Array.Empty(), Array.Empty(), Array.Empty()) @@ -1878,6 +1205,10 @@ public sealed class AddressSpaceApplierTests new( Array.Empty(), drivers, Array.Empty()); + private static RawTagPlan RawTag(string tagId, string rawPath, EquipmentTagAlarmInfo? alarm = null) => + new(tagId, rawPath, ParentNodeId: null, "drv", Name: rawPath[(rawPath.LastIndexOf('/') + 1)..], DataType: "Float", + Writable: false, Alarm: alarm, ReferencingEquipmentPaths: Array.Empty()); + private static AddressSpacePlan EmptyPlan => new( Array.Empty(), Array.Empty(), Array.Empty(), Array.Empty(), Array.Empty(), Array.Empty(), diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceChangeClassifierTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceChangeClassifierTests.cs index 24507eb6..ad32ead8 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceChangeClassifierTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceChangeClassifierTests.cs @@ -35,7 +35,7 @@ public sealed class AddressSpaceChangeClassifierTests } [Fact] - public void Added_equipment_tag_only_is_PureAdd() + public void Added_raw_tag_only_is_PureAdd() { var plan = Plan(addedTags: new[] { Tag("tag-1", "eq-1") }); AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.PureAdd); @@ -65,7 +65,7 @@ public sealed class AddressSpaceChangeClassifierTests } [Fact] - public void Removed_equipment_tag_only_is_PureRemove() + public void Removed_raw_tag_only_is_PureRemove() { var plan = Plan(removedTags: new[] { Tag("tag-1", "eq-1") }); AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.PureRemove); @@ -116,19 +116,6 @@ public sealed class AddressSpaceChangeClassifierTests AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.Rebuild); } - [Fact] - public void Non_surgical_changed_tag_is_Rebuild() - { - // FullName re-routes the node to a different driver point ⇒ NOT surgical-eligible ⇒ Rebuild. - var plan = Plan(changedTags: new[] - { - new AddressSpacePlan.EquipmentTagDelta( - Tag("tag-1", "eq-1", fullName: "40001"), - Tag("tag-1", "eq-1", fullName: "40002")), - }); - AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.Rebuild); - } - [Fact] public void Node_relevant_changed_vtag_is_Rebuild() { @@ -144,19 +131,6 @@ public sealed class AddressSpaceChangeClassifierTests // ----- surgical-only / rename-only / driver-only ⇒ AttributeOnly ----- - [Fact] - public void Surgical_eligible_changed_tag_only_is_AttributeOnly() - { - // Only Writable flips ⇒ surgical-eligible ⇒ AttributeOnly (no adds, no removes). - var plan = Plan(changedTags: new[] - { - new AddressSpacePlan.EquipmentTagDelta( - Tag("tag-1", "eq-1", writable: false), - Tag("tag-1", "eq-1", writable: true)), - }); - AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.AttributeOnly); - } - [Fact] public void Node_irrelevant_changed_vtag_only_is_AttributeOnly() { @@ -195,36 +169,6 @@ public sealed class AddressSpaceChangeClassifierTests // ----- mixed-with-surgical: adds still win over coincident surgical changes ----- - [Fact] - public void Adds_with_coincident_surgical_change_is_PureAdd() - { - var plan = Plan( - addedTags: new[] { Tag("tag-new", "eq-1") }, - changedTags: new[] - { - new AddressSpacePlan.EquipmentTagDelta( - Tag("tag-1", "eq-1", writable: false), - Tag("tag-1", "eq-1", writable: true)), - }); - AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.PureAdd); - } - - [Fact] - public void Adds_with_coincident_non_surgical_change_is_Rebuild() - { - // Rule 2 (node-affecting change) is evaluated BEFORE the add/remove split, so a non-surgical - // change forces Rebuild even alongside pure adds. - var plan = Plan( - addedTags: new[] { Tag("tag-new", "eq-1") }, - changedTags: new[] - { - new AddressSpacePlan.EquipmentTagDelta( - Tag("tag-1", "eq-1", fullName: "40001"), - Tag("tag-1", "eq-1", fullName: "40002")), - }); - AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.Rebuild); - } - [Fact] public void Adds_with_coincident_folder_rename_is_PureAdd() { @@ -240,8 +184,9 @@ public sealed class AddressSpaceChangeClassifierTests private static ScriptedAlarmPlan Alarm(string id) => new(id, "eq-1", "scr-1", "msg"); - private static EquipmentTagPlan Tag(string id, string equipmentId, string fullName = "40001", bool writable = false) => - new(id, equipmentId, "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: fullName, Writable: writable, Alarm: null); + private static RawTagPlan Tag(string id, string device) => + new(id, $"{device}/{id}", ParentNodeId: device, "drv", Name: id, DataType: "Float", Writable: false, Alarm: null, + ReferencingEquipmentPaths: Array.Empty()); private static EquipmentVirtualTagPlan Vtag(string id, string equipmentId, string name = "Efficiency", string expression = "ctx.GetTag(\"a\")") => new(id, equipmentId, FolderPath: "", Name: name, DataType: "Float64", Expression: expression, DependencyRefs: new[] { "a" }); @@ -256,9 +201,8 @@ public sealed class AddressSpaceChangeClassifierTests IReadOnlyList? addedAlarms = null, IReadOnlyList? removedAlarms = null, IReadOnlyList? changedAlarms = null, - IReadOnlyList? addedTags = null, - IReadOnlyList? removedTags = null, - IReadOnlyList? changedTags = null, + IReadOnlyList? addedTags = null, + IReadOnlyList? removedTags = null, IReadOnlyList? addedVtags = null, IReadOnlyList? removedVtags = null, IReadOnlyList? changedVtags = null, @@ -274,9 +218,8 @@ public sealed class AddressSpaceChangeClassifierTests removedAlarms ?? Array.Empty(), changedAlarms ?? Array.Empty()) { - AddedEquipmentTags = addedTags ?? Array.Empty(), - RemovedEquipmentTags = removedTags ?? Array.Empty(), - ChangedEquipmentTags = changedTags ?? Array.Empty(), + AddedRawTags = addedTags ?? Array.Empty(), + RemovedRawTags = removedTags ?? Array.Empty(), AddedEquipmentVirtualTags = addedVtags ?? Array.Empty(), RemovedEquipmentVirtualTags = removedVtags ?? Array.Empty(), ChangedEquipmentVirtualTags = changedVtags ?? Array.Empty(), diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpacePlannerTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpacePlannerTests.cs index 7f01b9b3..227dba03 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpacePlannerTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpacePlannerTests.cs @@ -30,31 +30,6 @@ public sealed class AddressSpacePlannerTests plan.IsEmpty.ShouldBeTrue(); } - /// Verifies an equipment-tag-only delta (no equipment/driver/alarm/galaxy change) - /// yields a NON-empty plan, so OpcUaPublishActor.HandleRebuild does not short-circuit at the - /// IsEmpty gate before materialising the new equipment variables. - [Fact] - public void Equipment_tag_only_change_yields_non_empty_plan_with_added_tag() - { - var prev = new AddressSpaceComposition( - Array.Empty(), Array.Empty(), Array.Empty()); - var next = new AddressSpaceComposition( - Array.Empty(), Array.Empty(), Array.Empty()) - { - EquipmentTags = new[] - { - new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null), - }, - }; - - var plan = AddressSpacePlanner.Compute(prev, next); - - plan.IsEmpty.ShouldBeFalse(); - plan.AddedEquipmentTags.Single().TagId.ShouldBe("tag-1"); - plan.RemovedEquipmentTags.ShouldBeEmpty(); - plan.ChangedEquipmentTags.ShouldBeEmpty(); - } - /// Verifies a VirtualTag-only delta (no equipment/driver/alarm/galaxy/tag change) /// yields a NON-empty plan with the new VirtualTag in AddedEquipmentVirtualTags, so a deploy that /// only adds VirtualTags is no longer a silent no-op at the IsEmpty gate. @@ -174,42 +149,6 @@ public sealed class AddressSpacePlannerTests plan.RemovedEquipmentVirtualTags.ShouldBeEmpty(); } - /// An equipment Tag with the same id but a toggled IsArray flag (and otherwise - /// identical fields) must route to ChangedEquipmentTags. This pins that IsArray is part of - /// (record value-equality) so an array-flag-only deploy is not - /// a silent no-op at the diff/IsEmpty gate — same posture as the Historize flag. - [Fact] - public void Same_id_with_toggled_isarray_routes_to_ChangedEquipmentTags() - { - var prev = new AddressSpaceComposition( - Array.Empty(), Array.Empty(), Array.Empty()) - { - EquipmentTags = new[] - { - new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Buffer", DataType: "Int16", - FullName: "40001", Writable: false, Alarm: null, IsArray: false, ArrayLength: null), - }, - }; - var next = new AddressSpaceComposition( - Array.Empty(), Array.Empty(), Array.Empty()) - { - EquipmentTags = new[] - { - new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Buffer", DataType: "Int16", - FullName: "40001", Writable: false, Alarm: null, IsArray: true, ArrayLength: 16), - }, - }; - - var plan = AddressSpacePlanner.Compute(prev, next); - - plan.IsEmpty.ShouldBeFalse(); - plan.ChangedEquipmentTags.Single().Previous.IsArray.ShouldBeFalse(); - plan.ChangedEquipmentTags.Single().Current.IsArray.ShouldBeTrue(); - plan.ChangedEquipmentTags.Single().Current.ArrayLength.ShouldBe((uint)16); - plan.AddedEquipmentTags.ShouldBeEmpty(); - plan.RemovedEquipmentTags.ShouldBeEmpty(); - } - /// Regression guard for structural equality on : /// two snapshots containing the SAME VirtualTag built from SEPARATE list instances must diff to an empty plan /// (IReadOnlyList equality is BY REFERENCE without the custom Equals override, so every VirtualTag with