fix(opcua): equipment-tag planner diff + folder-scoped NodeIds (review findings)

Two bundle-review fixes + idempotency coverage:
- CRITICAL: the planner ignored EquipmentTags, so an incremental deploy changing only
  equipment tags produced an empty plan and HandleRebuild short-circuited before
  materialising them. Add TagId to EquipmentTagPlan + Added/Removed/ChangedEquipmentTags
  to Phase7Plan (diffed by TagId, in IsEmpty, driving Apply's needsRebuild) — mirroring
  the GalaxyTags treatment.
- IMPORTANT: equipment variable NodeId was the raw driver FullName, which collides across
  identical machines (e.g. two PLCs both exposing register 40001) — the second variable
  was silently dropped. NodeId is now folder-scoped (parent/Name); FullName stays on
  EquipmentTagPlan for the later values-routing milestone.
- Task 4: SDK-backed idempotency test (double-apply -> single variable); restart-safety
  confirmed (RestoreApplied reuses the same RebuildAddressSpace -> HandleRebuild path).
- Minor: align composer equipment-tag sort with the artifact decoder (coalesce FolderPath).
This commit is contained in:
Joseph Doherty
2026-06-06 15:02:50 -04:00
parent 08cddfe128
commit aaf869145a
9 changed files with 192 additions and 28 deletions
@@ -110,6 +110,54 @@ public sealed class Phase7ApplierHierarchyTests : IDisposable
sdkServer.NodeManager!.FolderCount.ShouldBe(5);
}
/// <summary>Verifies MaterialiseEquipmentTags is idempotent against a real SDK node manager:
/// applying the same composition twice yields a single Variable node (no duplicates). This is the
/// restart-safety guarantee — HandleRebuild runs on both the apply path and the
/// DriverHostActor.RestoreApplied bootstrap path (same RebuildAddressSpace message), so a node
/// restart re-runs this pass and must not double-materialise.</summary>
[Fact]
public async Task MaterialiseEquipmentTags_against_real_SDK_node_manager_is_idempotent()
{
await using var host = new OpcUaApplicationHost(
new OpcUaApplicationHostOptions
{
ApplicationName = "OtOpcUa.EquipmentTags",
ApplicationUri = $"urn:OtOpcUa.EquipmentTags:{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 sink = new SdkAddressSpaceSink(sdkServer.NodeManager!);
var applier = new Phase7Applier(sink, NullLogger<Phase7Applier>.Instance);
var composition = new Phase7CompositionResult(
UnsAreas: Array.Empty<UnsAreaProjection>(),
UnsLines: Array.Empty<UnsLineProjection>(),
EquipmentNodes: new[] { new EquipmentNode("eq-1", "filling-eq", UnsLineId: "") },
DriverInstancePlans: Array.Empty<DriverInstancePlan>(),
ScriptedAlarmPlans: Array.Empty<ScriptedAlarmPlan>(),
GalaxyTags: Array.Empty<GalaxyTagPlan>())
{
EquipmentTags = new[]
{
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001"),
},
};
// Equipment folder first (the variable's parent), then the tag pass — applied twice.
applier.MaterialiseHierarchy(composition);
applier.MaterialiseEquipmentTags(composition);
applier.MaterialiseEquipmentTags(composition);
sdkServer.NodeManager!.VariableCount.ShouldBe(1); // single variable despite the double-apply
}
private static int AllocateFreePort()
{
using var listener = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Loopback, 0);