Merge B1-opcuaclient: v3 RawPath read-path
This commit is contained in:
+42
@@ -33,4 +33,46 @@ public class OpcUaClientDriverFactoryTests
|
||||
public void CreateInstance_throws_on_null_json_deserialisation()
|
||||
=> Should.Throw<System.InvalidOperationException>(
|
||||
() => OpcUaClientDriverFactoryExtensions.CreateInstance("drv-1", "null", NullLoggerFactory.Instance));
|
||||
|
||||
private const string RawTagsConfig =
|
||||
"""
|
||||
{
|
||||
"EndpointUrl": "opc.tcp://host:4840",
|
||||
"RawTags": [
|
||||
{ "rawPath": "Plant/Line1/Temp", "tagConfig": "{\"nodeId\":\"ns=2;s=Temperature\"}", "writeIdempotent": false },
|
||||
{ "rawPath": "Plant/Line1/Speed", "tagConfig": "{\"nodeId\":\"ns=2;s=Speed\"}", "writeIdempotent": true }
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the v3 authored <c>RawTags</c> (RawPath + TagConfig blob + WriteIdempotent) bind
|
||||
/// through the DriverConfig deserialiser directly onto <see cref="OpcUaClientDriverOptions"/> —
|
||||
/// the OpcUaClient driver has no separate DTO, so the factory's direct-into-options path must
|
||||
/// populate the RawPath resolution list.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void CreateInstance_binds_rawtags_from_driverconfig()
|
||||
{
|
||||
var driver = OpcUaClientDriverFactoryExtensions.CreateInstance("drv-raw", RawTagsConfig, NullLoggerFactory.Instance);
|
||||
// The factory succeeds and identity is intact — the deserialiser tolerated + bound the RawTags array.
|
||||
driver.DriverType.ShouldBe("OpcUaClient");
|
||||
driver.DriverInstanceId.ShouldBe("drv-raw");
|
||||
|
||||
// Assert the actual binding: deserialise the same DriverConfig into public options with the
|
||||
// factory's serialiser settings (case-insensitive + string enums) and confirm RawTags populated.
|
||||
var opts = System.Text.Json.JsonSerializer.Deserialize<OpcUaClientDriverOptions>(
|
||||
RawTagsConfig,
|
||||
new System.Text.Json.JsonSerializerOptions
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() },
|
||||
});
|
||||
opts.ShouldNotBeNull();
|
||||
opts!.RawTags.Count.ShouldBe(2);
|
||||
opts.RawTags[0].RawPath.ShouldBe("Plant/Line1/Temp");
|
||||
opts.RawTags[0].TagConfig.ShouldContain("ns=2;s=Temperature");
|
||||
opts.RawTags[0].WriteIdempotent.ShouldBeFalse();
|
||||
opts.RawTags[1].WriteIdempotent.ShouldBeTrue();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
+9
-1
@@ -110,7 +110,15 @@ public sealed class OpcUaClientStaleSessionRaceTests
|
||||
public async Task ReadRawAsync_uses_session_swapped_in_across_the_gate_not_the_captured_one()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-stale-raw");
|
||||
// v3: a HistoryRead over a variable is keyed by the tag's RawPath, resolved through the
|
||||
// authored RawTags table to its upstream node id. Author "ns=2;s=Counter" as both the
|
||||
// RawPath and the TagConfig.nodeId so the two-stage resolve reaches the wire.
|
||||
var options = new OpcUaClientDriverOptions
|
||||
{
|
||||
RawTags = [new RawTagEntry("ns=2;s=Counter", """{"nodeId":"ns=2;s=Counter"}""", WriteIdempotent: false)],
|
||||
};
|
||||
using var drv = new OpcUaClientDriver(options, "opcua-stale-raw");
|
||||
drv.SetNamespaceMapForTest(NamespaceMap.FromTable(new NamespaceTable(), options.RawTags));
|
||||
|
||||
var prologueReached = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var oldSession = NewSessionMock(prologueReached);
|
||||
|
||||
Reference in New Issue
Block a user