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