diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Applier.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Applier.cs index 6359e37e..4d405233 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Applier.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Applier.cs @@ -84,41 +84,90 @@ public sealed class Phase7Applier // Any add / remove / in-place CHANGE of Equipment, ScriptedAlarm, Equipment tag, or Equipment // VirtualTag topology requires a real address-space rebuild — the materialise passes re-derive // every node from the composition, so a changed-only deploy (e.g. a renamed equipment, a - // re-severitied alarm, a flipped tag dataType/Writable) must still rebuild or the running server - // keeps the stale node. + // re-severitied alarm, a flipped tag dataType) must still rebuild or the running server keeps + // the stale node. // ChangedDrivers is deliberately EXCLUDED: a driver-instance config change doesn't touch the // address-space topology — it routes through DriverHostActor's spawn-plan in Runtime, which // re-spawns the affected driver actor without re-materialising any nodes. - // F10b: a CHANGED VirtualTag whose ONLY differences are Expression/DependencyRefs/Historize is - // node-IRRELEVANT (see VtagDeltaIsNodeIrrelevant) — its materialised node is byte-identical and - // the vtag engine adopts those edits via VirtualTagHostActor's INDEPENDENT respawn - // (DriverHostActor → ApplyVirtualTags), so it skips the rebuild and PRESERVES every client's - // server-wide subscriptions. Any structural / node-affecting vtag change (Name/FolderPath/ - // DataType) — or any non-vtag change anywhere — still forces a full rebuild (safe default). - var needsRebuild = + // F10b (vtag skip): a CHANGED VirtualTag whose ONLY differences are Expression/DependencyRefs/ + // Historize is node-IRRELEVANT (see VtagDeltaIsNodeIrrelevant) — its materialised node is + // byte-identical and the vtag engine adopts those edits via VirtualTagHostActor's INDEPENDENT + // respawn (DriverHostActor → ApplyVirtualTags), so it skips the rebuild and PRESERVES every + // client's server-wide subscriptions. Any structural / node-affecting vtag change (Name/ + // FolderPath/DataType) — or any non-vtag change anywhere — still forces a full rebuild. + // F10b (surgical tag write): a CHANGED equipment tag whose ONLY differences are Writable / + // IsHistorized / HistorianTagname (a plain value variable — no alarm condition node) can be + // updated IN PLACE on the existing node via ISurgicalAddressSpaceSink.UpdateTagAttributes + // (see TagDeltaIsSurgicalEligible), again avoiding the full rebuild and preserving subscriptions. + // Any other tag difference (DataType/IsArray/ArrayLength/FullName/identity/alarm) — or a sink + // that lacks the surgical capability, or a node that turns out missing — falls back to a full + // rebuild (safe default). + var structuralRebuild = plan.AddedEquipment.Count > 0 || plan.RemovedEquipment.Count > 0 || plan.ChangedEquipment.Count > 0 || plan.AddedAlarms.Count > 0 || plan.RemovedAlarms.Count > 0 || plan.ChangedAlarms.Count > 0 || - plan.AddedEquipmentTags.Count > 0 || plan.RemovedEquipmentTags.Count > 0 || plan.ChangedEquipmentTags.Count > 0 || + plan.AddedEquipmentTags.Count > 0 || plan.RemovedEquipmentTags.Count > 0 || + plan.ChangedEquipmentTags.Any(d => !TagDeltaIsSurgicalEligible(d)) || plan.AddedEquipmentVirtualTags.Count > 0 || plan.RemovedEquipmentVirtualTags.Count > 0 || plan.ChangedEquipmentVirtualTags.Any(d => !VtagDeltaIsNodeIrrelevant(d)); - if (needsRebuild) + var surgicalTagDeltas = plan.ChangedEquipmentTags.Where(TagDeltaIsSurgicalEligible).ToList(); + var rebuilt = false; + + if (structuralRebuild) { - try + SafeRebuild(); + rebuilt = true; + } + else if (surgicalTagDeltas.Count > 0) + { + if (_sink is ISurgicalAddressSpaceSink surgical) { - _sink.RebuildAddressSpace(); + var allApplied = true; + foreach (var d in surgicalTagDeltas) + { + // Compute the node id + writable + historian EXACTLY as MaterialiseEquipmentTags would + // so the in-place update matches what a rebuild would have produced. + var nodeId = EquipmentNodeIds.Variable(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); } + catch (Exception ex) + { + _logger.LogError(ex, "Phase7Applier: surgical UpdateTagAttributes threw for {Node}", nodeId); + ok = false; + } + if (!ok) { allApplied = false; break; } + } + if (!allApplied) { SafeRebuild(); rebuilt = true; } } - catch (Exception ex) + else { - _logger.LogError(ex, "Phase7Applier: sink.RebuildAddressSpace threw"); + // Sink lacks the surgical capability ⇒ rebuild (safe default). + SafeRebuild(); + rebuilt = true; } } _logger.LogInformation( - "Phase7Applier: applied plan (added={Added}, removed={Removed}, changed={Changed}, rebuild={Rebuild})", - addedCount, removedCount, changedCount, needsRebuild); + "Phase7Applier: applied plan (added={Added}, removed={Removed}, changed={Changed}, surgicalTags={Surgical}, rebuild={Rebuild})", + addedCount, removedCount, changedCount, rebuilt ? 0 : surgicalTagDeltas.Count, rebuilt); - return new Phase7ApplyOutcome(removedCount, addedCount, changedCount, needsRebuild); + return new Phase7ApplyOutcome(removedCount, addedCount, changedCount, rebuilt); + } + + private void SafeRebuild() + { + try + { + _sink.RebuildAddressSpace(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Phase7Applier: sink.RebuildAddressSpace threw"); + } } /// @@ -340,6 +389,20 @@ public sealed class Phase7Applier Historize = d.Current.Historize, }).Equals(d.Current); + // F10b: a CHANGED equipment tag whose ONLY differences are Writable / IsHistorized / HistorianTagname + // (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). + // DataType / IsArray / ArrayLength / FullName / DriverInstanceId / identity / alarm differences fall + // through to a rebuild — the override-unequal default also covers any future field. + private static bool TagDeltaIsSurgicalEligible(Phase7Plan.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, + }).Equals(d.Current); + /// The "no-event" condition state written to a removed equipment / alarm node before the /// rebuild tears it down: inactive, acked, confirmed, enabled, unshelved, severity 0, empty message. /// Drives Retain to false so a removed condition stops replaying on ConditionRefresh. diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ApplierTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ApplierTests.cs index e85e15e1..d3eccd8a 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ApplierTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ApplierTests.cs @@ -1058,6 +1058,310 @@ public sealed class Phase7ApplierTests outcome.RemovedNodes.ShouldBe(2); // both removals counted (was 0 before the fix) } + // ----- 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 Phase7Applier(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 = Phase7Planner.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(EquipmentNodeIds.Variable("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 Phase7Applier(sinkOn, NullLogger.Instance); + var planOn = Phase7Planner.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 Phase7Applier(sinkOff, NullLogger.Instance); + var planOff = Phase7Planner.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 Phase7Applier(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 = Phase7Planner.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 + } + + /// F10b safe-default — a tag delta whose DataType changed is NOT surgical-eligible (the + /// node's value type would differ), so the applier must rebuild and make NO surgical call. + [Fact] + public void Changed_tag_data_type_change_rebuilds_and_no_surgical_call() + { + var sink = new RecordingSink(); + var applier = new Phase7Applier(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 AND Writable flips — DataType is node-affecting, so this must rebuild. + var next = CompositionWithTags( + new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Int32", FullName: "40001", Writable: true, Alarm: null)); + + var plan = Phase7Planner.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 IsArray flag changed is NOT surgical-eligible + /// (array-ness drives ValueRank/ArrayDimensions on the node), so the applier rebuilds. + [Fact] + public void Changed_tag_is_array_change_rebuilds() + { + var sink = new RecordingSink(); + var applier = new Phase7Applier(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 = Phase7Planner.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 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 Phase7Applier(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 = Phase7Planner.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 Phase7Applier(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 = Phase7Planner.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 Phase7Applier(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 = Phase7Planner.Compute(previous, next); + plan.ChangedEquipmentTags.Count.ShouldBe(1); + + var outcome = applier.Apply(plan); + + outcome.RebuildCalled.ShouldBeTrue(); + sink.RebuildCalls.ShouldBe(1); + sink.SurgicalCalls.ShouldBeEmpty(); + } + + /// F10b — a surgical-eligible tag delta MIXED with another change (here an added equipment) + /// must still rebuild: the rebuild is forced by the OTHER change. The surgical path is taken ONLY when + /// the tag deltas are the sole change. No surgical call is made (the rebuild materialises everything). + [Fact] + public void Surgical_eligible_tag_delta_mixed_with_added_equipment_rebuilds() + { + var sink = new RecordingSink(); + var applier = new Phase7Applier(sink, NullLogger.Instance); + + var previous = new Phase7CompositionResult( + 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 Phase7CompositionResult( + 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 = Phase7Planner.Compute(previous, next); + plan.ChangedEquipmentTags.Count.ShouldBe(1); + plan.AddedEquipment.Count.ShouldBe(1); + + var outcome = applier.Apply(plan); + + outcome.RebuildCalled.ShouldBeTrue(); + sink.RebuildCalls.ShouldBe(1); + sink.SurgicalCalls.ShouldBeEmpty(); + } + + /// 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 Phase7Applier(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 = Phase7Planner.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 Phase7Applier(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 = Phase7Planner.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 + } + private static Phase7CompositionResult CompositionWithTags(params EquipmentTagPlan[] tags) => new( Array.Empty(), Array.Empty(), Array.Empty()) @@ -1097,8 +1401,26 @@ public sealed class Phase7ApplierTests RemovedAlarms: Array.Empty(), ChangedAlarms: Array.Empty()); - private sealed class RecordingSink : IOpcUaAddressSpaceSink + private sealed class RecordingSink : IOpcUaAddressSpaceSink, ISurgicalAddressSpaceSink { + /// Gets the queue of surgical in-place tag-attribute update calls (F10b). + public ConcurrentQueue<(string NodeId, bool Writable, string? Historian)> SurgicalQueue { get; } = new(); + /// Gets the list of recorded surgical in-place tag-attribute update calls. + public List<(string NodeId, bool Writable, string? Historian)> 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; + + /// Records a surgical in-place tag-attribute update; returns . + /// The variable node ID to update in place. + /// The new Writable (AccessLevel) for the node. + /// The resolved historian tagname (null ⇒ not historized). + public bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname) + { + SurgicalQueue.Enqueue((variableNodeId, writable, historianTagname)); + return SurgicalReturns; + } + /// Gets the queue of alarm condition write calls. public ConcurrentQueue<(string NodeId, AlarmConditionSnapshot State)> AlarmQueue { get; } = new(); /// Gets the queue of folder creation calls. @@ -1172,6 +1494,28 @@ public sealed class Phase7ApplierTests public void RebuildAddressSpace() => Interlocked.Increment(ref RebuildCalls); } + /// A recording sink that does NOT implement — used to + /// prove the F10b fallback: when the bound sink lacks the surgical capability, a surgical-eligible tag + /// delta still drives a full RebuildAddressSpace. + private sealed class PlainRecordingSink : IOpcUaAddressSpaceSink + { + /// Gets the number of rebuild calls made on this sink. + public int RebuildCalls; + + /// Records a value write (no-op in this sink). + public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc) { } + /// No-op alarm condition write call. + public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc) { } + /// No-op alarm-condition materialise call. + public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false) { } + /// No-op folder creation call. + public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName) { } + /// No-op variable creation call. + public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) { } + /// Records a rebuild address space call. + public void RebuildAddressSpace() => Interlocked.Increment(ref RebuildCalls); + } + private sealed class ThrowingSink : IOpcUaAddressSpaceSink { private readonly bool _throwOnAlarmWrite;