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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user