fix(code-review): resolve OpcUaServer-001 — UNS Area/Line rename refreshes folder DisplayName

A rename-only deploy produced an IsEmpty plan that short-circuited before MaterialiseHierarchy,
leaving the OPC UA folder DisplayName stale. AddressSpacePlanner now diffs UnsAreas/UnsLines by
stable id into a RenamedFolders set (counted in IsEmpty); the applier refreshes the folder in
place via a new UpdateFolderDisplayName on ISurgicalAddressSpaceSink (forwarded through
DeferredAddressSpaceSink so it is NOT inert on driver hosts; falls back to rebuild when the sink
is non-surgical). DeploymentArtifact byte-parity untouched (rename rides the existing Name
round-trip). No EF migration, no serialized wire/proto contract change. +13 OpcUaServer tests, Runtime rebuild test.
This commit is contained in:
Joseph Doherty
2026-06-20 23:10:24 -04:00
parent 94eec70fb0
commit 23b42b424d
13 changed files with 700 additions and 11 deletions
@@ -210,6 +210,70 @@ public sealed class AddressSpaceApplierHierarchyTests : IDisposable
sdkServer.NodeManager!.VariableCount.ShouldBe(1); // the Speed signal under the equipment folder
}
/// <summary>OpcUaServer-001 — a UNS Area / Line rename-only deploy refreshes the EXISTING folder's
/// DisplayName IN PLACE against a real SDK node manager (no rebuild, no node count change). Proves the
/// full chain: planner emits a RenamedFolders delta → applier drives the surgical
/// SdkAddressSpaceSink.UpdateFolderDisplayName → OtOpcUaNodeManager mutates the live FolderState +
/// ClearChangeMasks. Before the fix, EnsureFolder early-returned on the existing folder and the
/// DisplayName stayed stale until a full RebuildAddressSpace.</summary>
[Fact]
public async Task Area_and_line_rename_updates_existing_folder_display_name_in_place_against_real_SDK()
{
await using var host = new OpcUaApplicationHost(
new OpcUaApplicationHostOptions
{
ApplicationName = "OtOpcUa.FolderRename",
ApplicationUri = $"urn:OtOpcUa.FolderRename:{Guid.NewGuid():N}",
OpcUaPort = AllocateFreePort(),
PublicHostname = "localhost",
PkiStoreRoot = _pkiRoot,
},
NullLogger<OpcUaApplicationHost>.Instance);
var sdkServer = new OtOpcUaSdkServer();
await host.StartAsync(sdkServer, Ct);
sdkServer.NodeManager.ShouldNotBeNull();
var nm = sdkServer.NodeManager!;
var sink = new SdkAddressSpaceSink(nm);
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
// Materialise the initial hierarchy (area + line + equipment) with the OLD display names.
var initial = new AddressSpaceComposition(
UnsAreas: new[] { new UnsAreaProjection("area-1", "Plant North") },
UnsLines: new[] { new UnsLineProjection("line-1", "area-1", "Cell A") },
EquipmentNodes: new[] { new EquipmentNode("eq-1", "Pump-1", "line-1") },
DriverInstancePlans: Array.Empty<DriverInstancePlan>(),
ScriptedAlarmPlans: Array.Empty<ScriptedAlarmPlan>());
applier.MaterialiseHierarchy(initial);
nm.FolderCount.ShouldBe(3);
nm.TryGetFolder("area-1")!.DisplayName.Text.ShouldBe("Plant North");
nm.TryGetFolder("line-1")!.DisplayName.Text.ShouldBe("Cell A");
// Now a rename-only deploy: same ids, new display names on BOTH the area and the line.
var renamed = new AddressSpaceComposition(
UnsAreas: new[] { new UnsAreaProjection("area-1", "Plant South") },
UnsLines: new[] { new UnsLineProjection("line-1", "area-1", "Cell B") },
EquipmentNodes: new[] { new EquipmentNode("eq-1", "Pump-1", "line-1") },
DriverInstancePlans: Array.Empty<DriverInstancePlan>(),
ScriptedAlarmPlans: Array.Empty<ScriptedAlarmPlan>());
var plan = AddressSpacePlanner.Compute(initial, renamed);
plan.IsEmpty.ShouldBeFalse(); // rename is no longer a silent no-op
plan.RenamedFolders.Count.ShouldBe(2);
var outcome = applier.Apply(plan);
// In-place: no rebuild, no node-count change — only the DisplayNames were swapped.
outcome.RebuildCalled.ShouldBeFalse();
nm.FolderCount.ShouldBe(3);
nm.TryGetFolder("area-1")!.DisplayName.Text.ShouldBe("Plant South");
nm.TryGetFolder("line-1")!.DisplayName.Text.ShouldBe("Cell B");
// The unrelated equipment folder is untouched.
nm.TryGetFolder("eq-1")!.DisplayName.Text.ShouldBe("Pump-1");
}
private static int AllocateFreePort()
{
using var listener = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Loopback, 0);