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
@@ -353,6 +353,131 @@ public sealed class AddressSpacePlannerTests
plan.RemovedEquipment.Select(e => e.EquipmentId).ShouldBe(new[] { "a", "z" });
}
/// <summary>OpcUaServer-001 — a deploy whose ONLY change is a UNS Area rename (the area's
/// <c>DisplayName</c> differs; same id, no equipment/driver/alarm/tag/vtag delta) must yield a
/// NON-empty plan with the rename in <see cref="AddressSpacePlan.RenamedFolders"/>, so
/// <c>OpcUaPublishActor.HandleRebuild</c> no longer short-circuits at the IsEmpty gate before the
/// folder's DisplayName is refreshed.</summary>
[Fact]
public void Area_rename_only_yields_non_empty_plan_with_renamed_folder()
{
var prev = new AddressSpaceComposition(
UnsAreas: new[] { new UnsAreaProjection("area-1", "Plant North") },
UnsLines: Array.Empty<UnsLineProjection>(),
EquipmentNodes: Array.Empty<EquipmentNode>(),
DriverInstancePlans: Array.Empty<DriverInstancePlan>(),
ScriptedAlarmPlans: Array.Empty<ScriptedAlarmPlan>());
var next = new AddressSpaceComposition(
UnsAreas: new[] { new UnsAreaProjection("area-1", "Plant South") },
UnsLines: Array.Empty<UnsLineProjection>(),
EquipmentNodes: Array.Empty<EquipmentNode>(),
DriverInstancePlans: Array.Empty<DriverInstancePlan>(),
ScriptedAlarmPlans: Array.Empty<ScriptedAlarmPlan>());
var plan = AddressSpacePlanner.Compute(prev, next);
plan.IsEmpty.ShouldBeFalse();
var rename = plan.RenamedFolders.ShouldHaveSingleItem();
rename.FolderNodeId.ShouldBe("area-1"); // folder NodeId == the area id (MaterialiseHierarchy scheme)
rename.NewDisplayName.ShouldBe("Plant South");
}
/// <summary>OpcUaServer-001 — a deploy whose ONLY change is a UNS Line rename yields a NON-empty plan
/// with the rename in <see cref="AddressSpacePlan.RenamedFolders"/> (folder NodeId == the line id).</summary>
[Fact]
public void Line_rename_only_yields_non_empty_plan_with_renamed_folder()
{
var prev = new AddressSpaceComposition(
UnsAreas: Array.Empty<UnsAreaProjection>(),
UnsLines: new[] { new UnsLineProjection("line-1", "area-1", "Cell A") },
EquipmentNodes: Array.Empty<EquipmentNode>(),
DriverInstancePlans: Array.Empty<DriverInstancePlan>(),
ScriptedAlarmPlans: Array.Empty<ScriptedAlarmPlan>());
var next = new AddressSpaceComposition(
UnsAreas: Array.Empty<UnsAreaProjection>(),
UnsLines: new[] { new UnsLineProjection("line-1", "area-1", "Cell B") },
EquipmentNodes: Array.Empty<EquipmentNode>(),
DriverInstancePlans: Array.Empty<DriverInstancePlan>(),
ScriptedAlarmPlans: Array.Empty<ScriptedAlarmPlan>());
var plan = AddressSpacePlanner.Compute(prev, next);
plan.IsEmpty.ShouldBeFalse();
var rename = plan.RenamedFolders.ShouldHaveSingleItem();
rename.FolderNodeId.ShouldBe("line-1");
rename.NewDisplayName.ShouldBe("Cell B");
}
/// <summary>OpcUaServer-001 — no false positives: two compositions with identical Area + Line topology
/// (same ids, same DisplayNames) produce NO rename and an empty plan. Pins that the rename diff fires
/// ONLY on an actual DisplayName change, not on every redeploy.</summary>
[Fact]
public void Identical_area_line_topology_yields_empty_plan_no_renames()
{
var areas = new[] { new UnsAreaProjection("area-1", "Plant North") };
var lines = new[] { new UnsLineProjection("line-1", "area-1", "Cell A") };
var prev = new AddressSpaceComposition(areas, lines, Array.Empty<EquipmentNode>(),
Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>());
// Fresh projection instances, identical contents — proves value (not reference) comparison.
var next = new AddressSpaceComposition(
new[] { new UnsAreaProjection("area-1", "Plant North") },
new[] { new UnsLineProjection("line-1", "area-1", "Cell A") },
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>());
var plan = AddressSpacePlanner.Compute(prev, next);
plan.IsEmpty.ShouldBeTrue();
plan.RenamedFolders.ShouldBeEmpty();
}
/// <summary>OpcUaServer-001 — a brand-new Area (added, not renamed) is NOT a rename: an area present
/// only in <c>next</c> is materialised by the hierarchy pass, not refreshed in place. The rename diff
/// must only flag folders present in BOTH snapshots whose DisplayName changed.</summary>
[Fact]
public void Added_area_is_not_a_rename()
{
var prev = new AddressSpaceComposition(
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>());
var next = new AddressSpaceComposition(
UnsAreas: new[] { new UnsAreaProjection("area-1", "Plant North") },
UnsLines: Array.Empty<UnsLineProjection>(),
EquipmentNodes: Array.Empty<EquipmentNode>(),
DriverInstancePlans: Array.Empty<DriverInstancePlan>(),
ScriptedAlarmPlans: Array.Empty<ScriptedAlarmPlan>());
var plan = AddressSpacePlanner.Compute(prev, next);
plan.RenamedFolders.ShouldBeEmpty();
}
/// <summary>OpcUaServer-001 — when BOTH an Area and a Line are renamed in one deploy, both renames
/// appear in <see cref="AddressSpacePlan.RenamedFolders"/>, ordered deterministically by folder id
/// (areas + lines concatenated, each sorted by id).</summary>
[Fact]
public void Area_and_line_renames_both_captured_and_ordered_by_id()
{
var prev = new AddressSpaceComposition(
UnsAreas: new[] { new UnsAreaProjection("area-1", "North") },
UnsLines: new[] { new UnsLineProjection("line-1", "area-1", "Cell A") },
EquipmentNodes: Array.Empty<EquipmentNode>(),
DriverInstancePlans: Array.Empty<DriverInstancePlan>(),
ScriptedAlarmPlans: Array.Empty<ScriptedAlarmPlan>());
var next = new AddressSpaceComposition(
UnsAreas: new[] { new UnsAreaProjection("area-1", "South") },
UnsLines: new[] { new UnsLineProjection("line-1", "area-1", "Cell B") },
EquipmentNodes: Array.Empty<EquipmentNode>(),
DriverInstancePlans: Array.Empty<DriverInstancePlan>(),
ScriptedAlarmPlans: Array.Empty<ScriptedAlarmPlan>());
var plan = AddressSpacePlanner.Compute(prev, next);
plan.RenamedFolders.Count.ShouldBe(2);
// "area-1" sorts before "line-1" (areas first, then lines, each id-sorted) — deterministic order.
plan.RenamedFolders.Select(r => r.FolderNodeId).ShouldBe(new[] { "area-1", "line-1" });
plan.RenamedFolders.Single(r => r.FolderNodeId == "area-1").NewDisplayName.ShouldBe("South");
plan.RenamedFolders.Single(r => r.FolderNodeId == "line-1").NewDisplayName.ShouldBe("Cell B");
}
/// <summary>Verifies that mixed changes across all three classes are captured in one pass.</summary>
[Fact]
public void Mixed_changes_across_all_three_classes_are_captured_in_one_pass()