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