diff --git a/archreview/plans/R2-07-surgical-pure-adds-plan.md.tasks.json b/archreview/plans/R2-07-surgical-pure-adds-plan.md.tasks.json index 56c629d6..2499e8b4 100644 --- a/archreview/plans/R2-07-surgical-pure-adds-plan.md.tasks.json +++ b/archreview/plans/R2-07-surgical-pure-adds-plan.md.tasks.json @@ -11,7 +11,7 @@ { "id": "T2", "subject": "Phase 1: route AddressSpaceApplier.Apply through the classifier \u2014 PureAdd skips rebuild (high-risk)", - "status": "pending", + "status": "completed", "blockedBy": [ "T1" ] diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs index 6a8345b8..2ab88bf3 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs @@ -7,22 +7,31 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer; /// /// Side-effecting orchestrator over . Drives an /// to materialise the diff between two -/// snapshots: +/// snapshots, routing each delta class to its MINIMAL mutation +/// (R2-07 03/P1) via instead of rebuilding the whole +/// address space for any topology change (which would kill every client's server-wide subscriptions): /// /// -/// RemovedEquipment / RemovedAlarms — write Bad-quality on every removed -/// node id then call RebuildAddressSpace at the end so the sink can -/// actually tear down the OPC UA folders + variables. -/// AddedEquipment / AddedAlarms — same Rebuild trigger (real SDK NodeManager -/// will repopulate from the persisted artifact). For now we record the work. -/// ChangedEquipment / ChangedAlarms — record what changed; the SDK adapter -/// that lands in F10b will decide between in-place property writes and -/// tear-down + rebuild. +/// PureAdd — no rebuild. OpcUaPublishActor's idempotent Materialise passes +/// create exactly the added folders/variables/conditions (no-op'ing existing nodes), then +/// AnnounceAddedNodes raises a Part 3 NodeAdded per affected parent so +/// model-aware clients re-browse. Existing MonitoredItems are untouched. +/// AttributeOnly — no rebuild. Surgical-eligible changed tags + UNS folder renames +/// are applied IN PLACE via (F10b). Any false +/// or throw from a surgical call falls back to a full rebuild (safe default). +/// PureRemove — scoped in-place teardown of only the removed subtree (writes a +/// terminal Bad / RemovedConditionState first so in-flight MonitoredItems observe the +/// removal). Maps to a full rebuild until R2-07 Phase 2 ships. +/// AddRemoveMix — removes-then-adds within one apply. Maps to a full rebuild until +/// R2-07 Phase 3 ships. +/// Rebuild — the default-closed safety valve for any node-affecting change +/// (ChangedEquipment / ChangedAlarms / non-surgical ChangedEquipmentTags / node-relevant +/// ChangedEquipmentVirtualTags), so the worst outcome of any misclassification is today's +/// full-rebuild behaviour. /// /// -/// This is the side-effecting layer deferred to F14. It stays pure-of-SDK so -/// production binds a real SDK sink, dev/Mac binds , -/// and tests can capture every call. +/// Stays pure-of-SDK so production binds a real SDK sink, dev/Mac binds +/// , and tests can capture every call. /// public sealed class AddressSpaceApplier { @@ -111,36 +120,26 @@ public sealed class AddressSpaceApplier plan.AddedEquipmentTags.Count + plan.AddedEquipmentVirtualTags.Count; - // 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) 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 (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 / DataType / IsArray / ArrayLength (a plain value variable — no - // alarm condition node) can be updated IN PLACE on the existing node via - // ISurgicalAddressSpaceSink.UpdateTagAttributes (see TagDeltaIsSurgicalEligible), avoiding the full - // rebuild and preserving subscriptions. The shape fields (DataType/IsArray/ArrayLength) are now - // surgical because the sink swaps DataType + ValueRank + ArrayDimensions in place and raises a - // GeneralModelChangeEvent. Any other tag difference (FullName/Name/DriverInstanceId/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.Any(d => !AddressSpaceChangeClassifier.TagDeltaIsSurgicalEligible(d)) || - plan.AddedEquipmentVirtualTags.Count > 0 || plan.RemovedEquipmentVirtualTags.Count > 0 || - plan.ChangedEquipmentVirtualTags.Any(d => !AddressSpaceChangeClassifier.VtagDeltaIsNodeIrrelevant(d)); + // R2-07 03/P1 — classify the plan and route each delta class to its MINIMAL mutation instead of + // rebuilding the world for any topology change. AddressSpaceChangeClassifier is a pure policy over + // the planner's diff (the planner stays a pure diff). The routing here: + // • PureAdd / AttributeOnly ⇒ NO rebuild. The idempotent Materialise passes in + // OpcUaPublishActor.HandleRebuild create exactly the added nodes (no-op'ing existing ones), so + // every client subscription server-wide survives; coincident surgical tag deltas + folder + // renames are applied IN PLACE via ISurgicalAddressSpaceSink below (any false/throw there falls + // back to a full rebuild — the F10b contract). + // • PureRemove / AddRemoveMix ⇒ full rebuild UNTIL their phases ship (R2-07 T11 / T13); mapping + // them here keeps each phase independently shippable with today's behaviour as the floor. + // • Rebuild ⇒ the default-closed safety valve for any node-affecting change (ChangedEquipment / + // ChangedAlarms / non-surgical ChangedEquipmentTags / node-relevant ChangedEquipmentVirtualTags) + // — the classifier's rule 2, which also catches any future plan field that makes a changed + // record unequal, so the worst outcome of a misclassification is today's full rebuild. + // ChangedDrivers is node-inert (routes through DriverHostActor's spawn plan) — the classifier leaves + // a driver-only plan AttributeOnly, so it never rebuilds here. + var kind = AddressSpaceChangeClassifier.Classify(plan); + var mustRebuild = kind is AddressSpaceChangeKind.Rebuild + or AddressSpaceChangeKind.PureRemove + or AddressSpaceChangeKind.AddRemoveMix; var surgicalTagDeltas = plan.ChangedEquipmentTags.Where(AddressSpaceChangeClassifier.TagDeltaIsSurgicalEligible).ToList(); // UNS Area / Line renames are surgically applicable (in-place DisplayName swap) @@ -151,7 +150,7 @@ public sealed class AddressSpaceApplier var rebuilt = false; var rebuildFailed = false; - if (structuralRebuild) + if (mustRebuild) { rebuildFailed = !SafeRebuild(); rebuilt = true; @@ -203,8 +202,8 @@ public sealed class AddressSpaceApplier } _logger.LogInformation( - "AddressSpaceApplier: applied plan (added={Added}, removed={Removed}, changed={Changed}, surgicalTags={Surgical}, renamedFolders={Renamed}, rebuild={Rebuild})", - addedCount, removedCount, changedCount, rebuilt ? 0 : surgicalTagDeltas.Count, rebuilt ? 0 : renamedFolders.Count, rebuilt); + "AddressSpaceApplier: applied plan (kind={Kind}, added={Added}, removed={Removed}, changed={Changed}, surgicalTags={Surgical}, renamedFolders={Renamed}, rebuild={Rebuild})", + kind, addedCount, removedCount, changedCount, rebuilt ? 0 : surgicalTagDeltas.Count, rebuilt ? 0 : renamedFolders.Count, rebuilt); // After the address-space work has completed, auto-provision the historian for the added // historized tags. This is fully detached (fire-and-forget) and wrapped so it can NEVER block diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierFailureSurfaceTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierFailureSurfaceTests.cs index c4a1b75d..9582c8d5 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierFailureSurfaceTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierFailureSurfaceTests.cs @@ -23,7 +23,7 @@ public sealed class AddressSpaceApplierFailureSurfaceTests var sink = new ConfigurableThrowingSink { ThrowOnRebuild = true }; var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - var outcome = applier.Apply(AddedEquipmentPlan("new")); + var outcome = applier.Apply(RebuildingPlan("eq-1")); outcome.RebuildCalled.ShouldBeTrue(); outcome.RebuildFailed.ShouldBeTrue(); @@ -36,7 +36,7 @@ public sealed class AddressSpaceApplierFailureSurfaceTests var sink = new ConfigurableThrowingSink(); var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - var outcome = applier.Apply(AddedEquipmentPlan("new")); + var outcome = applier.Apply(RebuildingPlan("eq-1")); outcome.RebuildCalled.ShouldBeTrue(); outcome.RebuildFailed.ShouldBeFalse(); @@ -121,10 +121,18 @@ public sealed class AddressSpaceApplierFailureSurfaceTests EquipmentScriptedAlarms = alarms, }; - private static AddressSpacePlan AddedEquipmentPlan(string id) => new( - AddedEquipment: new[] { new EquipmentNode(id, id, "line-1") }, + // A rebuild-forcing plan: a node-affecting ChangedEquipment delta classifies as Rebuild (R2-07), so + // these tests exercise the SafeRebuild path (RebuildCalled/RebuildFailed) regardless of the pure-add + // routing. A pure-add plan would no longer rebuild, so the failure-surface tests must use a change. + private static AddressSpacePlan RebuildingPlan(string id) => new( + AddedEquipment: Array.Empty(), RemovedEquipment: Array.Empty(), - ChangedEquipment: Array.Empty(), + ChangedEquipment: new[] + { + new AddressSpacePlan.EquipmentDelta( + new EquipmentNode(id, id, "line-1"), + new EquipmentNode(id, id + "-renamed", "line-1")), + }, AddedDrivers: Array.Empty(), RemovedDrivers: Array.Empty(), ChangedDrivers: Array.Empty(), diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierProvisioningTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierProvisioningTests.cs index 0e753aac..f8edaa56 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierProvisioningTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierProvisioningTests.cs @@ -64,7 +64,8 @@ public sealed class AddressSpaceApplierProvisioningTests var outcome = applier.Apply(plan); - outcome.RebuildCalled.ShouldBeTrue(); + outcome.RebuildCalled.ShouldBeFalse(); // PureAdd — no rebuild; provisioning still fires + outcome.AddedNodes.ShouldBeGreaterThan(0); // Fire-and-forget: await the capturing double's signal so the assertion is deterministic. await prov.Called.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); @@ -108,7 +109,7 @@ public sealed class AddressSpaceApplierProvisioningTests var outcome = applier.Apply(PlanWithAddedTags( HistorizedTag(displayName: "Temp", historianName: "Pump1.Temp", dataType: "Float32"))); - outcome.RebuildCalled.ShouldBeTrue(); // address-space work still completed + outcome.RebuildCalled.ShouldBeFalse(); // PureAdd — no rebuild; Apply still completed + provisioned } /// The default ctor (no provisioner) binds the no-op @@ -121,7 +122,8 @@ public sealed class AddressSpaceApplierProvisioningTests var outcome = applier.Apply(PlanWithAddedTags( HistorizedTag(displayName: "Temp", historianName: "Pump1.Temp", dataType: "Float32"))); - outcome.RebuildCalled.ShouldBeTrue(); + outcome.RebuildCalled.ShouldBeFalse(); // PureAdd — no rebuild; default no-op provisioning still safe + outcome.AddedNodes.ShouldBeGreaterThan(0); } /// An added historized tag whose DataType string is not a is @@ -319,7 +321,7 @@ public sealed class AddressSpaceApplierProvisioningTests var outcome = applier.Apply(PlanWithAddedTags( HistorizedTag(displayName: "Temp", historianName: "Pump1.Temp", dataType: "Float32"))); - outcome.RebuildCalled.ShouldBeTrue(); // address-space work still completed + outcome.RebuildCalled.ShouldBeFalse(); // PureAdd — no rebuild; Apply still completed + provisioned } private static EquipmentTagPlan HistorizedTag(string displayName, string? historianName, string dataType, string fullName = "ref") diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierTests.cs index 96433384..f713686d 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierTests.cs @@ -43,9 +43,11 @@ public sealed class AddressSpaceApplierTests sink.RebuildCalls.ShouldBe(1); } - /// Verifies that added equipment triggers rebuild without writing alarm state. + /// R2-07 T2 — added equipment is a PureAdd: the applier SKIPS the rebuild (the idempotent + /// Materialise passes create the new folder; existing client subscriptions survive) and writes no alarm + /// state. (Supersedes the pre-R2-07 "added equipment ⇒ rebuild" pin.) [Fact] - public void Added_equipment_triggers_rebuild_without_alarm_writes() + public void Added_equipment_is_pure_add_and_skips_rebuild() { var sink = new RecordingSink(); var applier = new AddressSpaceApplier(sink, NullLogger.Instance); @@ -63,10 +65,10 @@ public sealed class AddressSpaceApplierTests var outcome = applier.Apply(plan); - outcome.RebuildCalled.ShouldBeTrue(); + outcome.RebuildCalled.ShouldBeFalse(); // PureAdd — no teardown, subscriptions preserved outcome.AddedNodes.ShouldBe(1); sink.AlarmWrites.ShouldBeEmpty(); - sink.RebuildCalls.ShouldBe(1); + sink.RebuildCalls.ShouldBe(0); } /// Verifies that driver-only changes do not trigger address space rebuild. @@ -675,11 +677,11 @@ public sealed class AddressSpaceApplierTests sink.ModelChangeCalls.ShouldBeEmpty(); } - /// Verifies that added equipment tags in an otherwise-empty plan trigger an - /// address-space rebuild (the planner now diffs equipment tags, so a tags-only deploy is no - /// longer a silent no-op). + /// 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_trigger_rebuild() + public void Added_equipment_tags_are_pure_add_and_skip_rebuild() { var sink = new RecordingSink(); var applier = new AddressSpaceApplier(sink, NullLogger.Instance); @@ -694,16 +696,16 @@ public sealed class AddressSpaceApplierTests var outcome = applier.Apply(plan); - outcome.RebuildCalled.ShouldBeTrue(); + outcome.RebuildCalled.ShouldBeFalse(); outcome.AddedNodes.ShouldBe(1); - sink.RebuildCalls.ShouldBe(1); + sink.RebuildCalls.ShouldBe(0); } - /// Verifies that added Equipment VirtualTags in an otherwise-empty plan trigger an - /// address-space rebuild (parity with the equipment-tag path — the planner now diffs VirtualTags, - /// so a VirtualTag-only deploy is no longer a silent no-op). + /// 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_trigger_rebuild() + public void Added_equipment_virtual_tags_are_pure_add_and_skip_rebuild() { var sink = new RecordingSink(); var applier = new AddressSpaceApplier(sink, NullLogger.Instance); @@ -719,8 +721,119 @@ public sealed class AddressSpaceApplierTests var outcome = applier.Apply(plan); - outcome.RebuildCalled.ShouldBeTrue(); + 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(EquipmentNodeIds.Variable("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 T2 phase pin — a remove-only plan still triggers a full rebuild until Phase 2 + /// (PureRemove) ships. Flipped in T11. + [Fact] + public void Removed_equipment_tags_only_still_rebuild_phase_pin() + { + 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.ShouldBeTrue(); + sink.RebuildCalls.ShouldBe(1); + } + + /// R2-07 T2 phase pin — an add+remove plan still triggers a full rebuild until Phase 3 + /// (AddRemoveMix) ships. Flipped in T13. + [Fact] + public void Add_and_remove_still_rebuild_phase_pin() + { + 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.ShouldBeTrue(); sink.RebuildCalls.ShouldBe(1); } @@ -1496,11 +1609,12 @@ public sealed class AddressSpaceApplierTests outcome.ChangedNodes.ShouldBe(2); } - /// 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). + /// 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_rebuilds() + 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); @@ -1529,9 +1643,9 @@ public sealed class AddressSpaceApplierTests var outcome = applier.Apply(plan); - outcome.RebuildCalled.ShouldBeTrue(); - sink.RebuildCalls.ShouldBe(1); - sink.SurgicalCalls.ShouldBeEmpty(); + outcome.RebuildCalled.ShouldBeFalse(); + sink.RebuildCalls.ShouldBe(0); + sink.SurgicalCalls.ShouldHaveSingleItem().Writable.ShouldBeTrue(); } /// F10b fallback — a sink that does NOT implement cannot @@ -1632,11 +1746,12 @@ public sealed class AddressSpaceApplierTests call.DisplayName.ShouldBe("Cell B"); } - /// OpcUaServer-001 — a folder rename MIXED with a structural change (here an added equipment) - /// must rebuild: the rebuild + MaterialiseHierarchy re-create every folder with the new names, so no - /// separate surgical folder call is made. The rename is covered by the rebuild for free. + /// 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_rebuilds() + 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); @@ -1647,7 +1762,8 @@ public sealed class AddressSpaceApplierTests EquipmentNodes: Array.Empty(), DriverInstancePlans: Array.Empty(), ScriptedAlarmPlans: Array.Empty()); - // Area renamed AND a brand-new equipment node — the structural add forces a rebuild. + // 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(), @@ -1661,9 +1777,9 @@ public sealed class AddressSpaceApplierTests var outcome = applier.Apply(plan); - outcome.RebuildCalled.ShouldBeTrue(); - sink.RebuildCalls.ShouldBe(1); - sink.FolderRenameCalls.ShouldBeEmpty(); // no surgical folder call — rebuild covers it + 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