feat(opcua): surgical mixed add+remove deploys (R2-07 T13)

This commit is contained in:
Joseph Doherty
2026-07-13 12:22:45 -04:00
parent f4ea834fac
commit e2b47638ec
3 changed files with 79 additions and 13 deletions
@@ -927,10 +927,11 @@ public sealed class AddressSpaceApplierTests
sink.RebuildCalls.ShouldBe(1);
}
/// <summary>R2-07 T2 phase pin — an add+remove plan still triggers a full rebuild until Phase 3
/// (AddRemoveMix) ships. Flipped in T13.</summary>
/// <summary>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.)</summary>
[Fact]
public void Add_and_remove_still_rebuild_phase_pin()
public void Add_and_remove_is_mixed_removes_in_place_without_rebuild()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
@@ -949,6 +950,64 @@ public sealed class AddressSpaceApplierTests
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", EquipmentNodeIds.Variable("eq-1", "", "Old")));
outcome.AddedNodes.ShouldBe(1);
outcome.RemovedNodes.ShouldBe(1);
}
/// <summary>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.</summary>
[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<AddressSpaceApplier>.Instance);
var nodeId = EquipmentNodeIds.Variable("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));
}
/// <summary>R2-07 T13 — a remove failure inside an AddRemoveMix still trips the rebuild ratchet.</summary>
[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<AddressSpaceApplier>.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);
}