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
@@ -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;
}
}
}