feat(opcua): surgical pure-remove deploys — scoped teardown, no full rebuild (R2-07 T11)

This commit is contained in:
Joseph Doherty
2026-07-13 12:18:08 -04:00
parent 4504ed930c
commit 502d7650d0
3 changed files with 257 additions and 18 deletions
@@ -25,9 +25,12 @@ public sealed class AddressSpaceApplierTests
sink.AlarmWrites.ShouldBeEmpty();
}
/// <summary>Verifies that removed equipment writes inactive alarm state and triggers rebuild.</summary>
/// <summary>R2-07 T11 — removed equipment is a PureRemove: the applier writes each id's terminal
/// "no-event" condition state (top-of-Apply block) then tears down each equipment SUBTREE in place via
/// RemoveEquipmentSubtree — NO full rebuild (other clients' subscriptions survive). (Supersedes the
/// pre-R2-07 "removed equipment ⇒ rebuild" pin.)</summary>
[Fact]
public void Removed_equipment_writes_inactive_alarm_state_per_id_and_triggers_rebuild()
public void Removed_equipment_writes_terminal_state_and_removes_subtree_without_rebuild()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
@@ -36,11 +39,13 @@ public sealed class AddressSpaceApplierTests
var outcome = applier.Apply(plan);
outcome.RemovedNodes.ShouldBe(2);
outcome.RebuildCalled.ShouldBeTrue();
outcome.RebuildCalled.ShouldBeFalse();
sink.RebuildCalls.ShouldBe(0);
// Terminal "no-event" condition state written per id (inactive + acked + confirmed).
sink.AlarmWrites.Select(a => a.NodeId).OrderBy(x => x).ShouldBe(new[] { "eq-1", "eq-2" });
// Removed nodes are reset to the "no-event" state: inactive + acked + confirmed + enabled.
sink.AlarmWrites.All(a => !a.State.Active && a.State.Acknowledged && a.State.Confirmed).ShouldBeTrue();
sink.RebuildCalls.ShouldBe(1);
// Each equipment torn down as a subtree.
sink.RemoveCalls.OrderBy(x => x.NodeId).ShouldBe(new[] { ("equipment", "eq-1"), ("equipment", "eq-2") });
}
/// <summary>R2-07 T2 — added equipment is a PureAdd: the applier SKIPS the rebuild (the idempotent
@@ -789,10 +794,11 @@ public sealed class AddressSpaceApplierTests
sink.SurgicalCalls.ShouldHaveSingleItem(); // the surgical update was attempted first
}
/// <summary>R2-07 T2 phase pin — a remove-only plan still triggers a full rebuild until Phase 2
/// (PureRemove) ships. Flipped in T11.</summary>
/// <summary>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.)</summary>
[Fact]
public void Removed_equipment_tags_only_still_rebuild_phase_pin()
public void Removed_equipment_tag_writes_terminal_bad_and_removes_variable_without_rebuild()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
@@ -807,6 +813,116 @@ public sealed class AddressSpaceApplierTests
var outcome = applier.Apply(plan);
outcome.RebuildCalled.ShouldBeFalse();
sink.RebuildCalls.ShouldBe(0);
var nodeId = EquipmentNodeIds.Variable("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));
}
/// <summary>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).</summary>
[Fact]
public void Removed_alarm_bearing_tag_writes_removed_condition_state_and_removes_condition()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.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 = EquipmentNodeIds.Variable("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);
}
/// <summary>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.</summary>
[Fact]
public void Removed_child_under_removed_equipment_is_subsumed_by_subtree_removal()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.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"));
}
/// <summary>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.</summary>
[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<AddressSpaceApplier>.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);
}
/// <summary>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.</summary>
[Fact]
public void Pure_remove_on_non_surgical_sink_rebuilds()
{
var sink = new PlainRecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.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);
}
@@ -1276,9 +1392,14 @@ public sealed class AddressSpaceApplierTests
var outcome = applier.Apply(plan);
outcome.RebuildCalled.ShouldBeTrue();
sink.RebuildCalls.ShouldBe(1);
outcome.RemovedNodes.ShouldBe(2); // both removals counted (was 0 before the fix)
// 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", EquipmentNodeIds.Variable("eq-1", "", "Speed")));
sink.RemoveCalls.ShouldContain(("var", EquipmentNodeIds.Variable("eq-1", "", "Efficiency")));
}
// ----- F10b: surgical in-place tag-attribute writes (Writable / IsHistorized / HistorianTagname) -----
@@ -2072,12 +2193,18 @@ public sealed class AddressSpaceApplierTests
/// <summary>Gets the list of recorded alarm-condition materialise calls.</summary>
public List<(string AlarmNodeId, string EquipmentNodeId, string DisplayName, string AlarmType, int Severity, bool IsNative)> AlarmConditionCalls => AlarmConditionQueue.ToList();
/// <summary>Records a value write (no-op in this recording sink).</summary>
/// <summary>Gets the queue of value writes (NodeId, quality) — used to assert the PureRemove terminal Bad.</summary>
public ConcurrentQueue<(string NodeId, OpcUaQuality Quality)> ValueWriteQueue { get; } = new();
/// <summary>Gets the list of recorded value writes.</summary>
public List<(string NodeId, OpcUaQuality Quality)> ValueWrites => ValueWriteQueue.ToList();
/// <summary>Records a value write (NodeId + quality).</summary>
/// <param name="nodeId">The node ID.</param>
/// <param name="value">The value to write.</param>
/// <param name="quality">The OPC UA quality.</param>
/// <param name="sourceTimestampUtc">The source timestamp in UTC.</param>
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc) { }
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc)
=> ValueWriteQueue.Enqueue((nodeId, quality));
/// <summary>Records an alarm condition write call.</summary>
/// <param name="alarmNodeId">The alarm node ID.</param>
/// <param name="state">The full condition state snapshot.</param>