using System.Collections.Concurrent; using Microsoft.Extensions.Logging.Abstractions; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests; 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.) [Fact] public void Added_equipment_virtual_tags_are_pure_add_and_skip_rebuild() { var sink = new RecordingSink(); var applier = new AddressSpaceApplier(sink, NullLogger.Instance); var plan = EmptyPlan with { AddedEquipmentVirtualTags = new[] { new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "Efficiency", DataType: "Float", Expression: "a + b", DependencyRefs: new[] { "a", "b" }), }, }; var outcome = applier.Apply(plan); outcome.RebuildCalled.ShouldBeFalse(); outcome.AddedNodes.ShouldBe(1); 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. [Fact] public void Pure_add_with_coincident_surgical_tag_delta_skips_rebuild_and_updates_in_place() { var sink = new RecordingSink(); 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.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, OpcUaQuality.Bad)); sink.RemoveCalls.ShouldHaveSingleItem().ShouldBe(("var", nodeId)); } /// 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. var write = sink.AlarmWrites.ShouldHaveSingleItem(); 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.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 { 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")); } /// R2-07 T11 — if a surgical remove reports the node unknown (RemoveReturns=false), the applier /// falls back to a full rebuild (the one-way ratchet) and attempts no further surgical removes. [Fact] public void Pure_remove_with_remove_returns_false_falls_back_to_rebuild() { var sink = new RecordingSink { RemoveReturns = false }; 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), new EquipmentTagPlan("tag-2", "eq-1", "drv", FolderPath: "", Name: "B", DataType: "Float", FullName: "2", Writable: false, Alarm: null), }, }; var outcome = applier.Apply(plan); outcome.RebuildCalled.ShouldBeTrue(); sink.RebuildCalls.ShouldBe(1); // Ratchet: stopped at the FIRST failed remove — did not attempt the second. sink.RemoveCalls.Count.ShouldBe(1); } /// R2-07 T11 — a PureRemove on a sink lacking the surgical capability falls back to a full /// rebuild (safe default), same as the F10b attribute path. [Fact] public void Pure_remove_on_non_surgical_sink_rebuilds() { 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 outcome = applier.Apply(plan); outcome.RebuildCalled.ShouldBeTrue(); sink.RebuildCalls.ShouldBe(1); } /// 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.) [Fact] public void Add_and_remove_is_mixed_removes_in_place_without_rebuild() { var sink = new RecordingSink(); 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), }, RemovedEquipmentTags = new[] { new EquipmentTagPlan("tag-old", "eq-1", "drv", FolderPath: "", Name: "Old", DataType: "Float", FullName: "40001", Writable: false, Alarm: null), }, }; var outcome = applier.Apply(plan); 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"))); 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)); } /// 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() { var sink = new RecordingSink { RemoveReturns = 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), }, RemovedEquipmentTags = new[] { new EquipmentTagPlan("tag-old", "eq-1", "drv", FolderPath: "", Name: "Old", DataType: "Float", FullName: "40001", Writable: false, Alarm: null), }, }; var outcome = applier.Apply(plan); outcome.RebuildCalled.ShouldBeTrue(); 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.) [Fact] public void Changed_equipment_tags_only_trigger_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: "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 outcome = applier.Apply(plan); outcome.RebuildCalled.ShouldBeTrue(); outcome.ChangedNodes.ShouldBe(1); sink.RebuildCalls.ShouldBe(1); } /// F10b (backlog #11) — a deploy that ONLY edits an existing VirtualTag's Expression /// must SKIP the address-space rebuild. The vtag's materialised node is derived only from /// {EquipmentId, FolderPath, Name, DataType}, so an Expression-only edit leaves a byte-identical node; /// the vtag engine adopts the new expression via VirtualTagHostActor's independent respawn, not /// the address-space path. Skipping the rebuild preserves every client's server-wide subscriptions. /// The edit STILL counts as a change (ChangedNodes == 1) — it just no longer forces a rebuild. /// (Supersedes the former H1a "expression edit ⇒ rebuild" behavior.) [Fact] public void Changed_virtual_tag_expression_only_skips_rebuild() { var sink = new RecordingSink(); var applier = new AddressSpaceApplier(sink, NullLogger.Instance); var previous = CompositionWithVirtualTags( new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "Efficiency", DataType: "Float64", Expression: "ctx.GetTag(\"a\") * 60", DependencyRefs: new[] { "a" })); // Same VirtualTag id, edited expression — the planner classifies this as a change. var next = CompositionWithVirtualTags( new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "Efficiency", DataType: "Float64", Expression: "ctx.GetTag(\"a\") * 120", DependencyRefs: new[] { "a" })); var plan = AddressSpacePlanner.Compute(previous, next); // Guard the arrange: ONLY ChangedEquipmentVirtualTags is populated. plan.ChangedEquipmentVirtualTags.Count.ShouldBe(1); plan.AddedEquipmentVirtualTags.ShouldBeEmpty(); plan.RemovedEquipmentVirtualTags.ShouldBeEmpty(); var outcome = applier.Apply(plan); outcome.RebuildCalled.ShouldBeFalse(); sink.RebuildCalls.ShouldBe(0); // NO RebuildAddressSpace — subscriptions preserved outcome.ChangedNodes.ShouldBe(1); // the edit is still tallied as a change } /// F10b — a vtag delta differing ONLY in DependencyRefs (e.g. a new ctx.GetTag /// literal in the script) is node-irrelevant and SKIPS the rebuild; the engine respawn picks up the /// new dependency set. [Fact] public void Changed_virtual_tag_dependency_refs_only_skips_rebuild() { var sink = new RecordingSink(); var applier = new AddressSpaceApplier(sink, NullLogger.Instance); var previous = CompositionWithVirtualTags( new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "Efficiency", DataType: "Float64", Expression: "ctx.GetTag(\"a\")", DependencyRefs: new[] { "a" })); // Same node-relevant fields; only the dependency set differs. // Note: DependencyRefs normally tracks the Expression (derived by the ref extractor), so a // deps-only divergence is an edge case — e.g. the extractor logic changed between two deploys — // but it is still correctly node-irrelevant: the materialised OPC UA node is unchanged. var next = CompositionWithVirtualTags( new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "Efficiency", DataType: "Float64", Expression: "ctx.GetTag(\"a\")", DependencyRefs: new[] { "a", "b" })); var plan = AddressSpacePlanner.Compute(previous, next); plan.ChangedEquipmentVirtualTags.Count.ShouldBe(1); var outcome = applier.Apply(plan); outcome.RebuildCalled.ShouldBeFalse(); sink.RebuildCalls.ShouldBe(0); outcome.ChangedNodes.ShouldBe(1); } /// F10b — a vtag delta differing ONLY in Historize (write-side flag, not materialised /// on the node) is node-irrelevant and SKIPS the rebuild. [Fact] public void Changed_virtual_tag_historize_only_skips_rebuild() { var sink = new RecordingSink(); var applier = new AddressSpaceApplier(sink, NullLogger.Instance); var previous = CompositionWithVirtualTags( new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "Efficiency", DataType: "Float64", Expression: "ctx.GetTag(\"a\")", DependencyRefs: new[] { "a" }, Historize: false)); // Same node-relevant fields; only the Historize flag flips. var next = CompositionWithVirtualTags( new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "Efficiency", DataType: "Float64", Expression: "ctx.GetTag(\"a\")", DependencyRefs: new[] { "a" }, Historize: true)); var plan = AddressSpacePlanner.Compute(previous, next); plan.ChangedEquipmentVirtualTags.Count.ShouldBe(1); var outcome = applier.Apply(plan); outcome.RebuildCalled.ShouldBeFalse(); sink.RebuildCalls.ShouldBe(0); outcome.ChangedNodes.ShouldBe(1); } /// F10b safe-default — a vtag delta where the node-affecting DataType ALSO changed is /// NOT node-irrelevant: the materialised node would differ, so the applier must still rebuild. Pins /// the whitelist (Expression/DependencyRefs/Historize) against accidentally swallowing a DataType /// edit. [Fact] public void Changed_virtual_tag_data_type_change_still_rebuilds() { var sink = new RecordingSink(); var applier = new AddressSpaceApplier(sink, NullLogger.Instance); var previous = CompositionWithVirtualTags( new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "Efficiency", DataType: "Float64", Expression: "ctx.GetTag(\"a\") * 120", DependencyRefs: new[] { "a" })); // DataType flips AND the expression changes — DataType is node-affecting, so this must rebuild. var next = CompositionWithVirtualTags( new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "Efficiency", DataType: "Int32", Expression: "ctx.GetTag(\"a\") * 60", DependencyRefs: new[] { "a" })); var plan = AddressSpacePlanner.Compute(previous, next); plan.ChangedEquipmentVirtualTags.Count.ShouldBe(1); var outcome = applier.Apply(plan); outcome.RebuildCalled.ShouldBeTrue(); sink.RebuildCalls.ShouldBe(1); } /// F10b safe-default — a vtag delta where the node-affecting Name changed must /// rebuild (the node's BrowseName/NodeId derive from Name). [Fact] public void Changed_virtual_tag_name_change_still_rebuilds() { var sink = new RecordingSink(); var applier = new AddressSpaceApplier(sink, NullLogger.Instance); var previous = CompositionWithVirtualTags( new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "Efficiency", DataType: "Float64", Expression: "ctx.GetTag(\"a\")", DependencyRefs: new[] { "a" })); var next = CompositionWithVirtualTags( new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "EfficiencyPct", DataType: "Float64", Expression: "ctx.GetTag(\"a\")", DependencyRefs: new[] { "a" })); var plan = AddressSpacePlanner.Compute(previous, next); plan.ChangedEquipmentVirtualTags.Count.ShouldBe(1); var outcome = applier.Apply(plan); outcome.RebuildCalled.ShouldBeTrue(); sink.RebuildCalls.ShouldBe(1); } /// F10b safe-default — a vtag delta where the node-affecting FolderPath changed must /// rebuild (the folder-scoped NodeId derives from FolderPath). [Fact] public void Changed_virtual_tag_folder_path_change_still_rebuilds() { var sink = new RecordingSink(); var applier = new AddressSpaceApplier(sink, NullLogger.Instance); var previous = CompositionWithVirtualTags( new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "Efficiency", DataType: "Float64", Expression: "ctx.GetTag(\"a\")", DependencyRefs: new[] { "a" })); var next = CompositionWithVirtualTags( new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "Calc", Name: "Efficiency", DataType: "Float64", Expression: "ctx.GetTag(\"a\")", DependencyRefs: new[] { "a" })); var plan = AddressSpacePlanner.Compute(previous, next); plan.ChangedEquipmentVirtualTags.Count.ShouldBe(1); var outcome = applier.Apply(plan); outcome.RebuildCalled.ShouldBeTrue(); sink.RebuildCalls.ShouldBe(1); } /// F10b — the skip is ONLY for a node-irrelevant vtag edit that is the SOLE change. A /// node-irrelevant Expression-only vtag edit MIXED with another NON-surgical change (here a changed /// equipment tag whose driver-side FullName re-routes) must still rebuild — the rebuild is forced /// by the OTHER change, and the running server gets its single rebuild as before. [Fact] public void Node_irrelevant_vtag_edit_mixed_with_another_change_still_rebuilds() { 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), }, 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). 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: "40002", Writable: false, Alarm: null), }, EquipmentVirtualTags = new[] { new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "Efficiency", DataType: "Float64", Expression: "ctx.GetTag(\"a\") * 120", DependencyRefs: new[] { "a" }), }, }; var plan = AddressSpacePlanner.Compute(previous, next); // Both a node-irrelevant vtag change AND a node-affecting tag change are present. plan.ChangedEquipmentVirtualTags.Count.ShouldBe(1); plan.ChangedEquipmentTags.Count.ShouldBe(1); var outcome = applier.Apply(plan); outcome.RebuildCalled.ShouldBeTrue(); sink.RebuildCalls.ShouldBe(1); } /// F10b safe-default — a vtag delta where the node-affecting EquipmentId changed /// (admin reassigns the vtag to a different equipment) must rebuild. The materialised node lives /// under the old equipment folder; moving it to another folder requires a full address-space /// rebuild. EquipmentId is NOT in the node-irrelevant whitelist (Expression/DependencyRefs/Historize), /// so the applier must not skip. [Fact] public void Changed_virtual_tag_equipment_id_triggers_rebuild() { var sink = new RecordingSink(); var applier = new AddressSpaceApplier(sink, NullLogger.Instance); var previous = CompositionWithVirtualTags( new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "Efficiency", DataType: "Float64", Expression: "ctx.GetTag(\"a\")", DependencyRefs: new[] { "a" })); // Same VirtualTagId, identical Expression/DependencyRefs/DataType/Name/FolderPath — // but reassigned to a different equipment ("eq-2" instead of "eq-1"). var next = CompositionWithVirtualTags( new EquipmentVirtualTagPlan("vt-1", "eq-2", FolderPath: "", Name: "Efficiency", DataType: "Float64", Expression: "ctx.GetTag(\"a\")", DependencyRefs: new[] { "a" })); var plan = AddressSpacePlanner.Compute(previous, next); // Guard the arrange: the planner sees this as a changed vtag (same id, different EquipmentId). plan.ChangedEquipmentVirtualTags.Count.ShouldBe(1); plan.AddedEquipmentVirtualTags.ShouldBeEmpty(); plan.RemovedEquipmentVirtualTags.ShouldBeEmpty(); var outcome = applier.Apply(plan); // EquipmentId is node-affecting — the node moves to a different folder — so rebuild is required. outcome.RebuildCalled.ShouldBeTrue(); sink.RebuildCalls.ShouldBe(1); outcome.ChangedNodes.ShouldBe(1); } /// F10b safe-default — when a plan contains TWO changed vtags and ONE is node-irrelevant /// (Expression-only) while the OTHER is structural (DataType changed), the applier must still rebuild. /// The Any(d => !VtagDeltaIsNodeIrrelevant(d)) predicate must catch the structural delta /// even though its sibling vtag would individually qualify for the skip. [Fact] public void Changed_virtual_tags_one_irrelevant_one_structural_triggers_rebuild() { var sink = new RecordingSink(); var applier = new AddressSpaceApplier(sink, NullLogger.Instance); var previous = new AddressSpaceComposition( Array.Empty(), Array.Empty(), Array.Empty()) { EquipmentVirtualTags = new[] { // vt-A: will receive an Expression-only edit (node-irrelevant). new EquipmentVirtualTagPlan("vt-a", "eq-1", FolderPath: "", Name: "SpeedRpm", DataType: "Float64", Expression: "ctx.GetTag(\"speed\") * 60", DependencyRefs: new[] { "speed" }), // vt-B: will receive a DataType change (structural / node-affecting). new EquipmentVirtualTagPlan("vt-b", "eq-1", FolderPath: "", Name: "LoadPct", DataType: "Float64", Expression: "ctx.GetTag(\"load\")", DependencyRefs: new[] { "load" }), }, }; var next = new AddressSpaceComposition( Array.Empty(), Array.Empty(), Array.Empty()) { EquipmentVirtualTags = new[] { // vt-A: Expression-only change — individually node-irrelevant. new EquipmentVirtualTagPlan("vt-a", "eq-1", FolderPath: "", Name: "SpeedRpm", DataType: "Float64", Expression: "ctx.GetTag(\"speed\") * 120", DependencyRefs: new[] { "speed" }), // vt-B: DataType flipped — structurally node-affecting. new EquipmentVirtualTagPlan("vt-b", "eq-1", FolderPath: "", Name: "LoadPct", DataType: "Int32", Expression: "ctx.GetTag(\"load\")", DependencyRefs: new[] { "load" }), }, }; var plan = AddressSpacePlanner.Compute(previous, next); // Guard the arrange: both vtags are diffed as changed, nothing else. plan.ChangedEquipmentVirtualTags.Count.ShouldBe(2); plan.AddedEquipmentVirtualTags.ShouldBeEmpty(); plan.RemovedEquipmentVirtualTags.ShouldBeEmpty(); plan.ChangedEquipmentTags.ShouldBeEmpty(); plan.ChangedEquipment.ShouldBeEmpty(); var outcome = applier.Apply(plan); // The structural delta on vt-B must force a rebuild even though vt-A is node-irrelevant. outcome.RebuildCalled.ShouldBeTrue(); sink.RebuildCalls.ShouldBe(1); outcome.ChangedNodes.ShouldBe(2); } /// H1a — a deploy that ONLY edits an existing scripted alarm (here its message template) /// must rebuild the address space. The planner diffs the thin ScriptedAlarmPlan projection /// into ChangedAlarms alone; the applier must drive exactly one rebuild so the condition node /// reflects the edit. [Fact] public void Changed_alarms_only_trigger_rebuild() { var sink = new RecordingSink(); var applier = new AddressSpaceApplier(sink, NullLogger.Instance); var previous = CompositionWithAlarms(new ScriptedAlarmPlan("alm-1", "eq-1", "scr-1", "Temp high")); // Same alarm id, edited message template — the planner classifies this as a change. var next = CompositionWithAlarms(new ScriptedAlarmPlan("alm-1", "eq-1", "scr-1", "Temp critically high")); var plan = AddressSpacePlanner.Compute(previous, next); // Guard the arrange: ONLY ChangedAlarms is populated. plan.ChangedAlarms.Count.ShouldBe(1); plan.AddedAlarms.ShouldBeEmpty(); plan.RemovedAlarms.ShouldBeEmpty(); var outcome = applier.Apply(plan); outcome.RebuildCalled.ShouldBeTrue(); outcome.ChangedNodes.ShouldBe(1); sink.RebuildCalls.ShouldBe(1); } /// H1a guard — a deploy that ONLY changes a driver instance's config must NOT rebuild the /// address space. Driver-instance changes route through DriverHostActor's spawn-plan in Runtime, not /// the address-space topology, so ChangedDrivers is intentionally excluded from /// needsRebuild. This pins the exclusion against accidental inclusion. (The pre-existing /// uses a hand-built plan; /// this one drives the planner end-to-end.) [Fact] public void Changed_drivers_only_do_not_trigger_rebuild() { var sink = new RecordingSink(); var applier = new AddressSpaceApplier(sink, NullLogger.Instance); var previous = CompositionWithDrivers(new DriverInstancePlan("d-1", "Modbus", "{\"v\":1}")); var next = CompositionWithDrivers(new DriverInstancePlan("d-1", "Modbus", "{\"v\":2}")); var plan = AddressSpacePlanner.Compute(previous, next); // Guard the arrange: ONLY ChangedDrivers is populated. plan.ChangedDrivers.Count.ShouldBe(1); plan.AddedDrivers.ShouldBeEmpty(); plan.RemovedDrivers.ShouldBeEmpty(); plan.ChangedEquipment.ShouldBeEmpty(); plan.ChangedAlarms.ShouldBeEmpty(); plan.ChangedEquipmentTags.ShouldBeEmpty(); plan.ChangedEquipmentVirtualTags.ShouldBeEmpty(); var outcome = applier.Apply(plan); outcome.RebuildCalled.ShouldBeFalse(); outcome.ChangedNodes.ShouldBe(1); // driver change is still tallied, just not rebuild-forcing sink.RebuildCalls.ShouldBe(0); } /// H1a (review follow-up) — a deploy that ONLY removes existing equipment tag / VirtualTag /// nodes must rebuild AND tally the removals. Removed tags/VirtualTags are plain variable nodes (no /// Part 9 condition to write), so before the fix they reached the rebuild path but were never added /// to removedCountAddressSpaceApplyOutcome.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() { 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), }, EquipmentVirtualTags = new[] { new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "Efficiency", DataType: "Float64", Expression: "ctx.GetTag(\"a\")", DependencyRefs: new[] { "a" }), }, }; var next = new AddressSpaceComposition( Array.Empty(), Array.Empty(), Array.Empty()); var plan = AddressSpacePlanner.Compute(previous, next); // Guard the arrange: ONLY the two Removed sets are populated. plan.RemovedEquipmentTags.Count.ShouldBe(1); 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. 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"))); sink.RemoveCalls.ShouldContain(("var", V3NodeIds.Uns("eq-1", "Efficiency"))); } // ----- 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 /// apply the new DisplayName IN PLACE via ISurgicalAddressSpaceSink.UpdateFolderDisplayName, /// preserving every client's subscriptions. Exactly one surgical folder call lands with the area's /// NodeId (== UnsAreaId) and the NEW display name; the rename still counts as a change. [Fact] public void Area_rename_only_skips_rebuild_and_updates_folder_in_place() { var sink = new RecordingSink(); var applier = new AddressSpaceApplier(sink, NullLogger.Instance); var previous = CompositionWithAreas(new UnsAreaProjection("area-1", "Plant North")); var next = CompositionWithAreas(new UnsAreaProjection("area-1", "Plant South")); var plan = AddressSpacePlanner.Compute(previous, next); plan.RenamedFolders.Count.ShouldBe(1); var outcome = applier.Apply(plan); outcome.RebuildCalled.ShouldBeFalse(); sink.RebuildCalls.ShouldBe(0); // NO RebuildAddressSpace — subscriptions preserved var call = sink.FolderRenameCalls.ShouldHaveSingleItem(); call.FolderNodeId.ShouldBe("area-1"); // folder NodeId == UnsAreaId (MaterialiseHierarchy scheme) call.DisplayName.ShouldBe("Plant South"); // the NEW display name outcome.ChangedNodes.ShouldBe(1); } /// OpcUaServer-001 — a Line rename only updates the line folder in place (NodeId == UnsLineId) /// and skips the rebuild. [Fact] public void Line_rename_only_skips_rebuild_and_updates_folder_in_place() { var sink = new RecordingSink(); var applier = new AddressSpaceApplier(sink, NullLogger.Instance); var previous = CompositionWithLines(new UnsLineProjection("line-1", "area-1", "Cell A")); var next = CompositionWithLines(new UnsLineProjection("line-1", "area-1", "Cell B")); var plan = AddressSpacePlanner.Compute(previous, next); plan.RenamedFolders.Count.ShouldBe(1); var outcome = applier.Apply(plan); outcome.RebuildCalled.ShouldBeFalse(); sink.RebuildCalls.ShouldBe(0); var call = sink.FolderRenameCalls.ShouldHaveSingleItem(); call.FolderNodeId.ShouldBe("line-1"); call.DisplayName.ShouldBe("Cell B"); } /// R2-07 T2 — a folder rename MIXED with an added equipment is now a PureAdd: the added /// equipment is created by the Materialise passes and the rename is applied IN PLACE via the surgical /// sink, so NO rebuild fires and exactly one folder-rename call lands. (Supersedes the pre-R2-07 /// "rename-mixed-with-add ⇒ rebuild" pin.) [Fact] public void Folder_rename_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( UnsAreas: new[] { new UnsAreaProjection("area-1", "North") }, UnsLines: Array.Empty(), EquipmentNodes: Array.Empty(), DriverInstancePlans: Array.Empty(), ScriptedAlarmPlans: Array.Empty()); // Area renamed AND a brand-new equipment node — an add no longer forces a rebuild; the rename // rides the in-place surgical path alongside the PureAdd. var next = new AddressSpaceComposition( UnsAreas: new[] { new UnsAreaProjection("area-1", "South") }, UnsLines: Array.Empty(), EquipmentNodes: new[] { new EquipmentNode("eq-new", "New", "line-1") }, DriverInstancePlans: Array.Empty(), ScriptedAlarmPlans: Array.Empty()); var plan = AddressSpacePlanner.Compute(previous, next); plan.RenamedFolders.Count.ShouldBe(1); plan.AddedEquipment.Count.ShouldBe(1); var outcome = applier.Apply(plan); outcome.RebuildCalled.ShouldBeFalse(); sink.RebuildCalls.ShouldBe(0); sink.FolderRenameCalls.ShouldHaveSingleItem().ShouldBe(("area-1", "South")); } /// OpcUaServer-001 fallback — a rename-only plan on a sink that does NOT implement /// cannot apply the in-place update, so it drives a full /// rebuild (safe default). [Fact] public void Folder_rename_on_non_surgical_sink_rebuilds() { var sink = new PlainRecordingSink(); var applier = new AddressSpaceApplier(sink, NullLogger.Instance); var previous = CompositionWithAreas(new UnsAreaProjection("area-1", "North")); var next = CompositionWithAreas(new UnsAreaProjection("area-1", "South")); var plan = AddressSpacePlanner.Compute(previous, next); plan.RenamedFolders.Count.ShouldBe(1); var outcome = applier.Apply(plan); outcome.RebuildCalled.ShouldBeTrue(); sink.RebuildCalls.ShouldBe(1); } /// OpcUaServer-001 fallback — when the surgical sink reports the folder MISSING /// (UpdateFolderDisplayName 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 Folder_rename_surgical_returning_false_falls_back_to_rebuild() { var sink = new RecordingSink { FolderRenameReturns = false }; var applier = new AddressSpaceApplier(sink, NullLogger.Instance); var previous = CompositionWithAreas(new UnsAreaProjection("area-1", "North")); var next = CompositionWithAreas(new UnsAreaProjection("area-1", "South")); var plan = AddressSpacePlanner.Compute(previous, next); plan.RenamedFolders.Count.ShouldBe(1); var outcome = applier.Apply(plan); outcome.RebuildCalled.ShouldBeTrue(); sink.RebuildCalls.ShouldBe(1); // fell back to a full rebuild sink.FolderRenameCalls.ShouldHaveSingleItem(); // the surgical update was attempted first } // ----- 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() { var applier = new AddressSpaceApplier(new RecordingSink(), NullLogger.Instance); var plan = EmptyPlan with { AddedAlarms = new[] { new ScriptedAlarmPlan("alm-1", "eq-9", "scr", "msg") }, }; applier.ComputeAddAnnouncements(plan).ShouldBe(new[] { "eq-9" }); } /// An added equipment WITH a UnsLineId announces its parent line; WITHOUT one announces its own /// new folder id (a valid Part 3 NodeAdded of the folder itself; the root has no announceable id). [Fact] public void ComputeAddAnnouncements_added_equipment_announces_line_or_self() { var applier = new AddressSpaceApplier(new RecordingSink(), NullLogger.Instance); var withLine = EmptyPlan with { AddedEquipment = new[] { new EquipmentNode("eq-1", "One", "line-7") } }; applier.ComputeAddAnnouncements(withLine).ShouldBe(new[] { "line-7" }); var noLine = EmptyPlan with { AddedEquipment = new[] { new EquipmentNode("eq-2", "Two", "") } }; applier.ComputeAddAnnouncements(noLine).ShouldBe(new[] { "eq-2" }); } /// A plan with no additions yields no announcements. [Fact] public void ComputeAddAnnouncements_empty_add_plan_yields_none() { var applier = new AddressSpaceApplier(new RecordingSink(), NullLogger.Instance); applier.ComputeAddAnnouncements(EmptyPlan).ShouldBeEmpty(); } /// AnnounceAddedNodes raises exactly one RaiseNodesAddedModelChange per DISTINCT affected /// parent (dedup + subfolder-parent + added-equipment cases), Safe-wrapped. [Fact] public void AnnounceAddedNodes_raises_one_model_change_per_distinct_parent() { var sink = new RecordingSink(); var applier = new AddressSpaceApplier(sink, NullLogger.Instance); var plan = EmptyPlan with { AddedEquipment = new[] { new EquipmentNode("eq-2", "Two", "line-7") }, 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), new EquipmentTagPlan("t3", "eq-1", "drv", FolderPath: "Diag", Name: "C", DataType: "Float", FullName: "3", Writable: false, Alarm: null), }, }; applier.AnnounceAddedNodes(plan); sink.ModelChangeCalls.OrderBy(x => x).ShouldBe( new[] { "eq-1", V3NodeIds.Uns("eq-1", "Diag"), "line-7" }.OrderBy(x => x)); } private static AddressSpaceComposition CompositionWithAreas(params UnsAreaProjection[] areas) => new( areas, Array.Empty(), Array.Empty(), Array.Empty(), Array.Empty()); private static AddressSpaceComposition CompositionWithLines(params UnsLineProjection[] lines) => new( 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()) { EquipmentVirtualTags = vtags, }; private static AddressSpaceComposition CompositionWithAlarms(params ScriptedAlarmPlan[] alarms) => // ScriptedAlarmPlans is the set the planner diffs into Added/Removed/ChangedAlarms. new( Array.Empty(), Array.Empty(), alarms); private static AddressSpaceComposition CompositionWithDrivers(params DriverInstancePlan[] drivers) => new( Array.Empty(), drivers, Array.Empty()); private static AddressSpacePlan EmptyPlan => new( Array.Empty(), Array.Empty(), Array.Empty(), Array.Empty(), Array.Empty(), Array.Empty(), Array.Empty(), Array.Empty(), Array.Empty()); private static AddressSpacePlan WithEquipmentRemoval(params string[] ids) => new( AddedEquipment: Array.Empty(), RemovedEquipment: ids.Select(id => new EquipmentNode(id, id, "line-1")).ToArray(), ChangedEquipment: Array.Empty(), AddedDrivers: Array.Empty(), RemovedDrivers: Array.Empty(), ChangedDrivers: Array.Empty(), AddedAlarms: Array.Empty(), RemovedAlarms: Array.Empty(), ChangedAlarms: Array.Empty()); 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(); /// 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(); /// 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). /// The new OPC UA data type name to apply in place. /// The new array-ness of the node. /// 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)); return SurgicalReturns; } /// Gets the queue of surgical in-place folder display-name update calls (OpcUaServer-001). public ConcurrentQueue<(string FolderNodeId, string DisplayName)> FolderRenameQueue { get; } = new(); /// Gets the list of recorded surgical folder display-name update calls. public List<(string FolderNodeId, string DisplayName)> FolderRenameCalls => FolderRenameQueue.ToList(); /// When false, reports the folder missing (returns /// false), driving the applier's rebuild fallback. Defaults to true. public bool FolderRenameReturns { get; init; } = true; /// Records a surgical in-place folder display-name update; returns . /// The folder node ID to update in place. /// The new display name to apply. public bool UpdateFolderDisplayName(string folderNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { FolderRenameQueue.Enqueue((folderNodeId, displayName)); return FolderRenameReturns; } /// 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(); /// Gets the list of recorded surgical remove calls. public List<(string Kind, string NodeId)> 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; /// Records a surgical variable-node removal; returns . public bool RemoveVariableNode(string variableNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { RemoveQueue.Enqueue(("var", variableNodeId)); return RemoveReturns; } /// Records a surgical alarm-condition-node removal; returns . public bool RemoveAlarmConditionNode(string alarmNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { RemoveQueue.Enqueue(("alarm", alarmNodeId)); return RemoveReturns; } /// Records a surgical equipment-subtree removal; returns . public bool RemoveEquipmentSubtree(string equipmentNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { RemoveQueue.Enqueue(("equipment", equipmentNodeId)); return RemoveReturns; } /// Gets the queue of alarm condition write calls. public ConcurrentQueue<(string NodeId, AlarmConditionSnapshot State)> AlarmQueue { get; } = new(); /// Gets the queue of folder creation calls. public ConcurrentQueue<(string NodeId, string? Parent, string DisplayName)> FolderQueue { get; } = new(); /// Gets the queue of variable creation calls. public ConcurrentQueue<(string NodeId, string? Parent, string DisplayName, string DataType, bool Writable)> VariableQueue { get; } = new(); /// Gets the queue of the historian-tagname arg captured per EnsureVariable call, /// keyed by NodeId (null ⇒ that call passed not-historized). public ConcurrentQueue<(string NodeId, string? HistorianTagname)> HistorianQueue { get; } = new(); /// Gets the queue of the isArray/arrayLength args captured per EnsureVariable /// call, keyed by NodeId. public ConcurrentQueue<(string NodeId, bool IsArray, uint? ArrayLength)> ArrayQueue { get; } = new(); /// Gets the queue of alarm-condition materialise calls. public ConcurrentQueue<(string AlarmNodeId, string EquipmentNodeId, string DisplayName, string AlarmType, int Severity, bool IsNative)> AlarmConditionQueue { get; } = new(); /// Gets the number of rebuild calls made on this sink. public int RebuildCalls; /// Gets the list of recorded alarm writes. public List<(string NodeId, AlarmConditionSnapshot State)> AlarmWrites => AlarmQueue.ToList(); /// Gets the list of recorded folder creation calls. public List<(string NodeId, string? Parent, string DisplayName)> FolderCalls => FolderQueue.ToList(); /// Gets the list of recorded variable creation calls. public List<(string NodeId, string? Parent, string DisplayName, string DataType, bool Writable)> VariableCalls => VariableQueue.ToList(); /// Gets the list of recorded (NodeId, historian-tagname) pairs captured per EnsureVariable call. public List<(string NodeId, string? HistorianTagname)> HistorianCalls => HistorianQueue.ToList(); /// Gets the list of recorded (NodeId, isArray, arrayLength) triples captured per EnsureVariable call. public List<(string NodeId, bool IsArray, uint? ArrayLength)> ArrayCalls => ArrayQueue.ToList(); /// Gets the list of recorded alarm-condition materialise calls. 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, OpcUaQuality Quality)> ValueWriteQueue { get; } = new(); /// Gets the list of recorded value writes. public List<(string NodeId, OpcUaQuality Quality)> ValueWrites => ValueWriteQueue.ToList(); /// Records a value write (NodeId + quality). /// The node ID. /// The value to write. /// The OPC UA quality. /// The source timestamp in UTC. public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) => ValueWriteQueue.Enqueue((nodeId, quality)); /// Records an alarm condition write call. /// The alarm node ID. /// The full condition state snapshot. /// The source timestamp in UTC. public void WriteAlarmQuality(string alarmNodeId, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { } public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) => AlarmQueue.Enqueue((alarmNodeId, state)); /// Records an alarm-condition materialise call. /// The alarm node ID (== ScriptedAlarmId). /// The equipment folder node ID. /// The condition display name. /// The domain alarm type. /// The domain severity. public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) => AlarmConditionQueue.Enqueue((alarmNodeId, equipmentNodeId, displayName, alarmType, severity, isNative)); /// Records a folder creation call. /// The folder node ID. /// The parent folder node ID, if any. /// The display name for the folder. public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) => FolderQueue.Enqueue((folderNodeId, parentNodeId, displayName)); /// Records a variable creation call. /// The variable node ID. /// The parent folder node ID, if any. /// The display name for the variable. /// The OPC UA built-in type name. /// Whether the node is created read/write. /// The resolved historian tagname (null ⇒ not historized). public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) { VariableQueue.Enqueue((variableNodeId, parentFolderNodeId, displayName, dataType, writable)); HistorianQueue.Enqueue((variableNodeId, historianTagname)); ArrayQueue.Enqueue((variableNodeId, isArray, arrayLength)); } /// Records a rebuild address space call. public void RebuildAddressSpace() => Interlocked.Increment(ref RebuildCalls); /// Gets the queue of NodeAdded model-change announcements (discovered-node injection). public ConcurrentQueue ModelChangeQueue { get; } = new(); /// Gets the list of recorded NodeAdded model-change announcements (discovered-node injection). public List ModelChangeCalls => ModelChangeQueue.ToList(); /// Records a NodeAdded model-change announcement. /// The node under which discovered nodes were added. public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) => ModelChangeQueue.Enqueue(affectedNodeId); public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { } public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") { } } /// 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, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } /// No-op alarm condition write call. public void WriteAlarmQuality(string alarmNodeId, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { } public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } /// No-op alarm-condition materialise call. public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { } /// No-op folder creation call. public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } /// No-op variable creation call. public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) { } /// Records a rebuild address space call. public void RebuildAddressSpace() => Interlocked.Increment(ref RebuildCalls); /// No-op NodeAdded model-change announcement. public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { } public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") { } } private sealed class ThrowingSink : IOpcUaAddressSpaceSink { private readonly bool _throwOnAlarmWrite; /// Initializes a new instance of the ThrowingSink class. /// Whether to throw on alarm state writes. public ThrowingSink(bool throwOnAlarmWrite) { _throwOnAlarmWrite = throwOnAlarmWrite; } /// Records a value write (no-op in this sink). /// The node ID. /// The value to write. /// The OPC UA quality. /// The source timestamp in UTC. public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } /// Throws an exception if configured to do so. /// The alarm node ID. /// The full condition state snapshot. /// The source timestamp in UTC. /// Thrown when configured to throw on alarm write. public void WriteAlarmQuality(string alarmNodeId, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { } public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { if (_throwOnAlarmWrite) throw new InvalidOperationException("simulated sink fault"); } /// No-op alarm-condition materialise call. /// The alarm node ID. /// The equipment folder node ID. /// The condition display name. /// The domain alarm type. /// The domain severity. public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { } /// No-op folder creation call. /// The folder node ID. /// The parent folder node ID, if any. /// The display name for the folder. public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } /// No-op variable creation call. /// The variable node ID. /// The parent folder node ID, if any. /// The display name for the variable. /// The OPC UA built-in type name. /// Whether the node is created read/write. /// The resolved historian tagname (null ⇒ not historized). public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) { } /// No-op rebuild address space call. public void RebuildAddressSpace() { } /// No-op NodeAdded model-change announcement. public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { } public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") { } } }