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
@@ -52,19 +52,45 @@ public sealed record AddressSpacePlan(
/// <inheritdoc cref="AddedEquipmentVirtualTags"/>
public IReadOnlyList<EquipmentVirtualTagDelta> ChangedEquipmentVirtualTags { get; init; } = Array.Empty<EquipmentVirtualTagDelta>();
/// <summary>
/// OpcUaServer-001 — UNS Area / Line folder renames: a folder whose stable id is unchanged but
/// whose <c>DisplayName</c> differs between the previous + next composition. A deploy whose ONLY
/// change is an Area or Line rename produces NO Equipment / Driver / Alarm / Tag / VirtualTag
/// delta, so without this set the plan would be <see cref="IsEmpty"/> and
/// <c>OpcUaPublishActor.HandleRebuild</c> would short-circuit BEFORE the apply path runs, leaving
/// the folder's stale OPC UA <c>DisplayName</c> until some unrelated structural change forced a
/// rebuild. <see cref="AddressSpaceApplier"/> applies each rename IN PLACE via
/// <see cref="Commons.OpcUa.ISurgicalAddressSpaceSink.UpdateFolderDisplayName"/> (folder NodeId =
/// the area's <c>UnsAreaId</c> / line's <c>UnsLineId</c>, the exact ids <c>MaterialiseHierarchy</c>
/// uses), preserving every client's subscriptions; a sink lacking the surgical capability or a
/// missing folder falls back to a full rebuild. Added as an init-only member (defaulting empty) so
/// every existing <c>AddressSpacePlan</c> construction site compiles unchanged — consistent with
/// the EquipmentTag / EquipmentVirtualTag diff sets above.
/// </summary>
public IReadOnlyList<FolderRename> RenamedFolders { get; init; } = Array.Empty<FolderRename>();
/// <summary>Gets a value indicating whether the composition plan contains no changes.</summary>
public bool IsEmpty =>
AddedEquipment.Count == 0 && RemovedEquipment.Count == 0 && ChangedEquipment.Count == 0 &&
AddedDrivers.Count == 0 && RemovedDrivers.Count == 0 && ChangedDrivers.Count == 0 &&
AddedAlarms.Count == 0 && RemovedAlarms.Count == 0 && ChangedAlarms.Count == 0 &&
AddedEquipmentTags.Count == 0 && RemovedEquipmentTags.Count == 0 && ChangedEquipmentTags.Count == 0 &&
AddedEquipmentVirtualTags.Count == 0 && RemovedEquipmentVirtualTags.Count == 0 && ChangedEquipmentVirtualTags.Count == 0;
AddedEquipmentVirtualTags.Count == 0 && RemovedEquipmentVirtualTags.Count == 0 && ChangedEquipmentVirtualTags.Count == 0 &&
RenamedFolders.Count == 0;
public sealed record EquipmentDelta(EquipmentNode Previous, EquipmentNode Current);
public sealed record DriverDelta(DriverInstancePlan Previous, DriverInstancePlan Current);
public sealed record AlarmDelta(ScriptedAlarmPlan Previous, ScriptedAlarmPlan Current);
public sealed record EquipmentTagDelta(EquipmentTagPlan Previous, EquipmentTagPlan Current);
public sealed record EquipmentVirtualTagDelta(EquipmentVirtualTagPlan Previous, EquipmentVirtualTagPlan Current);
/// <summary>OpcUaServer-001 — one renamed UNS Area / Line folder: the stable folder
/// <paramref name="FolderNodeId"/> (the area's <c>UnsAreaId</c> or line's <c>UnsLineId</c>, the exact
/// NodeId <c>MaterialiseHierarchy</c> placed the folder at) and the <paramref name="NewDisplayName"/>
/// to apply in place.</summary>
/// <param name="FolderNodeId">The folder's stable NodeId (area/line id) — unchanged by a rename.</param>
/// <param name="NewDisplayName">The new display name to apply.</param>
public sealed record FolderRename(string FolderNodeId, string NewDisplayName);
}
public static class AddressSpacePlanner
@@ -112,6 +138,15 @@ public static class AddressSpacePlanner
t => t.VirtualTagId,
(a, b) => new AddressSpacePlan.EquipmentVirtualTagDelta(a, b));
// OpcUaServer-001 — UNS Area / Line renames: a folder whose stable id is unchanged but whose
// DisplayName differs. Diffed by stable id (UnsAreaId / UnsLineId) so an Area/Line whose ONLY
// change is its friendly name is no longer a silent no-op at the IsEmpty gate. The folder NodeId
// IS the area/line id (the exact scheme MaterialiseHierarchy uses), so the rename carries it
// directly. Areas first, then lines; each list is independently sorted by id for determinism.
var renamedFolders = DiffRenames(previous.UnsAreas, next.UnsAreas, a => a.UnsAreaId, a => a.DisplayName)
.Concat(DiffRenames(previous.UnsLines, next.UnsLines, l => l.UnsLineId, l => l.DisplayName))
.ToList();
return new AddressSpacePlan(
addedEq, removedEq, changedEq,
addedDrv, removedDrv, changedDrv,
@@ -123,9 +158,37 @@ public static class AddressSpacePlanner
AddedEquipmentVirtualTags = addedVTags,
RemovedEquipmentVirtualTags = removedVTags,
ChangedEquipmentVirtualTags = changedVTags,
RenamedFolders = renamedFolders,
};
}
/// <summary>
/// OpcUaServer-001 — emit a <see cref="AddressSpacePlan.FolderRename"/> for every folder present in
/// BOTH snapshots (matched by stable <paramref name="identity"/>) whose <paramref name="displayName"/>
/// differs (ordinal). Added/removed folders are NOT renames — they're handled by the equipment /
/// hierarchy rebuild path — so this pass only flags an in-place display-name change on a surviving
/// folder. Sorted by id for deterministic ordering.
/// </summary>
private static List<AddressSpacePlan.FolderRename> DiffRenames<T>(
IReadOnlyList<T> previous,
IReadOnlyList<T> next,
Func<T, string> identity,
Func<T, string> displayName) where T : class
{
var prevById = previous.ToDictionary(identity, StringComparer.Ordinal);
var renames = new List<AddressSpacePlan.FolderRename>();
foreach (var n in next)
{
if (prevById.TryGetValue(identity(n), out var p)
&& !string.Equals(displayName(p), displayName(n), StringComparison.Ordinal))
{
renames.Add(new AddressSpacePlan.FolderRename(identity(n), displayName(n)));
}
}
renames.Sort((a, b) => string.CompareOrdinal(a.FolderNodeId, b.FolderNodeId));
return renames;
}
private static (IReadOnlyList<T> Added, IReadOnlyList<T> Removed, IReadOnlyList<TDelta> Changed)
DiffById<T, TDelta>(
IReadOnlyList<T> previous,