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:
@@ -90,6 +90,30 @@ public class DeferredAddressSpaceSinkTests
|
||||
surgical.UpdateCalled.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateFolderDisplayName_returns_false_for_non_surgical_inner()
|
||||
{
|
||||
// SpySink does NOT implement ISurgicalAddressSpaceSink.
|
||||
var sink = new DeferredAddressSpaceSink();
|
||||
sink.SetSink(new SpySink());
|
||||
|
||||
sink.UpdateFolderDisplayName("area-1", "Plant South").ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateFolderDisplayName_returns_true_for_surgical_inner()
|
||||
{
|
||||
// OpcUaServer-001: the deferred wrapper must forward the folder-rename capability to a surgical inner.
|
||||
var surgical = new SpySurgicalSink();
|
||||
var sink = new DeferredAddressSpaceSink();
|
||||
sink.SetSink(surgical);
|
||||
|
||||
var result = sink.UpdateFolderDisplayName("area-1", "Plant South");
|
||||
|
||||
result.ShouldBeTrue();
|
||||
surgical.FolderRenameCalled.ShouldBeTrue();
|
||||
}
|
||||
|
||||
// ---------- SetSink(null) reverts to null sink ----------
|
||||
|
||||
[Fact]
|
||||
@@ -137,6 +161,7 @@ public class DeferredAddressSpaceSinkTests
|
||||
private sealed class SpySurgicalSink : IOpcUaAddressSpaceSink, ISurgicalAddressSpaceSink
|
||||
{
|
||||
public bool UpdateCalled { get; private set; }
|
||||
public bool FolderRenameCalled { get; private set; }
|
||||
|
||||
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc) { }
|
||||
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc) { }
|
||||
@@ -150,5 +175,11 @@ public class DeferredAddressSpaceSinkTests
|
||||
UpdateCalled = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool UpdateFolderDisplayName(string folderNodeId, string displayName)
|
||||
{
|
||||
FolderRenameCalled = true;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -210,6 +210,70 @@ public sealed class AddressSpaceApplierHierarchyTests : IDisposable
|
||||
sdkServer.NodeManager!.VariableCount.ShouldBe(1); // the Speed signal under the equipment folder
|
||||
}
|
||||
|
||||
/// <summary>OpcUaServer-001 — a UNS Area / Line rename-only deploy refreshes the EXISTING folder's
|
||||
/// DisplayName IN PLACE against a real SDK node manager (no rebuild, no node count change). Proves the
|
||||
/// full chain: planner emits a RenamedFolders delta → applier drives the surgical
|
||||
/// SdkAddressSpaceSink.UpdateFolderDisplayName → OtOpcUaNodeManager mutates the live FolderState +
|
||||
/// ClearChangeMasks. Before the fix, EnsureFolder early-returned on the existing folder and the
|
||||
/// DisplayName stayed stale until a full RebuildAddressSpace.</summary>
|
||||
[Fact]
|
||||
public async Task Area_and_line_rename_updates_existing_folder_display_name_in_place_against_real_SDK()
|
||||
{
|
||||
await using var host = new OpcUaApplicationHost(
|
||||
new OpcUaApplicationHostOptions
|
||||
{
|
||||
ApplicationName = "OtOpcUa.FolderRename",
|
||||
ApplicationUri = $"urn:OtOpcUa.FolderRename:{Guid.NewGuid():N}",
|
||||
OpcUaPort = AllocateFreePort(),
|
||||
PublicHostname = "localhost",
|
||||
PkiStoreRoot = _pkiRoot,
|
||||
},
|
||||
NullLogger<OpcUaApplicationHost>.Instance);
|
||||
|
||||
var sdkServer = new OtOpcUaSdkServer();
|
||||
await host.StartAsync(sdkServer, Ct);
|
||||
sdkServer.NodeManager.ShouldNotBeNull();
|
||||
var nm = sdkServer.NodeManager!;
|
||||
|
||||
var sink = new SdkAddressSpaceSink(nm);
|
||||
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
||||
|
||||
// Materialise the initial hierarchy (area + line + equipment) with the OLD display names.
|
||||
var initial = new AddressSpaceComposition(
|
||||
UnsAreas: new[] { new UnsAreaProjection("area-1", "Plant North") },
|
||||
UnsLines: new[] { new UnsLineProjection("line-1", "area-1", "Cell A") },
|
||||
EquipmentNodes: new[] { new EquipmentNode("eq-1", "Pump-1", "line-1") },
|
||||
DriverInstancePlans: Array.Empty<DriverInstancePlan>(),
|
||||
ScriptedAlarmPlans: Array.Empty<ScriptedAlarmPlan>());
|
||||
applier.MaterialiseHierarchy(initial);
|
||||
|
||||
nm.FolderCount.ShouldBe(3);
|
||||
nm.TryGetFolder("area-1")!.DisplayName.Text.ShouldBe("Plant North");
|
||||
nm.TryGetFolder("line-1")!.DisplayName.Text.ShouldBe("Cell A");
|
||||
|
||||
// Now a rename-only deploy: same ids, new display names on BOTH the area and the line.
|
||||
var renamed = new AddressSpaceComposition(
|
||||
UnsAreas: new[] { new UnsAreaProjection("area-1", "Plant South") },
|
||||
UnsLines: new[] { new UnsLineProjection("line-1", "area-1", "Cell B") },
|
||||
EquipmentNodes: new[] { new EquipmentNode("eq-1", "Pump-1", "line-1") },
|
||||
DriverInstancePlans: Array.Empty<DriverInstancePlan>(),
|
||||
ScriptedAlarmPlans: Array.Empty<ScriptedAlarmPlan>());
|
||||
|
||||
var plan = AddressSpacePlanner.Compute(initial, renamed);
|
||||
plan.IsEmpty.ShouldBeFalse(); // rename is no longer a silent no-op
|
||||
plan.RenamedFolders.Count.ShouldBe(2);
|
||||
|
||||
var outcome = applier.Apply(plan);
|
||||
|
||||
// In-place: no rebuild, no node-count change — only the DisplayNames were swapped.
|
||||
outcome.RebuildCalled.ShouldBeFalse();
|
||||
nm.FolderCount.ShouldBe(3);
|
||||
nm.TryGetFolder("area-1")!.DisplayName.Text.ShouldBe("Plant South");
|
||||
nm.TryGetFolder("line-1")!.DisplayName.Text.ShouldBe("Cell B");
|
||||
// The unrelated equipment folder is untouched.
|
||||
nm.TryGetFolder("eq-1")!.DisplayName.Text.ShouldBe("Pump-1");
|
||||
}
|
||||
|
||||
private static int AllocateFreePort()
|
||||
{
|
||||
using var listener = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Loopback, 0);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 />
|
||||
|
||||
+83
-1
@@ -249,12 +249,80 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
|
||||
ctx.SaveChanges();
|
||||
}
|
||||
|
||||
private sealed class RecordingSink : IOpcUaAddressSpaceSink
|
||||
/// <summary>OpcUaServer-001 — a deploy whose ONLY change is a UNS Area rename (the area Name differs;
|
||||
/// no equipment/driver/alarm/tag delta) must NOT short-circuit at the actor's IsEmpty gate: the second
|
||||
/// RebuildAddressSpace drives the in-place folder display-name refresh (a surgical
|
||||
/// <c>UpdateFolderDisplayName</c> call) without a full rebuild. Proves the planner→applier→sink chain
|
||||
/// reaches the apply path through the actor for a rename-only redeploy.</summary>
|
||||
[Fact]
|
||||
public void Rebuild_with_area_rename_only_updates_folder_in_place_without_rebuild()
|
||||
{
|
||||
var db = NewInMemoryDbFactory();
|
||||
var sink = new RecordingSink();
|
||||
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
||||
var dep1 = SeedAreaDeployment(db, areaName: "Plant North");
|
||||
|
||||
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(
|
||||
sink: sink, dbFactory: db, applier: applier));
|
||||
|
||||
// First deploy: the area folder is materialised with the OLD name (one rebuild).
|
||||
actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep1)));
|
||||
AwaitAssert(() => sink.RebuildCalls.ShouldBe(1), duration: TimeSpan.FromSeconds(2));
|
||||
|
||||
// Second deploy: ONLY the area Name changed — a rename. The actor must reach the apply path and
|
||||
// drive a surgical in-place folder rename (NOT a rebuild, NOT a no-op).
|
||||
var dep2 = SeedAreaDeployment(db, areaName: "Plant South");
|
||||
actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep2)));
|
||||
|
||||
AwaitAssert(() =>
|
||||
{
|
||||
sink.FolderRenameCalls.ShouldContain(("area-1", "Plant South"));
|
||||
}, duration: TimeSpan.FromSeconds(2));
|
||||
sink.RebuildCalls.ShouldBe(1); // still 1 — the rename did NOT force a second full rebuild
|
||||
}
|
||||
|
||||
/// <summary>Seal a deployment whose area carries <paramref name="areaName"/>, with a line + one
|
||||
/// equipment under it. The equipment makes the FIRST deploy a structural rebuild (so the area folder is
|
||||
/// materialised); a later redeploy that changes ONLY the area Name is then a pure rename. Returns the
|
||||
/// new DeploymentId so the test can target THAT artifact (apply-time, not-yet-sealed semantics).</summary>
|
||||
private static Guid SeedAreaDeployment(IDbContextFactory<OtOpcUaConfigDbContext> dbFactory, string areaName)
|
||||
{
|
||||
var artifact = JsonSerializer.SerializeToUtf8Bytes(new
|
||||
{
|
||||
UnsAreas = new[] { new { UnsAreaId = "area-1", ClusterId = "c1", Name = areaName } },
|
||||
UnsLines = new[] { new { UnsLineId = "line-1", UnsAreaId = "area-1", Name = "Cell A" } },
|
||||
Equipment = new[] { new { EquipmentId = "eq-1", DriverInstanceId = (string?)null, UnsLineId = "line-1", Name = "Pump-1", MachineCode = "EQ-1" } },
|
||||
DriverInstances = Array.Empty<object>(),
|
||||
Namespaces = Array.Empty<object>(),
|
||||
Tags = Array.Empty<object>(),
|
||||
ScriptedAlarms = Array.Empty<object>(),
|
||||
});
|
||||
|
||||
var id = Guid.NewGuid();
|
||||
using var ctx = dbFactory.CreateDbContext();
|
||||
ctx.Deployments.Add(new Deployment
|
||||
{
|
||||
DeploymentId = id,
|
||||
RevisionHash = new string('c', 64),
|
||||
Status = DeploymentStatus.Sealed,
|
||||
CreatedBy = "test",
|
||||
SealedAtUtc = DateTime.UtcNow,
|
||||
ArtifactBlob = artifact,
|
||||
});
|
||||
ctx.SaveChanges();
|
||||
return id;
|
||||
}
|
||||
|
||||
private sealed class RecordingSink : IOpcUaAddressSpaceSink, ISurgicalAddressSpaceSink
|
||||
{
|
||||
/// <summary>Gets the list of recorded sink calls.</summary>
|
||||
public ConcurrentQueue<string> Calls { get; } = new();
|
||||
/// <summary>Gets or sets the count of rebuild address space calls.</summary>
|
||||
public int RebuildCalls;
|
||||
/// <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>Records a value write call.</summary>
|
||||
/// <param name="nodeId">The OPC UA node ID.</param>
|
||||
/// <param name="value">The value to write.</param>
|
||||
@@ -293,5 +361,19 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
|
||||
=> Calls.Enqueue($"EV:{variableNodeId}");
|
||||
/// <summary>Records a rebuild address space call.</summary>
|
||||
public void RebuildAddressSpace() => Interlocked.Increment(ref RebuildCalls);
|
||||
/// <summary>Records a surgical in-place tag-attribute update (always succeeds in this recording sink).</summary>
|
||||
public bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname, string dataType, bool isArray, uint? arrayLength)
|
||||
{
|
||||
Calls.Enqueue($"UT:{variableNodeId}");
|
||||
return true;
|
||||
}
|
||||
/// <summary>Records a surgical in-place folder display-name update (always succeeds in this recording sink).</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 true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user