fix(address-space): announce surgically-added raw nodes; equipment-with-references removal stays surgical (#599)
- AnnounceAddedNodes raises NodeAdded in the Raw realm for the existing parents of added raw containers/tags (ComputeRawAddAnnouncements); Uns side unchanged. - ApplyPureRemove removes UNS reference variables BEFORE the equipment subtree, so a reference the subtree's id-prefix scope reaches no longer ratchets to a full rebuild (03/P5). A NotSubsumed filter was not used: in production a reference NodeId is Area/Line/Equipment/Name, outside the subtree scope, so filtering would leak it. The matching RemoveEquipmentSubtree severing (03/S20) is in the node-manager commit. - UnsReferenceBackingParityTests: reflection guard discovering the classifier's surgical raw-tag whitelist and requiring a same-typed mirror field (03/U11). Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
@@ -182,6 +182,104 @@ public sealed class NodeManagerSurgicalRemoveTests : IDisposable
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
// ---------- Gitea #599 (archreview 03/P5 + 03/S20): equipment-with-references removal ----------
|
||||
|
||||
/// <summary>
|
||||
/// S20 — the subtree removal severs the inverse half of every cross-tree <c>Organizes</c> edge a removed
|
||||
/// variable holds. The raw node survives a UNS-scoped removal, so without this it keeps a browsable,
|
||||
/// unresolvable reference to a node that no longer exists — the hazard <c>RemoveVariableNode</c> was
|
||||
/// already fixed for (2026-08-15 review I3), left open on the subtree path.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
[Fact]
|
||||
public async Task RemoveEquipmentSubtree_severs_the_inverse_Organizes_edge_on_the_surviving_raw_node()
|
||||
{
|
||||
var (host, server) = await BootAsync();
|
||||
try
|
||||
{
|
||||
var nm = server.NodeManager!;
|
||||
nm.EnsureVariable("Plant/Modbus/dev1/temp", null, "temp", "Float", writable: false, realm: AddressSpaceRealm.Raw);
|
||||
nm.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment 1", realm: AddressSpaceRealm.Uns);
|
||||
nm.EnsureVariable("eq-1/Temp", "eq-1", "Temp", "Float", writable: false, realm: AddressSpaceRealm.Uns);
|
||||
nm.AddReference("eq-1/Temp", AddressSpaceRealm.Uns, "Plant/Modbus/dev1/temp", AddressSpaceRealm.Raw);
|
||||
var mirror = nm.TryGetVariable("eq-1/Temp", AddressSpaceRealm.Uns)!.NodeId;
|
||||
InverseOrganizesTo(nm, mirror).ShouldBeTrue(); // precondition: the edge really is there
|
||||
|
||||
nm.RemoveEquipmentSubtree("eq-1", AddressSpaceRealm.Uns).ShouldBeTrue();
|
||||
|
||||
nm.TryGetVariable("Plant/Modbus/dev1/temp", AddressSpaceRealm.Raw).ShouldNotBeNull();
|
||||
InverseOrganizesTo(nm, mirror).ShouldBeFalse();
|
||||
}
|
||||
finally
|
||||
{
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// P5 — removing an equipment that holds UNS references is a scoped in-place removal, NOT a full rebuild,
|
||||
/// and leaves the surviving raw node with no dangling inverse edge. Run for both id shapes: a reference
|
||||
/// whose NodeId sits under the equipment id (so the subtree removal reaches it — before the fix the
|
||||
/// reference loop then found it gone and ratcheted to a rebuild, severing every subscription on the
|
||||
/// server), and the production shape, an <c>Area/Line/Equipment/Name</c> NodeId parented to an
|
||||
/// id-named equipment folder (which the subtree's id-prefix scope does not reach).
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
[Theory]
|
||||
[InlineData("eq-1/Temp")]
|
||||
[InlineData("Area/Line/Eq/Temp")]
|
||||
public async Task Removing_equipment_with_references_is_surgical_and_leaves_no_dangling_edge(string mirrorId)
|
||||
{
|
||||
var (host, server) = await BootAsync();
|
||||
try
|
||||
{
|
||||
var nm = server.NodeManager!;
|
||||
nm.EnsureVariable("Plant/Modbus/dev1/temp", null, "temp", "Float", writable: false, realm: AddressSpaceRealm.Raw);
|
||||
nm.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment 1", realm: AddressSpaceRealm.Uns);
|
||||
nm.EnsureVariable(mirrorId, "eq-1", "Temp", "Float", writable: false, realm: AddressSpaceRealm.Uns);
|
||||
nm.AddReference(mirrorId, AddressSpaceRealm.Uns, "Plant/Modbus/dev1/temp", AddressSpaceRealm.Raw);
|
||||
// A bystander whose value must survive (a rebuild would drop it).
|
||||
nm.EnsureFolder("eq-2", parentNodeId: null, displayName: "Equipment 2", realm: AddressSpaceRealm.Uns);
|
||||
nm.EnsureVariable("eq-2/S", "eq-2", "S", "Float", writable: false, realm: AddressSpaceRealm.Uns);
|
||||
var mirror = nm.TryGetVariable(mirrorId, AddressSpaceRealm.Uns)!.NodeId;
|
||||
|
||||
var plan = new AddressSpacePlan(
|
||||
AddedEquipment: Array.Empty<EquipmentNode>(),
|
||||
RemovedEquipment: new[] { new EquipmentNode("eq-1", "Equipment 1", "") },
|
||||
ChangedEquipment: Array.Empty<AddressSpacePlan.EquipmentDelta>(),
|
||||
AddedDrivers: Array.Empty<DriverInstancePlan>(),
|
||||
RemovedDrivers: Array.Empty<DriverInstancePlan>(),
|
||||
ChangedDrivers: Array.Empty<AddressSpacePlan.DriverDelta>(),
|
||||
AddedAlarms: Array.Empty<ScriptedAlarmPlan>(),
|
||||
RemovedAlarms: Array.Empty<ScriptedAlarmPlan>(),
|
||||
ChangedAlarms: Array.Empty<AddressSpacePlan.AlarmDelta>())
|
||||
{
|
||||
RemovedUnsReferenceVariables = new[]
|
||||
{
|
||||
new UnsReferenceVariable("ref-1", "eq-1", mirrorId, "Temp", "Plant/Modbus/dev1/temp", "Float", Writable: false),
|
||||
},
|
||||
};
|
||||
|
||||
var outcome = new AddressSpaceApplier(new SdkAddressSpaceSink(nm),
|
||||
Microsoft.Extensions.Logging.Abstractions.NullLogger<AddressSpaceApplier>.Instance).Apply(plan);
|
||||
|
||||
outcome.RebuildCalled.ShouldBeFalse();
|
||||
nm.TryGetVariable(mirrorId, AddressSpaceRealm.Uns).ShouldBeNull();
|
||||
nm.TryGetFolder("eq-1").ShouldBeNull();
|
||||
nm.TryGetVariable("eq-2/S", AddressSpaceRealm.Uns).ShouldNotBeNull(); // positive control: no rebuild
|
||||
nm.TryGetVariable("Plant/Modbus/dev1/temp", AddressSpaceRealm.Raw).ShouldNotBeNull();
|
||||
InverseOrganizesTo(nm, mirror).ShouldBeFalse();
|
||||
}
|
||||
finally
|
||||
{
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private static bool InverseOrganizesTo(OtOpcUaNodeManager nm, NodeId mirror) =>
|
||||
nm.GetNodeReferences("Plant/Modbus/dev1/temp", AddressSpaceRealm.Raw)
|
||||
.Any(r => r.ReferenceTypeId == ReferenceTypeIds.Organizes && r.IsInverse && r.TargetId == mirror);
|
||||
|
||||
private async Task<(OpcUaApplicationHost Host, OtOpcUaSdkServer Server)> BootAsync()
|
||||
{
|
||||
var host = new OpcUaApplicationHost(
|
||||
|
||||
Reference in New Issue
Block a user