|
|
|
@@ -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) { }
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|