feat(otopcua): applier pass to materialise discovered nodes idempotently

This commit is contained in:
Joseph Doherty
2026-06-26 07:16:36 -04:00
parent f8406d348c
commit 598cdfad5a
11 changed files with 191 additions and 0 deletions
@@ -333,5 +333,7 @@ public sealed class AddressSpaceApplierHierarchyTests : IDisposable
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) { }
/// <summary>Rebuilds the address space (stub implementation for testing).</summary>
public void RebuildAddressSpace() { }
/// <summary>Announces a NodeAdded model-change (stub implementation for testing).</summary>
public void RaiseNodesAddedModelChange(string affectedNodeId) { }
}
}
@@ -567,6 +567,114 @@ public sealed class AddressSpaceApplierTests
.ShouldBe(("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", 700, false));
}
/// <summary>Task 4 — MaterialiseDiscoveredNodes ensures the discovered folders PARENT-FIRST (ordered by
/// depth = '/' count) and the discovered variables at their folder-scoped NodeIds/parents, with variables
/// created READ-ONLY (writable == false), then raises EXACTLY ONE NodeAdded model-change under the
/// equipment root. Folders are passed in REVERSE (child-first) to prove the applier re-orders them
/// parent-first before ensuring (a child folder's parent must exist first).</summary>
[Fact]
public void MaterialiseDiscoveredNodes_ensures_folders_parent_first_read_only_variables_and_raises_model_change_once()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
// Child folder listed BEFORE its parent — the applier must re-order parent-first.
var folders = new[]
{
new DiscoveredFolder("EQ-1/FOCAS/Identity", "EQ-1/FOCAS", "Identity"),
new DiscoveredFolder("EQ-1/FOCAS", "EQ-1", "FOCAS"),
};
var variables = new[]
{
new DiscoveredVariable("EQ-1/FOCAS/Identity/SeriesNumber", "EQ-1/FOCAS/Identity", "SeriesNumber",
"String", Writable: false, IsArray: false, ArrayLength: null),
};
applier.MaterialiseDiscoveredNodes("EQ-1", folders, variables);
// Folders ensured parent-first regardless of input order (shallowest depth first).
sink.FolderCalls.Select(f => f.NodeId).ShouldBe(new[] { "EQ-1/FOCAS", "EQ-1/FOCAS/Identity" });
sink.FolderCalls.ShouldContain(("EQ-1/FOCAS", "EQ-1", "FOCAS"));
sink.FolderCalls.ShouldContain(("EQ-1/FOCAS/Identity", "EQ-1/FOCAS", "Identity"));
// Variable ensured at its folder-scoped NodeId, parented to its sub-folder, READ-ONLY.
sink.VariableCalls.ShouldHaveSingleItem()
.ShouldBe(("EQ-1/FOCAS/Identity/SeriesNumber", "EQ-1/FOCAS/Identity", "SeriesNumber", "String", false));
// Exactly one NodeAdded model-change, announced under the equipment root.
sink.ModelChangeCalls.ShouldHaveSingleItem().ShouldBe("EQ-1");
}
/// <summary>Task 4 — a discovered array variable (rare) authored <c>Writable: true</c> is forced
/// READ-ONLY (mirrors MaterialiseEquipmentTags: the driver write path can't handle arrays), while the
/// IsArray / ArrayLength flags are forwarded verbatim to the sink.</summary>
[Fact]
public void MaterialiseDiscoveredNodes_array_variable_is_forced_read_only()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var variables = new[]
{
new DiscoveredVariable("EQ-1/FOCAS/Buffer", "EQ-1", "Buffer", "Int16",
Writable: true, IsArray: true, ArrayLength: 8u),
};
applier.MaterialiseDiscoveredNodes("EQ-1", Array.Empty<DiscoveredFolder>(), variables);
var varCall = sink.VariableCalls.ShouldHaveSingleItem();
varCall.Writable.ShouldBeFalse(); // clamped to read-only despite Writable: true
var arrCall = sink.ArrayCalls.ShouldHaveSingleItem();
arrCall.IsArray.ShouldBeTrue();
arrCall.ArrayLength.ShouldBe(8u);
}
/// <summary>Task 4 — re-applying the SAME discovered plan is idempotent-SAFE: it does not throw, the
/// distinct folder/variable set the applier issues per pass is stable (the real sink early-returns on
/// existing nodes), and a model-change is raised once PER call (twice across two calls).</summary>
[Fact]
public void MaterialiseDiscoveredNodes_is_idempotent_safe_on_repeated_application()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var folders = new[]
{
new DiscoveredFolder("EQ-1/FOCAS", "EQ-1", "FOCAS"),
new DiscoveredFolder("EQ-1/FOCAS/Identity", "EQ-1/FOCAS", "Identity"),
};
var variables = new[]
{
new DiscoveredVariable("EQ-1/FOCAS/Identity/SeriesNumber", "EQ-1/FOCAS/Identity", "SeriesNumber",
"String", Writable: false, IsArray: false, ArrayLength: null),
};
applier.MaterialiseDiscoveredNodes("EQ-1", folders, variables);
Should.NotThrow(() => applier.MaterialiseDiscoveredNodes("EQ-1", folders, variables));
// Each pass re-issues the same parent-first ensures (the real sink dedups via early-return); the
// DISTINCT set the applier produces is stable across re-applies.
sink.FolderCalls.Select(f => f.NodeId).Distinct().ShouldBe(new[] { "EQ-1/FOCAS", "EQ-1/FOCAS/Identity" });
sink.VariableCalls.Select(v => v.NodeId).Distinct().ShouldBe(new[] { "EQ-1/FOCAS/Identity/SeriesNumber" });
// One model-change per call ⇒ two across two calls.
sink.ModelChangeCalls.ShouldBe(new[] { "EQ-1", "EQ-1" });
}
/// <summary>Task 4 — empty input (no folders, no variables) returns WITHOUT touching the sink: no
/// EnsureFolder/EnsureVariable and, crucially, NO NodeAdded model-change.</summary>
[Fact]
public void MaterialiseDiscoveredNodes_empty_input_does_not_touch_sink()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
applier.MaterialiseDiscoveredNodes("EQ-1", Array.Empty<DiscoveredFolder>(), Array.Empty<DiscoveredVariable>());
sink.FolderCalls.ShouldBeEmpty();
sink.VariableCalls.ShouldBeEmpty();
sink.ModelChangeCalls.ShouldBeEmpty();
}
/// <summary>Verifies that added equipment tags in an otherwise-empty plan trigger an
/// address-space rebuild (the planner now diffs equipment tags, so a tags-only deploy is no
/// longer a silent no-op).</summary>
@@ -1761,6 +1869,14 @@ public sealed class AddressSpaceApplierTests
}
/// <summary>Records a rebuild address space call.</summary>
public void RebuildAddressSpace() => Interlocked.Increment(ref RebuildCalls);
/// <summary>Gets the queue of NodeAdded model-change announcements (discovered-node injection).</summary>
public ConcurrentQueue<string> ModelChangeQueue { get; } = new();
/// <summary>Gets the list of recorded NodeAdded model-change announcements (discovered-node injection).</summary>
public List<string> ModelChangeCalls => ModelChangeQueue.ToList();
/// <summary>Records a NodeAdded model-change announcement.</summary>
/// <param name="affectedNodeId">The node under which discovered nodes were added.</param>
public void RaiseNodesAddedModelChange(string affectedNodeId) => ModelChangeQueue.Enqueue(affectedNodeId);
}
/// <summary>A recording sink that does NOT implement <see cref="ISurgicalAddressSpaceSink"/> — used to
@@ -1783,6 +1899,8 @@ public sealed class AddressSpaceApplierTests
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) { }
/// <summary>Records a rebuild address space call.</summary>
public void RebuildAddressSpace() => Interlocked.Increment(ref RebuildCalls);
/// <summary>No-op NodeAdded model-change announcement.</summary>
public void RaiseNodesAddedModelChange(string affectedNodeId) { }
}
private sealed class ThrowingSink : IOpcUaAddressSpaceSink
@@ -1829,5 +1947,7 @@ public sealed class AddressSpaceApplierTests
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) { }
/// <summary>No-op rebuild address space call.</summary>
public void RebuildAddressSpace() { }
/// <summary>No-op NodeAdded model-change announcement.</summary>
public void RaiseNodesAddedModelChange(string affectedNodeId) { }
}
}
@@ -212,6 +212,8 @@ public sealed class DeferredAddressSpaceSinkTests
}
/// <inheritdoc />
public void RebuildAddressSpace() => CallQueue.Enqueue("RB");
/// <inheritdoc />
public void RaiseNodesAddedModelChange(string affectedNodeId) => CallQueue.Enqueue($"NA:{affectedNodeId}");
}
private sealed class SurgicalRecordingSink : IOpcUaAddressSpaceSink, ISurgicalAddressSpaceSink
@@ -249,5 +251,7 @@ public sealed class DeferredAddressSpaceSinkTests
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) { }
/// <inheritdoc />
public void RebuildAddressSpace() { }
/// <inheritdoc />
public void RaiseNodesAddedModelChange(string affectedNodeId) { }
}
}
@@ -222,5 +222,7 @@ public sealed class OtOpcUaTelemetryHookTests : RuntimeActorTestBase
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) { }
/// <summary>Rebuilds address space (recorded via span).</summary>
public void RebuildAddressSpace() { /* recorded via span */ }
/// <summary>Announces a NodeAdded model-change (stub implementation).</summary>
public void RaiseNodesAddedModelChange(string affectedNodeId) { }
}
}
@@ -361,6 +361,9 @@ 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 NodeAdded model-change announcement.</summary>
/// <param name="affectedNodeId">The node under which discovered nodes were added.</param>
public void RaiseNodesAddedModelChange(string affectedNodeId) => Calls.Enqueue($"NA:{affectedNodeId}");
/// <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)
{
@@ -596,6 +596,10 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase
/// <summary>Records a rebuild call.</summary>
public void RebuildAddressSpace() => Interlocked.Increment(ref RebuildCalls);
/// <summary>Announces a NodeAdded model-change (no-op in test).</summary>
/// <param name="affectedNodeId">The node under which discovered nodes were added.</param>
public void RaiseNodesAddedModelChange(string affectedNodeId) { }
}
/// <summary>Test implementation of IServiceLevelPublisher that records publishes.</summary>