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:
@@ -1473,6 +1473,144 @@ public sealed class AddressSpaceApplierTests
|
||||
sink.SurgicalCalls.ShouldHaveSingleItem(); // the surgical update was attempted first
|
||||
}
|
||||
|
||||
// ----- OpcUaServer-001: in-place UNS Area / Line folder rename (no rebuild) -----
|
||||
|
||||
/// <summary>OpcUaServer-001 — a deploy whose ONLY change is a UNS Area rename must SKIP the rebuild and
|
||||
/// apply the new DisplayName IN PLACE via <c>ISurgicalAddressSpaceSink.UpdateFolderDisplayName</c>,
|
||||
/// preserving every client's subscriptions. Exactly one surgical folder call lands with the area's
|
||||
/// NodeId (== UnsAreaId) and the NEW display name; the rename still counts as a change.</summary>
|
||||
[Fact]
|
||||
public void Area_rename_only_skips_rebuild_and_updates_folder_in_place()
|
||||
{
|
||||
var sink = new RecordingSink();
|
||||
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
||||
|
||||
var previous = CompositionWithAreas(new UnsAreaProjection("area-1", "Plant North"));
|
||||
var next = CompositionWithAreas(new UnsAreaProjection("area-1", "Plant South"));
|
||||
|
||||
var plan = AddressSpacePlanner.Compute(previous, next);
|
||||
plan.RenamedFolders.Count.ShouldBe(1);
|
||||
|
||||
var outcome = applier.Apply(plan);
|
||||
|
||||
outcome.RebuildCalled.ShouldBeFalse();
|
||||
sink.RebuildCalls.ShouldBe(0); // NO RebuildAddressSpace — subscriptions preserved
|
||||
var call = sink.FolderRenameCalls.ShouldHaveSingleItem();
|
||||
call.FolderNodeId.ShouldBe("area-1"); // folder NodeId == UnsAreaId (MaterialiseHierarchy scheme)
|
||||
call.DisplayName.ShouldBe("Plant South"); // the NEW display name
|
||||
outcome.ChangedNodes.ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>OpcUaServer-001 — a Line rename only updates the line folder in place (NodeId == UnsLineId)
|
||||
/// and skips the rebuild.</summary>
|
||||
[Fact]
|
||||
public void Line_rename_only_skips_rebuild_and_updates_folder_in_place()
|
||||
{
|
||||
var sink = new RecordingSink();
|
||||
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
||||
|
||||
var previous = CompositionWithLines(new UnsLineProjection("line-1", "area-1", "Cell A"));
|
||||
var next = CompositionWithLines(new UnsLineProjection("line-1", "area-1", "Cell B"));
|
||||
|
||||
var plan = AddressSpacePlanner.Compute(previous, next);
|
||||
plan.RenamedFolders.Count.ShouldBe(1);
|
||||
|
||||
var outcome = applier.Apply(plan);
|
||||
|
||||
outcome.RebuildCalled.ShouldBeFalse();
|
||||
sink.RebuildCalls.ShouldBe(0);
|
||||
var call = sink.FolderRenameCalls.ShouldHaveSingleItem();
|
||||
call.FolderNodeId.ShouldBe("line-1");
|
||||
call.DisplayName.ShouldBe("Cell B");
|
||||
}
|
||||
|
||||
/// <summary>OpcUaServer-001 — a folder rename MIXED with a structural change (here an added equipment)
|
||||
/// must rebuild: the rebuild + MaterialiseHierarchy re-create every folder with the new names, so no
|
||||
/// separate surgical folder call is made. The rename is covered by the rebuild for free.</summary>
|
||||
[Fact]
|
||||
public void Folder_rename_mixed_with_added_equipment_rebuilds()
|
||||
{
|
||||
var sink = new RecordingSink();
|
||||
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
||||
|
||||
var previous = new AddressSpaceComposition(
|
||||
UnsAreas: new[] { new UnsAreaProjection("area-1", "North") },
|
||||
UnsLines: Array.Empty<UnsLineProjection>(),
|
||||
EquipmentNodes: Array.Empty<EquipmentNode>(),
|
||||
DriverInstancePlans: Array.Empty<DriverInstancePlan>(),
|
||||
ScriptedAlarmPlans: Array.Empty<ScriptedAlarmPlan>());
|
||||
// Area renamed AND a brand-new equipment node — the structural add forces a rebuild.
|
||||
var next = new AddressSpaceComposition(
|
||||
UnsAreas: new[] { new UnsAreaProjection("area-1", "South") },
|
||||
UnsLines: Array.Empty<UnsLineProjection>(),
|
||||
EquipmentNodes: new[] { new EquipmentNode("eq-new", "New", "line-1") },
|
||||
DriverInstancePlans: Array.Empty<DriverInstancePlan>(),
|
||||
ScriptedAlarmPlans: Array.Empty<ScriptedAlarmPlan>());
|
||||
|
||||
var plan = AddressSpacePlanner.Compute(previous, next);
|
||||
plan.RenamedFolders.Count.ShouldBe(1);
|
||||
plan.AddedEquipment.Count.ShouldBe(1);
|
||||
|
||||
var outcome = applier.Apply(plan);
|
||||
|
||||
outcome.RebuildCalled.ShouldBeTrue();
|
||||
sink.RebuildCalls.ShouldBe(1);
|
||||
sink.FolderRenameCalls.ShouldBeEmpty(); // no surgical folder call — rebuild covers it
|
||||
}
|
||||
|
||||
/// <summary>OpcUaServer-001 fallback — a rename-only plan on a sink that does NOT implement
|
||||
/// <see cref="ISurgicalAddressSpaceSink"/> cannot apply the in-place update, so it drives a full
|
||||
/// rebuild (safe default).</summary>
|
||||
[Fact]
|
||||
public void Folder_rename_on_non_surgical_sink_rebuilds()
|
||||
{
|
||||
var sink = new PlainRecordingSink();
|
||||
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
||||
|
||||
var previous = CompositionWithAreas(new UnsAreaProjection("area-1", "North"));
|
||||
var next = CompositionWithAreas(new UnsAreaProjection("area-1", "South"));
|
||||
|
||||
var plan = AddressSpacePlanner.Compute(previous, next);
|
||||
plan.RenamedFolders.Count.ShouldBe(1);
|
||||
|
||||
var outcome = applier.Apply(plan);
|
||||
|
||||
outcome.RebuildCalled.ShouldBeTrue();
|
||||
sink.RebuildCalls.ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>OpcUaServer-001 fallback — when the surgical sink reports the folder MISSING
|
||||
/// (<c>UpdateFolderDisplayName</c> returns false), the applier falls back to a full rebuild. The
|
||||
/// surgical call is still attempted (recorded once) before the fallback fires.</summary>
|
||||
[Fact]
|
||||
public void Folder_rename_surgical_returning_false_falls_back_to_rebuild()
|
||||
{
|
||||
var sink = new RecordingSink { FolderRenameReturns = false };
|
||||
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
||||
|
||||
var previous = CompositionWithAreas(new UnsAreaProjection("area-1", "North"));
|
||||
var next = CompositionWithAreas(new UnsAreaProjection("area-1", "South"));
|
||||
|
||||
var plan = AddressSpacePlanner.Compute(previous, next);
|
||||
plan.RenamedFolders.Count.ShouldBe(1);
|
||||
|
||||
var outcome = applier.Apply(plan);
|
||||
|
||||
outcome.RebuildCalled.ShouldBeTrue();
|
||||
sink.RebuildCalls.ShouldBe(1); // fell back to a full rebuild
|
||||
sink.FolderRenameCalls.ShouldHaveSingleItem(); // the surgical update was attempted first
|
||||
}
|
||||
|
||||
private static AddressSpaceComposition CompositionWithAreas(params UnsAreaProjection[] areas) =>
|
||||
new(
|
||||
areas, Array.Empty<UnsLineProjection>(), Array.Empty<EquipmentNode>(),
|
||||
Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>());
|
||||
|
||||
private static AddressSpaceComposition CompositionWithLines(params UnsLineProjection[] lines) =>
|
||||
new(
|
||||
Array.Empty<UnsAreaProjection>(), lines, Array.Empty<EquipmentNode>(),
|
||||
Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>());
|
||||
|
||||
private static AddressSpaceComposition CompositionWithTags(params EquipmentTagPlan[] tags) =>
|
||||
new(
|
||||
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
|
||||
@@ -1535,6 +1673,23 @@ public sealed class AddressSpaceApplierTests
|
||||
return SurgicalReturns;
|
||||
}
|
||||
|
||||
/// <summary>Gets the queue of surgical in-place folder display-name update calls (OpcUaServer-001).</summary>
|
||||
public ConcurrentQueue<(string FolderNodeId, string DisplayName)> FolderRenameQueue { get; } = new();
|
||||
/// <summary>Gets the list of recorded surgical folder display-name update calls.</summary>
|
||||
public List<(string FolderNodeId, string DisplayName)> FolderRenameCalls => FolderRenameQueue.ToList();
|
||||
/// <summary>When false, <see cref="UpdateFolderDisplayName"/> reports the folder missing (returns
|
||||
/// false), driving the applier's rebuild fallback. Defaults to true.</summary>
|
||||
public bool FolderRenameReturns { get; init; } = true;
|
||||
|
||||
/// <summary>Records a surgical in-place folder display-name update; returns <see cref="FolderRenameReturns"/>.</summary>
|
||||
/// <param name="folderNodeId">The folder node ID to update in place.</param>
|
||||
/// <param name="displayName">The new display name to apply.</param>
|
||||
public bool UpdateFolderDisplayName(string folderNodeId, string displayName)
|
||||
{
|
||||
FolderRenameQueue.Enqueue((folderNodeId, displayName));
|
||||
return FolderRenameReturns;
|
||||
}
|
||||
|
||||
/// <summary>Gets the queue of alarm condition write calls.</summary>
|
||||
public ConcurrentQueue<(string NodeId, AlarmConditionSnapshot State)> AlarmQueue { get; } = new();
|
||||
/// <summary>Gets the queue of folder creation calls.</summary>
|
||||
|
||||
Reference in New Issue
Block a user