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
@@ -139,6 +139,40 @@ public sealed class DeferredAddressSpaceSinkTests
.ShouldBeFalse();
}
/// <summary>OpcUaServer-001 regression: the deferred wrapper MUST forward the folder-rename surgical
/// capability to a surgical inner sink — otherwise <c>AddressSpaceApplier</c> (which injects THIS
/// wrapper on every driver-role host) never sees the capability and the in-place UNS Area/Line rename
/// refresh is inert in production (silently falls back to a full rebuild).</summary>
[Fact]
public void UpdateFolderDisplayName_forwards_to_a_surgical_inner_sink()
{
var deferred = new DeferredAddressSpaceSink();
var inner = new SurgicalRecordingSink { Result = true };
deferred.SetSink(inner);
((ISurgicalAddressSpaceSink)deferred).UpdateFolderDisplayName("area-1", "Plant South")
.ShouldBeTrue();
var call = inner.FolderRenameCalls.ShouldHaveSingleItem();
call.FolderNodeId.ShouldBe("area-1");
call.DisplayName.ShouldBe("Plant South");
}
/// <summary>The folder-rename forward returns the inner's own result (false ⇒ folder missing) so the
/// caller falls back to a full rebuild; and returns false when the inner is not surgical at all.</summary>
[Fact]
public void UpdateFolderDisplayName_returns_inner_result_and_false_when_not_surgical()
{
var deferred = new DeferredAddressSpaceSink();
// Inner reports the folder missing ⇒ forward returns false.
deferred.SetSink(new SurgicalRecordingSink { Result = false });
((ISurgicalAddressSpaceSink)deferred).UpdateFolderDisplayName("area-1", "X").ShouldBeFalse();
// Non-surgical inner (the null sink before swap-in) ⇒ false.
deferred.SetSink(null);
((ISurgicalAddressSpaceSink)deferred).UpdateFolderDisplayName("area-1", "X").ShouldBeFalse();
}
/// <summary>Builds a minimal <see cref="AlarmConditionSnapshot"/> for the forwarding tests (the
/// inner sink only records the node id, so the exact state values don't matter here).</summary>
private static AlarmConditionSnapshot Snapshot(bool active = false) =>
@@ -182,10 +216,12 @@ public sealed class DeferredAddressSpaceSinkTests
private sealed class SurgicalRecordingSink : IOpcUaAddressSpaceSink, ISurgicalAddressSpaceSink
{
/// <summary>Gets or sets the value <see cref="UpdateTagAttributes"/> returns.</summary>
/// <summary>Gets or sets the value <see cref="UpdateTagAttributes"/> + <see cref="UpdateFolderDisplayName"/> return.</summary>
public bool Result { get; set; } = true;
/// <summary>Gets the recorded surgical calls (incl. the FB-7 DataType/array-shape args).</summary>
public List<(string NodeId, bool Writable, string? Historian, string DataType, bool IsArray, uint? ArrayLength)> SurgicalCalls { get; } = new();
/// <summary>Gets the recorded surgical folder-rename calls (OpcUaServer-001).</summary>
public List<(string FolderNodeId, string DisplayName)> FolderRenameCalls { get; } = new();
/// <inheritdoc />
public bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname, string dataType, bool isArray, uint? arrayLength)
@@ -194,6 +230,13 @@ public sealed class DeferredAddressSpaceSinkTests
return Result;
}
/// <inheritdoc />
public bool UpdateFolderDisplayName(string folderNodeId, string displayName)
{
FolderRenameCalls.Add((folderNodeId, displayName));
return Result;
}
/// <inheritdoc />
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc) { }
/// <inheritdoc />