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