Driver.OpcUaClient-001 — ReadAsync/WriteAsync/DiscoverAsync captured the session before acquiring _gate, so a reconnect that completed while the operation was blocked on the gate left the wire call bound to a stale, closed session. All three now re-read Session (and parse NodeIds) inside the _gate critical section after WaitAsync returns. Driver.OpcUaClient-002 — OnReconnectComplete ignored the give-up (null session) case, permanently wedging the driver with no Faulted signal and no reconnect loop. The give-up branch now transitions HostState to Faulted, sets a Faulted DriverHealth with an explanatory message, and re-arms a fresh SessionReconnectHandler (TryRearmReconnect) against the last-known session so an always-on gateway self-heals. Driver.OpcUaClient-003 — BrowseRecursiveAsync discarded browse continuation points, silently truncating large remote folders. It now loops on BrowseResult.ContinuationPoint calling BrowseNextAsync and appending each page until the continuation point is empty. Driver.OpcUaClient-004 — driver-specs.md §8 namespace handling was absent. Added NamespaceMap (built from session.NamespaceUris at connect, rebuilt on reconnect) which persists discovered NodeIds in the server-stable nsu=<uri>;... form; reads/writes re-resolve that form against the current session so a remote namespace-table reorder no longer misaddresses nodes. Added the TargetNamespaceKind option + UnsMappingTable and ValidateNamespaceKind startup enforcement. Driver.OpcUaClient-005 — OnKeepAlive read/wrote _reconnectHandler without a lock, racing the SDK keep-alive timer thread and leaking handlers. The check-and-set in OnKeepAlive, the take-and-clear in ShutdownAsync, and the dispose/re-arm in OnReconnectComplete now all run inside the _probeLock critical section. Adds OpcUaClientNamespaceTests (11 xUnit + Shouldly regression tests) covering ValidateNamespaceKind and the NamespaceMap stable encoding. Reconnect/browse wire paths remain fixture-gated per finding -015. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
140 lines
5.8 KiB
C#
140 lines
5.8 KiB
C#
using Opc.Ua;
|
|
using Shouldly;
|
|
using Xunit;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
|
|
|
|
/// <summary>
|
|
/// Regression coverage for driver-specs.md §8 namespace handling — the
|
|
/// <c>TargetNamespaceKind</c> startup enforcement and the server-stable NodeId encoding
|
|
/// that <see cref="NamespaceMap"/> produces (findings Driver.OpcUaClient-004).
|
|
/// </summary>
|
|
[Trait("Category", "Unit")]
|
|
public sealed class OpcUaClientNamespaceTests
|
|
{
|
|
// ---- Driver.OpcUaClient-004: TargetNamespaceKind startup enforcement ----
|
|
|
|
[Fact]
|
|
public void ValidateNamespaceKind_Equipment_without_mapping_table_is_rejected()
|
|
{
|
|
// §8: an Equipment-kind instance gateways raw equipment data and MUST carry a
|
|
// config-driven UNS mapping table — remote nodes don't conform to UNS by default.
|
|
var opts = new OpcUaClientDriverOptions { TargetNamespaceKind = OpcUaTargetNamespaceKind.Equipment };
|
|
Should.Throw<InvalidOperationException>(() => OpcUaClientDriver.ValidateNamespaceKind(opts))
|
|
.Message.ShouldContain("UnsMappingTable");
|
|
}
|
|
|
|
[Fact]
|
|
public void ValidateNamespaceKind_Equipment_with_mapping_table_passes()
|
|
{
|
|
var opts = new OpcUaClientDriverOptions
|
|
{
|
|
TargetNamespaceKind = OpcUaTargetNamespaceKind.Equipment,
|
|
UnsMappingTable = new Dictionary<string, string> { ["Line1"] = "Site/AreaA/Line1" },
|
|
};
|
|
Should.NotThrow(() => OpcUaClientDriver.ValidateNamespaceKind(opts));
|
|
}
|
|
|
|
[Fact]
|
|
public void ValidateNamespaceKind_SystemPlatform_with_mapping_table_is_rejected()
|
|
{
|
|
// §8: processed data preserves its own hierarchy — a UNS mapping table is ambiguous.
|
|
var opts = new OpcUaClientDriverOptions
|
|
{
|
|
TargetNamespaceKind = OpcUaTargetNamespaceKind.SystemPlatform,
|
|
UnsMappingTable = new Dictionary<string, string> { ["x"] = "y" },
|
|
};
|
|
Should.Throw<InvalidOperationException>(() => OpcUaClientDriver.ValidateNamespaceKind(opts))
|
|
.Message.ShouldContain("SystemPlatform");
|
|
}
|
|
|
|
[Fact]
|
|
public void ValidateNamespaceKind_SystemPlatform_without_mapping_table_passes()
|
|
{
|
|
var opts = new OpcUaClientDriverOptions { TargetNamespaceKind = OpcUaTargetNamespaceKind.SystemPlatform };
|
|
Should.NotThrow(() => OpcUaClientDriver.ValidateNamespaceKind(opts));
|
|
}
|
|
|
|
[Fact]
|
|
public void Default_TargetNamespaceKind_is_Equipment()
|
|
{
|
|
// §8 comparison table: OPC UA Client default lane is Equipment.
|
|
new OpcUaClientDriverOptions().TargetNamespaceKind.ShouldBe(OpcUaTargetNamespaceKind.Equipment);
|
|
}
|
|
|
|
// ---- Driver.OpcUaClient-004: server-stable NodeId encoding ----
|
|
|
|
[Fact]
|
|
public void NamespaceMap_FromSession_rejects_null_session() =>
|
|
Should.Throw<ArgumentNullException>(() => NamespaceMap.FromSession(null!));
|
|
|
|
[Fact]
|
|
public void NamespaceMap_namespace0_NodeId_keeps_compact_form()
|
|
{
|
|
// Namespace 0 is fixed by the OPC UA spec — it never moves, so the compact i=… form
|
|
// is already server-stable and must not be rewritten to nsu=…
|
|
var map = TestMap("http://opcfoundation.org/UA/", "urn:remote:server");
|
|
var coreNode = new NodeId(2253u); // i=2253, Server object, namespace 0
|
|
map.ToStableReference(coreNode).ShouldBe(coreNode.ToString());
|
|
map.ToStableReference(coreNode).ShouldNotContain("nsu=");
|
|
}
|
|
|
|
[Fact]
|
|
public void NamespaceMap_nonzero_namespace_NodeId_is_encoded_with_uri_not_index()
|
|
{
|
|
// A ns=1 reference is session-relative; if the remote reorders its table the index
|
|
// points at the wrong namespace. ToStableReference must embed the URI instead.
|
|
var map = TestMap("http://opcfoundation.org/UA/", "urn:remote:server");
|
|
var node = new NodeId("Temperature", 1);
|
|
var stable = map.ToStableReference(node);
|
|
stable.ShouldContain("nsu=urn:remote:server");
|
|
stable.ShouldContain("s=Temperature");
|
|
stable.ShouldNotStartWith("ns=1");
|
|
}
|
|
|
|
[Fact]
|
|
public void NamespaceMap_unknown_namespace_index_falls_back_to_raw_form()
|
|
{
|
|
// An index past the captured table can't be URI-encoded; fall back rather than throw —
|
|
// the read/write path surfaces BadNodeIdInvalid if it truly can't resolve.
|
|
var map = TestMap("http://opcfoundation.org/UA/");
|
|
var node = new NodeId("Orphan", 5);
|
|
Should.NotThrow(() => map.ToStableReference(node));
|
|
}
|
|
|
|
[Fact]
|
|
public void NamespaceMap_index_and_uri_lookups_are_bidirectional()
|
|
{
|
|
var map = TestMap("http://opcfoundation.org/UA/", "urn:remote:server", "urn:remote:vendor");
|
|
map.Count.ShouldBe(3);
|
|
map.UriForIndex(1).ShouldBe("urn:remote:server");
|
|
map.IndexForUri("urn:remote:vendor").ShouldBe((ushort)2);
|
|
map.IndexForUri("urn:not:present").ShouldBeNull();
|
|
map.UriForIndex(99).ShouldBeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public void NamespaceMap_TryResolve_rejects_empty_and_null_input()
|
|
{
|
|
NamespaceMap.TryResolve(null!, "ns=1;s=X", out _).ShouldBeFalse();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Build a <see cref="NamespaceMap"/> directly from a URI table, mirroring what
|
|
/// <see cref="NamespaceMap.FromSession"/> snapshots — lets the encoding be tested
|
|
/// without standing up a live <c>ISession</c>.
|
|
/// </summary>
|
|
private static NamespaceMap TestMap(params string[] uris)
|
|
{
|
|
var table = new NamespaceTable();
|
|
// Index 0 (the OPC UA core namespace) is seeded by NamespaceTable's ctor; append the
|
|
// rest. Skip uris[0] when it is the core namespace to avoid a duplicate.
|
|
for (var i = 0; i < uris.Length; i++)
|
|
{
|
|
if (i == 0 && uris[i] == "http://opcfoundation.org/UA/") continue;
|
|
table.Append(uris[i]);
|
|
}
|
|
return NamespaceMap.FromTable(table);
|
|
}
|
|
}
|