diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs index 2438c3c6..d91a2047 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs @@ -17,8 +17,10 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer; /// create exactly the added folders/variables/conditions (no-op'ing existing nodes), then /// AnnounceAddedNodes raises a Part 3 NodeAdded per affected parent so /// model-aware clients re-browse. Existing MonitoredItems are untouched. -/// AttributeOnly — no rebuild. Surgical-eligible changed tags + UNS folder renames -/// are applied IN PLACE via (F10b). Any false +/// AttributeOnly — no rebuild. Surgical-eligible changed equipment tags, changed RAW +/// tags (in the Raw realm) and UNS folder renames are applied IN PLACE via +/// (F10b); a changed UNS reference variable is a +/// remove-previous here + recreate-current in the materialise pass. Any false /// or throw from a surgical call falls back to a full rebuild (safe default). /// PureRemove — scoped in-place teardown of only the removed subtree (writes a /// terminal Bad / RemovedConditionState first so in-flight MonitoredItems observe the @@ -160,6 +162,12 @@ public sealed class AddressSpaceApplier // MaterialiseHierarchy re-creates every folder with the new display names, so the renames are // covered for free and need no separate surgical pass. var renamedFolders = plan.RenamedFolders; + // v3 surgical raw-tag + UNS-reference deltas (2026-08-15 surgical-deploys, Task 5). When kind is not + // Rebuild the classifier has ALREADY guaranteed every ChangedRawTags entry is surgical-eligible; the + // filter is defense-in-depth so a future classifier change can only ever under-apply here (the + // non-eligible remainder is left to the rebuild the classifier would have demanded). + var surgicalRawTagDeltas = plan.ChangedRawTags.Where(AddressSpaceChangeClassifier.RawTagDeltaIsSurgicalEligible).ToList(); + var unsRefDeltas = plan.ChangedUnsReferenceVariables; var rebuilt = false; var rebuildFailed = false; @@ -168,7 +176,8 @@ public sealed class AddressSpaceApplier rebuildFailed = !SafeRebuild(); rebuilt = true; } - else if (surgicalTagDeltas.Count > 0 || renamedFolders.Count > 0 || hasRemovePass) + else if (surgicalTagDeltas.Count > 0 || renamedFolders.Count > 0 || hasRemovePass || + surgicalRawTagDeltas.Count > 0 || unsRefDeltas.Count > 0) { if (_sink is ISurgicalAddressSpaceSink surgical) { @@ -204,6 +213,58 @@ public sealed class AddressSpaceApplier } 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 + // a single UNS variable. + foreach (var d in allApplied ? surgicalRawTagDeltas : Enumerable.Empty()) + { + // ReferencingEquipmentPaths is node-INERT for a plain value tag (it only drives native-alarm + // notifier wiring, and an alarm-bearing delta never reaches here — the classifier routes it + // to Rebuild). So a delta whose ONLY difference is the referencing set needs NOTHING said to + // the sink: compare the two sides with the referencing set copied across, and skip on equal. + if ((d.Previous with { ReferencingEquipmentPaths = d.Current.ReferencingEquipmentPaths }).Equals(d.Current)) + { + continue; + } + + // Compute writable + historian EXACTLY as MaterialiseRawSubtree does so the in-place update + // matches what a rebuild would have produced: arrays are forced read-only (the driver write + // path can't handle them) and a blank historian override falls back to the RawPath (== NodeId). + var writable = d.Current.Writable && !d.Current.IsArray; + var historian = d.Current.IsHistorized + ? (string.IsNullOrWhiteSpace(d.Current.HistorianTagname) ? d.Current.NodeId : d.Current.HistorianTagname) + : null; + bool ok; + try { ok = surgical.UpdateTagAttributes(d.Current.NodeId, writable, historian, d.Current.DataType, d.Current.IsArray, d.Current.ArrayLength, AddressSpaceRealm.Raw); } + catch (Exception ex) + { + _logger.LogError(ex, "AddressSpaceApplier: surgical UpdateTagAttributes threw for raw {Node}", d.Current.NodeId); + ok = false; + } + if (!ok) { allApplied = false; break; } + } + // v3 UNS reference variables — REMOVE the previous node (terminal Bad first, so an in-flight + // MonitoredItem observes the removal), and let the CURRENT node be (re)created by the + // materialise pass that runs after Apply returns. + // + // WHY remove+recreate rather than an in-place update: a UnsReferenceDelta is either a re-point + // (BackingRawPath moved — the node's Organizes edge must move with it, and the surgical sink + // surface has no RemoveReference) or a display-name-override edit (the effective name is part of + // the UNS NodeId, so the identity itself changes — an OPC UA NodeId is immutable). Remove+recreate + // covers both shapes with one mechanism. The cost is that ONE node's monitored items, against + // today's cost of a full rebuild killing every subscription server-wide. + // + // WHERE the recreate happens: the publish actor's materialise passes, which run after Apply + // returns — full-composition MaterialiseUnsReferences today, narrowed to the plan's added + + // changed-current references by Task 6 of the surgical-deploys plan. Either way the ordering + // holds (remove here, recreate strictly after), and the recreated node inherits its shape / + // historian tagname from the FULL composition's backing raw tag. + foreach (var d in allApplied ? unsRefDeltas : Enumerable.Empty()) + { + if (!SafeWriteValue(d.Previous.NodeId, null, OpcUaStatus.Bad, ts, AddressSpaceRealm.Uns)) failedNodes++; + if (!SafeRemoveVariable(surgical, d.Previous.NodeId, AddressSpaceRealm.Uns)) { allApplied = false; break; } + } // R2-07 Phase 2/3 — scoped remove teardown for PureRemove AND AddRemoveMix (runs after any // coincident surgical attribute updates / renames succeeded). Terminal Bad / // RemovedConditionState writes then in-place removes; any false/throw flips allApplied → the @@ -224,8 +285,10 @@ public sealed class AddressSpaceApplier } _logger.LogInformation( - "AddressSpaceApplier: applied plan (kind={Kind}, added={Added}, removed={Removed}, changed={Changed}, surgicalTags={Surgical}, renamedFolders={Renamed}, rebuild={Rebuild})", - kind, addedCount, removedCount, changedCount, rebuilt ? 0 : surgicalTagDeltas.Count, rebuilt ? 0 : renamedFolders.Count, rebuilt); + "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, + rebuilt ? 0 : surgicalRawTagDeltas.Count, rebuilt ? 0 : unsRefDeltas.Count, + rebuilt ? 0 : renamedFolders.Count, rebuilt); // After the address-space work has completed, auto-provision the historian for the added // historized tags. This is fully detached (fire-and-forget) and wrapped so it can NEVER block 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 7151a280..b9535fa5 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierTests.cs @@ -147,8 +147,8 @@ public sealed class AddressSpaceApplierTests 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)); - sink.RemoveCalls.ShouldHaveSingleItem().ShouldBe(("var", nodeId)); + 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 @@ -178,7 +178,7 @@ public sealed class AddressSpaceApplierTests write.NodeId.ShouldBe(nodeId); (!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)); + sink.RemoveCalls.ShouldHaveSingleItem().ShouldBe(("alarm", nodeId, AddressSpaceRealm.Uns)); sink.ValueWrites.ShouldNotContain(w => w.NodeId == nodeId); } @@ -207,7 +207,7 @@ public sealed class AddressSpaceApplierTests 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")); + sink.RemoveCalls.ShouldHaveSingleItem().ShouldBe(("equipment", "eq-1", AddressSpaceRealm.Uns)); } /// R2-07 T11 — if a surgical remove reports the node unknown (RemoveReturns=false), the applier @@ -283,7 +283,7 @@ 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"))); + sink.RemoveCalls.ShouldHaveSingleItem().ShouldBe(("var", V3NodeIds.Uns("eq-1", "Old"), AddressSpaceRealm.Uns)); outcome.AddedNodes.ShouldBe(1); outcome.RemovedNodes.ShouldBe(1); } @@ -314,7 +314,7 @@ public sealed class AddressSpaceApplierTests var outcome = applier.Apply(plan); outcome.RebuildCalled.ShouldBeFalse(); - sink.RemoveCalls.ShouldHaveSingleItem().ShouldBe(("var", nodeId)); + sink.RemoveCalls.ShouldHaveSingleItem().ShouldBe(("var", nodeId, AddressSpaceRealm.Uns)); } /// R2-07 T13 — a remove failure inside an AddRemoveMix still trips the rebuild ratchet. @@ -787,8 +787,8 @@ public sealed class AddressSpaceApplierTests 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"))); - sink.RemoveCalls.ShouldContain(("var", V3NodeIds.Uns("eq-1", "Efficiency"))); + sink.RemoveCalls.ShouldContain(("var", V3NodeIds.Uns("eq-1", "Speed"), AddressSpaceRealm.Uns)); + sink.RemoveCalls.ShouldContain(("var", V3NodeIds.Uns("eq-1", "Efficiency"), AddressSpaceRealm.Uns)); } // ----- F10b: surgical in-place tag-attribute writes (Writable / IsHistorized / HistorianTagname) ----- @@ -1434,6 +1434,204 @@ public sealed class AddressSpaceApplierTests new[] { "eq-1", V3NodeIds.Uns("eq-1", "Diag"), "line-7" }.OrderBy(x => x)); } + // ----- Surgical v3 deltas: raw-tag attribute edits (Raw realm) + UNS-reference remove ----- + // Task 4 taught AddressSpaceChangeClassifier that a non-alarm RawTagDelta and any UnsReferenceDelta are + // surgical-eligible; these pin the applier actually APPLYING them (before this, such a plan classified + // AttributeOnly and fell through the surgical branch as a silent no-op). + + /// A non-alarm raw-tag delta (historize flipped on) is applied IN PLACE on the raw node via + /// ISurgicalAddressSpaceSink.UpdateTagAttributes in the Raw realm — no rebuild, so every + /// client subscription survives. The arguments match + /// exactly: a blank historian override resolves to the RawPath (== NodeId). + [Fact] + public void Surgical_raw_tag_delta_updates_in_place_raw_realm() + { + var sink = new RecordingSink(); + var applier = new AddressSpaceApplier(sink, NullLogger.Instance); + + var previous = RawTag("Plant/Modbus/dev1/speed"); + var current = previous with { IsHistorized = true }; + + var plan = AddressSpacePlanner.Compute(CompositionWithRawTags(previous), CompositionWithRawTags(current)); + plan.ChangedRawTags.Count.ShouldBe(1); + plan.AddedRawTags.ShouldBeEmpty(); + plan.RemovedRawTags.ShouldBeEmpty(); + + var outcome = applier.Apply(plan); + + outcome.RebuildCalled.ShouldBeFalse(); + sink.RebuildCalls.ShouldBe(0); + var call = sink.SurgicalCalls.ShouldHaveSingleItem(); + call.NodeId.ShouldBe("Plant/Modbus/dev1/speed"); + call.Writable.ShouldBeTrue(); // Writable && !IsArray + call.Historian.ShouldBe("Plant/Modbus/dev1/speed"); // blank override ⇒ the RawPath + call.DataType.ShouldBe("Float"); + call.IsArray.ShouldBeFalse(); + call.ArrayLength.ShouldBeNull(); + call.Realm.ShouldBe(AddressSpaceRealm.Raw); // the raw node, NOT a UNS projection + outcome.ChangedNodes.ShouldBe(1); + } + + /// MaterialiseRawSubtree parity for the two computed arguments: an ARRAY tag is forced read-only + /// (the driver write path can't handle arrays) and a non-blank HistorianTagname override wins over + /// the RawPath default. + [Fact] + public void Surgical_raw_tag_delta_forces_array_read_only_and_honours_historian_override() + { + var sink = new RecordingSink(); + var applier = new AddressSpaceApplier(sink, NullLogger.Instance); + + var previous = RawTag("Plant/Modbus/dev1/block") with + { + Writable = true, IsArray = true, ArrayLength = 8, IsHistorized = true, HistorianTagname = "WW.Old", + }; + var current = previous with { HistorianTagname = "WW.New", ArrayLength = 16 }; + + var plan = AddressSpacePlanner.Compute(CompositionWithRawTags(previous), CompositionWithRawTags(current)); + plan.ChangedRawTags.Count.ShouldBe(1); + + applier.Apply(plan); + + sink.RebuildCalls.ShouldBe(0); + var call = sink.SurgicalCalls.ShouldHaveSingleItem(); + call.Writable.ShouldBeFalse(); // Writable && !IsArray ⇒ an array tag is read-only + call.Historian.ShouldBe("WW.New"); // override verbatim + call.IsArray.ShouldBeTrue(); + call.ArrayLength.ShouldBe(16u); + call.Realm.ShouldBe(AddressSpaceRealm.Raw); + } + + /// The headline UNS-reference case: adding/dropping ONE UnsTagReference flips its backing + /// raw tag to Changed purely through ReferencingEquipmentPaths, which is node-INERT for a value tag. + /// The applier must recognise that and make NO sink call at all — not a rebuild, and not even an + /// idempotent in-place update. + [Fact] + public void Reference_only_raw_tag_delta_skips_the_sink_call() + { + var sink = new RecordingSink(); + var applier = new AddressSpaceApplier(sink, NullLogger.Instance); + + var previous = RawTag("Plant/Modbus/dev1/speed"); + var current = previous with { ReferencingEquipmentPaths = new[] { "filling/line-1/station-1" } }; + + var plan = AddressSpacePlanner.Compute(CompositionWithRawTags(previous), CompositionWithRawTags(current)); + plan.ChangedRawTags.Count.ShouldBe(1); + + var outcome = applier.Apply(plan); + + outcome.RebuildCalled.ShouldBeFalse(); + sink.RebuildCalls.ShouldBe(0); + sink.SurgicalCalls.ShouldBeEmpty(); // node-inert ⇒ nothing to say to the sink + } + + /// A UNS-reference delta (here a display-name-override edit, which moves the NodeId) tears the + /// PREVIOUS node down in place: a terminal Bad so an in-flight MonitoredItem observes the removal, then + /// RemoveVariableNode — both in the Uns realm, no rebuild. The RECREATE of the current node is NOT the + /// applier's job (it rides the materialise pass after Apply returns). + [Fact] + public void Uns_reference_delta_removes_previous_node() + { + var sink = new RecordingSink(); + var applier = new AddressSpaceApplier(sink, NullLogger.Instance); + + var previous = UnsRef("filling/line-1/station-1/speed", "speed"); + var current = previous with { NodeId = "filling/line-1/station-1/Line Speed", EffectiveName = "Line Speed" }; + + var plan = AddressSpacePlanner.Compute(CompositionWithUnsRefs(previous), CompositionWithUnsRefs(current)); + plan.ChangedUnsReferenceVariables.Count.ShouldBe(1); + plan.AddedUnsReferenceVariables.ShouldBeEmpty(); + plan.RemovedUnsReferenceVariables.ShouldBeEmpty(); + + var outcome = applier.Apply(plan); + + outcome.RebuildCalled.ShouldBeFalse(); + sink.RebuildCalls.ShouldBe(0); + sink.ValueWrites.ShouldContain((previous.NodeId, OpcUaStatus.Bad, AddressSpaceRealm.Uns)); + sink.RemoveCalls.ShouldHaveSingleItem().ShouldBe(("var", previous.NodeId, AddressSpaceRealm.Uns)); + // The applier never recreates — no EnsureVariable for the new NodeId here. + sink.VariableCalls.ShouldBeEmpty(); + outcome.ChangedNodes.ShouldBe(1); + } + + /// The one-way rebuild ratchet holds for the new raw-tag path: a surgical update that reports the + /// node missing (false) falls back to a full rebuild. + [Fact] + public void Surgical_raw_tag_false_falls_back_to_rebuild() + { + var sink = new RecordingSink { SurgicalReturns = false }; + var applier = new AddressSpaceApplier(sink, NullLogger.Instance); + + var previous = RawTag("Plant/Modbus/dev1/speed"); + var current = previous with { IsHistorized = true }; + + var outcome = applier.Apply( + AddressSpacePlanner.Compute(CompositionWithRawTags(previous), CompositionWithRawTags(current))); + + sink.SurgicalCalls.ShouldHaveSingleItem(); // attempted in place first + outcome.RebuildCalled.ShouldBeTrue(); + sink.RebuildCalls.ShouldBe(1); + } + + /// The same ratchet for the UNS-reference remove: a remove reporting the node missing (false) + /// falls back to a full rebuild. + [Fact] + public void Uns_reference_remove_false_falls_back_to_rebuild() + { + var sink = new RecordingSink { RemoveReturns = false }; + var applier = new AddressSpaceApplier(sink, NullLogger.Instance); + + var previous = UnsRef("filling/line-1/station-1/speed", "speed"); + var current = previous with { BackingRawPath = "Plant/Modbus/dev1/speed2" }; + + var outcome = applier.Apply( + AddressSpacePlanner.Compute(CompositionWithUnsRefs(previous), CompositionWithUnsRefs(current))); + + sink.RemoveCalls.ShouldHaveSingleItem(); // attempted in place first + outcome.RebuildCalled.ShouldBeTrue(); + sink.RebuildCalls.ShouldBe(1); + } + + /// A sink lacking the surgical capability cannot apply a raw-tag delta in place, so it must + /// REBUILD. This is what closes the Task-4 gap for the fallback case: before the branch trigger learned + /// about raw-tag deltas, such a plan classified AttributeOnly and applied nothing at all. + [Fact] + public void Raw_tag_delta_with_non_surgical_sink_rebuilds() + { + var sink = new PlainRecordingSink(); + var applier = new AddressSpaceApplier(sink, NullLogger.Instance); + + var previous = RawTag("Plant/Modbus/dev1/speed"); + var current = previous with { IsHistorized = true }; + + var outcome = applier.Apply( + AddressSpacePlanner.Compute(CompositionWithRawTags(previous), CompositionWithRawTags(current))); + + outcome.RebuildCalled.ShouldBeTrue(); + sink.RebuildCalls.ShouldBe(1); + } + + private static RawTagPlan RawTag(string rawPath) => + new("tag-" + rawPath, rawPath, ParentNodeId: "Plant/Modbus/dev1", DriverInstanceId: "drv-1", + Name: rawPath[(rawPath.LastIndexOf('/') + 1)..], DataType: "Float", Writable: true, Alarm: null, + ReferencingEquipmentPaths: Array.Empty()); + + private static UnsReferenceVariable UnsRef(string nodeId, string effectiveName) => + new("ref-1", "eq-1", nodeId, effectiveName, "Plant/Modbus/dev1/speed", "Float", Writable: true); + + private static AddressSpaceComposition CompositionWithRawTags(params RawTagPlan[] tags) => + new( + Array.Empty(), Array.Empty(), Array.Empty()) + { + RawTags = tags, + }; + + private static AddressSpaceComposition CompositionWithUnsRefs(params UnsReferenceVariable[] refs) => + new( + Array.Empty(), Array.Empty(), Array.Empty()) + { + UnsReferenceVariables = refs, + }; + private static AddressSpaceComposition CompositionWithAreas(params UnsAreaProjection[] areas) => new( areas, Array.Empty(), Array.Empty(), @@ -1486,9 +1684,9 @@ public sealed class AddressSpaceApplierTests private sealed class RecordingSink : IOpcUaAddressSpaceSink, ISurgicalAddressSpaceSink { /// Gets the queue of surgical in-place tag-attribute update calls (F10b + FB-7). - public ConcurrentQueue<(string NodeId, bool Writable, string? Historian, string DataType, bool IsArray, uint? ArrayLength)> SurgicalQueue { get; } = new(); + public ConcurrentQueue<(string NodeId, bool Writable, string? Historian, string DataType, bool IsArray, uint? ArrayLength, AddressSpaceRealm Realm)> SurgicalQueue { get; } = new(); /// Gets the list of recorded surgical in-place tag-attribute update calls. - public List<(string NodeId, bool Writable, string? Historian, string DataType, bool IsArray, uint? ArrayLength)> SurgicalCalls => SurgicalQueue.ToList(); + public List<(string NodeId, bool Writable, string? Historian, string DataType, bool IsArray, uint? ArrayLength, AddressSpaceRealm Realm)> SurgicalCalls => SurgicalQueue.ToList(); /// When false, reports the node missing (returns false), /// driving the applier's rebuild fallback. Defaults to true (node present, update succeeds). public bool SurgicalReturns { get; init; } = true; @@ -1502,7 +1700,7 @@ public sealed class AddressSpaceApplierTests /// The new 1-D array length when is true. public bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname, string dataType, bool isArray, uint? arrayLength, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { - SurgicalQueue.Enqueue((variableNodeId, writable, historianTagname, dataType, isArray, arrayLength)); + SurgicalQueue.Enqueue((variableNodeId, writable, historianTagname, dataType, isArray, arrayLength, realm)); return SurgicalReturns; } @@ -1524,9 +1722,9 @@ public sealed class AddressSpaceApplierTests } /// Gets the queue of surgical remove calls (kind + node id) in call order (R2-07 Phase 2). - public ConcurrentQueue<(string Kind, string NodeId)> RemoveQueue { get; } = new(); + public ConcurrentQueue<(string Kind, string NodeId, AddressSpaceRealm Realm)> RemoveQueue { get; } = new(); /// Gets the list of recorded surgical remove calls. - public List<(string Kind, string NodeId)> RemoveCalls => RemoveQueue.ToList(); + public List<(string Kind, string NodeId, AddressSpaceRealm Realm)> RemoveCalls => RemoveQueue.ToList(); /// When false, the Remove* members report the node missing (return false), driving the /// applier's rebuild fallback. Defaults to true (node present, removal succeeds). public bool RemoveReturns { get; init; } = true; @@ -1534,21 +1732,21 @@ public sealed class AddressSpaceApplierTests /// Records a surgical variable-node removal; returns . public bool RemoveVariableNode(string variableNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { - RemoveQueue.Enqueue(("var", variableNodeId)); + RemoveQueue.Enqueue(("var", variableNodeId, realm)); return RemoveReturns; } /// Records a surgical alarm-condition-node removal; returns . public bool RemoveAlarmConditionNode(string alarmNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { - RemoveQueue.Enqueue(("alarm", alarmNodeId)); + RemoveQueue.Enqueue(("alarm", alarmNodeId, realm)); return RemoveReturns; } /// Records a surgical equipment-subtree removal; returns . public bool RemoveEquipmentSubtree(string equipmentNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { - RemoveQueue.Enqueue(("equipment", equipmentNodeId)); + RemoveQueue.Enqueue(("equipment", equipmentNodeId, realm)); return RemoveReturns; } @@ -1583,9 +1781,9 @@ public sealed class AddressSpaceApplierTests public List<(string AlarmNodeId, string EquipmentNodeId, string DisplayName, string AlarmType, int Severity, bool IsNative)> AlarmConditionCalls => AlarmConditionQueue.ToList(); /// Gets the queue of value writes (NodeId, quality) — used to assert the PureRemove terminal Bad. - public ConcurrentQueue<(string NodeId, uint Quality)> ValueWriteQueue { get; } = new(); + public ConcurrentQueue<(string NodeId, uint Quality, AddressSpaceRealm Realm)> ValueWriteQueue { get; } = new(); /// Gets the list of recorded value writes. - public List<(string NodeId, uint Quality)> ValueWrites => ValueWriteQueue.ToList(); + public List<(string NodeId, uint Quality, AddressSpaceRealm Realm)> ValueWrites => ValueWriteQueue.ToList(); /// Records a value write (NodeId + quality). /// The node ID. @@ -1593,7 +1791,7 @@ public sealed class AddressSpaceApplierTests /// The OPC UA status code, sub-code included. /// The source timestamp in UTC. public void WriteValue(string nodeId, object? value, uint statusCode, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) - => ValueWriteQueue.Enqueue((nodeId, statusCode)); + => ValueWriteQueue.Enqueue((nodeId, statusCode, realm)); /// Records an alarm condition write call. /// The alarm node ID. /// The full condition state snapshot.