dd05baedd6
Nothing read the mapping table beyond the startup validator; v3 UNS projection is
explicit via UnsTagReference. Legacy keys in stored DriverConfig are ignored (pinned).
Reverts the docker-dev opc-plc seed workaround from fdee129f.
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
176 lines
7.9 KiB
C#
176 lines
7.9 KiB
C#
using Opc.Ua;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
|
|
|
|
/// <summary>
|
|
/// Regression coverage for 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: server-stable NodeId encoding ----
|
|
|
|
/// <summary>Verifies that NamespaceMap.FromSession rejects null session.</summary>
|
|
[Fact]
|
|
public void NamespaceMap_FromSession_rejects_null_session() =>
|
|
Should.Throw<ArgumentNullException>(() => NamespaceMap.FromSession(null!));
|
|
|
|
/// <summary>Verifies that namespace 0 NodeId keeps compact form.</summary>
|
|
[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=");
|
|
}
|
|
|
|
/// <summary>Verifies that nonzero namespace NodeId is encoded with URI, not index.</summary>
|
|
[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");
|
|
}
|
|
|
|
/// <summary>Verifies that unknown namespace index falls back to raw form.</summary>
|
|
[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));
|
|
}
|
|
|
|
/// <summary>Verifies that namespace index and URI lookups are bidirectional.</summary>
|
|
[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();
|
|
}
|
|
|
|
/// <summary>Verifies that NamespaceMap.TryResolve rejects empty and null input.</summary>
|
|
[Fact]
|
|
public void NamespaceMap_TryResolve_rejects_empty_and_null_input()
|
|
{
|
|
NamespaceMap.TryResolve(null!, "ns=1;s=X", out _).ShouldBeFalse();
|
|
}
|
|
|
|
// ---- v3: RawPath → upstream node id resolution table ----
|
|
|
|
/// <summary>Verifies a RawPath resolves to the upstream node id authored in its TagConfig.nodeId.</summary>
|
|
[Fact]
|
|
public void RawPath_resolves_to_upstream_nodeId_from_tagconfig()
|
|
{
|
|
var map = RawMap(
|
|
new RawTagEntry("Plant/Line1/Temp", """{"nodeId":"nsu=urn:remote:server;s=Temperature"}""", WriteIdempotent: false),
|
|
new RawTagEntry("Plant/Line1/Speed", """{"nodeId":"ns=2;s=Speed"}""", WriteIdempotent: true));
|
|
|
|
map.RawTagCount.ShouldBe(2);
|
|
map.TryResolve("Plant/Line1/Temp", out var temp).ShouldBeTrue();
|
|
temp.ShouldBe("nsu=urn:remote:server;s=Temperature");
|
|
map.TryResolve("Plant/Line1/Speed", out var speed).ShouldBeTrue();
|
|
speed.ShouldBe("ns=2;s=Speed");
|
|
}
|
|
|
|
/// <summary>Verifies an unknown RawPath and a blank RawPath both fail resolution.</summary>
|
|
[Fact]
|
|
public void RawPath_unknown_or_blank_fails_resolution()
|
|
{
|
|
var map = RawMap(new RawTagEntry("Plant/Line1/Temp", """{"nodeId":"ns=2;s=Temperature"}""", WriteIdempotent: false));
|
|
map.TryResolve("Plant/Line1/Missing", out var missing).ShouldBeFalse();
|
|
missing.ShouldBe(string.Empty);
|
|
map.TryResolve(" ", out _).ShouldBeFalse();
|
|
}
|
|
|
|
/// <summary>Verifies WriteIdempotent is threaded onto the table from the RawTagEntry, not the blob.</summary>
|
|
[Fact]
|
|
public void RawPath_binding_threads_writeidempotent_flag()
|
|
{
|
|
var map = RawMap(
|
|
new RawTagEntry("Plant/A", """{"nodeId":"ns=2;s=A"}""", WriteIdempotent: true),
|
|
new RawTagEntry("Plant/B", """{"nodeId":"ns=2;s=B"}""", WriteIdempotent: false));
|
|
|
|
map.TryGetBinding("Plant/A", out var a).ShouldBeTrue();
|
|
a.NodeId.ShouldBe("ns=2;s=A");
|
|
a.WriteIdempotent.ShouldBeTrue();
|
|
map.TryGetBinding("Plant/B", out var b).ShouldBeTrue();
|
|
b.WriteIdempotent.ShouldBeFalse();
|
|
}
|
|
|
|
/// <summary>Verifies an entry whose TagConfig lacks a usable nodeId is skipped (not registered).</summary>
|
|
[Fact]
|
|
public void RawPath_entry_without_nodeId_is_skipped()
|
|
{
|
|
var map = RawMap(
|
|
new RawTagEntry("Plant/Good", """{"nodeId":"ns=2;s=Good"}""", WriteIdempotent: false),
|
|
new RawTagEntry("Plant/NoNode", """{"other":"value"}""", WriteIdempotent: false),
|
|
new RawTagEntry("Plant/Blank", """{"nodeId":" "}""", WriteIdempotent: false),
|
|
new RawTagEntry("Plant/Garbage", "not json", WriteIdempotent: false));
|
|
|
|
map.RawTagCount.ShouldBe(1);
|
|
map.TryResolve("Plant/Good", out _).ShouldBeTrue();
|
|
map.TryResolve("Plant/NoNode", out _).ShouldBeFalse();
|
|
map.TryResolve("Plant/Blank", out _).ShouldBeFalse();
|
|
map.TryResolve("Plant/Garbage", out _).ShouldBeFalse();
|
|
}
|
|
|
|
/// <summary>Verifies a PascalCase "NodeId" key is accepted (legacy tolerance).</summary>
|
|
[Fact]
|
|
public void RawPath_accepts_legacy_pascalcase_nodeid_key()
|
|
{
|
|
var map = RawMap(new RawTagEntry("Plant/Legacy", """{"NodeId":"ns=2;s=Legacy"}""", WriteIdempotent: false));
|
|
map.TryResolve("Plant/Legacy", out var nid).ShouldBeTrue();
|
|
nid.ShouldBe("ns=2;s=Legacy");
|
|
}
|
|
|
|
/// <summary>Verifies a map built without raw tags has an empty resolution table.</summary>
|
|
[Fact]
|
|
public void Map_without_rawtags_has_empty_resolution_table()
|
|
{
|
|
var map = TestMap("http://opcfoundation.org/UA/", "urn:remote:server");
|
|
map.RawTagCount.ShouldBe(0);
|
|
map.TryResolve("anything", 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);
|
|
}
|
|
|
|
/// <summary>Build a <see cref="NamespaceMap"/> carrying only the v3 RawPath table (core URI table).</summary>
|
|
private static NamespaceMap RawMap(params RawTagEntry[] rawTags) =>
|
|
NamespaceMap.FromTable(new NamespaceTable(), rawTags);
|
|
}
|