v3(b1-opcuaclient): re-key OpcUaClient resolution to RawPath via RawTags

NamespaceMap now carries a RawPath -> upstream node id table built from the
deployed RawTagEntry list (reads each tag's TagConfig.nodeId, threads
WriteIdempotent). Under v3 the read/write/subscribe/history reference handed to
the driver is the tag's RawPath identity; the driver resolves it in two stages:
RawPath -> nodeId string (instance TryResolve) -> live NodeId re-bound against
the session (static TryResolve). Alarm ConditionId + event-history sourceName
stay on the direct session parse (they are upstream node ids, not RawPaths).

- Options: add IReadOnlyList<RawTagEntry> RawTags (Contracts now refs Core.Abstractions).
- Factory: RawTags binds straight into options (no separate DTO); EndpointUrl kept.
- Browser: unchanged (emits neutral BrowseNode DTOs, no TagConfig FullName key).
- Tests: 138 green; new RawPath resolution + factory-binding coverage; migrated
  the stale-session ReadRaw test to author a resolving RawTag.

Contracts + Driver + Browser build clean (0 warn). Wave C wires endpoint->DeviceConfig
and the deploy artifact that populates RawTags.
This commit is contained in:
Joseph Doherty
2026-07-15 20:11:44 -04:00
parent c379e246d0
commit ec01649905
8 changed files with 348 additions and 52 deletions
@@ -1,6 +1,7 @@
using Opc.Ua;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
@@ -130,6 +131,83 @@ public sealed class OpcUaClientNamespaceTests
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
@@ -147,4 +225,8 @@ public sealed class OpcUaClientNamespaceTests
}
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);
}