f02067d295
The tests probe a free port (bind 0, release) and start the SDK server later; under a highly parallel run another test can take the port in between and the SDK fails with 'Failed to establish tcp listener sockets for Ipv4 and IPv6.'. OpcUaHostStart.StartAsync builds a fresh host + server on a fresh port per attempt (up to 5) when the failure is a bind failure; all 45 start sites in OpcUaServer.Tests, OpcUaServer.IntegrationTests and Runtime.Tests use it. Per-attempt GUID ApplicationUris are hoisted out of the host factory because a changed URI against a reused PKI store fails with 'Cannot access private key'. Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
220 lines
9.1 KiB
C#
220 lines
9.1 KiB
C#
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Opc.Ua;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
|
|
|
|
/// <summary>
|
|
/// <see cref="OtOpcUaNodeManager.WriteStatuses"/> — the status-only batch write behind the
|
|
/// comms-loss annotation for value variables (archreview 03/S15).
|
|
///
|
|
/// <para><b>Keeping the value is the whole point.</b> The host holds no value cache (v3's
|
|
/// single-source design), so "this driver has stopped communicating, but here is what it last
|
|
/// said" can only be expressed at the node, where the value already lives. The Part 8
|
|
/// last-known-value convention is what a SCADA client expects, and the interface's default
|
|
/// implementation — which nulls the value because a value-less sink has nothing else to write —
|
|
/// would silently erase it if the SDK sink ever stopped overriding it.</para>
|
|
///
|
|
/// <para><b>Never creates.</b> A status carries nothing to create a node from, and the caller's
|
|
/// per-driver index can legitimately name a node a surgical remove has just dropped. A missing id
|
|
/// is skipped, not materialised — the opposite of <see cref="OtOpcUaNodeManager.WriteValues"/>,
|
|
/// which lazily creates.</para>
|
|
///
|
|
/// <para>Boots a real <see cref="OtOpcUaSdkServer"/>, like
|
|
/// <see cref="NodeManagerBatchWriteTests"/>, so the assertions read the SDK node state a client
|
|
/// would receive.</para>
|
|
/// </summary>
|
|
public sealed class OtOpcUaNodeManagerWriteStatusesTests : IDisposable
|
|
{
|
|
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
|
|
|
private readonly string _pkiRoot = Path.Combine(
|
|
Path.GetTempPath(),
|
|
$"otopcua-writestatuses-{Guid.NewGuid():N}");
|
|
|
|
/// <summary>
|
|
/// The contract: value retained, status + timestamp replaced. Asserted on a node that was first
|
|
/// written a real value, because "value retained" is unfalsifiable on a node that never had one.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task WriteStatuses_replaces_status_and_timestamp_but_keeps_the_value()
|
|
{
|
|
var (host, server) = await BootAsync();
|
|
try
|
|
{
|
|
var nm = server.NodeManager!;
|
|
var t1 = new DateTime(2026, 8, 20, 10, 0, 0, DateTimeKind.Utc);
|
|
var t2 = new DateTime(2026, 8, 20, 10, 5, 0, DateTimeKind.Utc);
|
|
|
|
nm.EnsureVariable("Plant/Modbus/sim/HR5", parentFolderNodeId: null, displayName: "HR5",
|
|
dataType: "Int32", writable: false, realm: AddressSpaceRealm.Raw);
|
|
nm.WriteValue("Plant/Modbus/sim/HR5", 42, OpcUaStatus.Good, t1, AddressSpaceRealm.Raw);
|
|
|
|
nm.WriteStatuses([new StatusWrite("Plant/Modbus/sim/HR5", OpcUaStatus.BadNoCommunication, t2, AddressSpaceRealm.Raw)]);
|
|
|
|
var v = nm.TryGetVariable("Plant/Modbus/sim/HR5", AddressSpaceRealm.Raw);
|
|
v.ShouldNotBeNull();
|
|
v!.Value.ShouldBe(42, "the last known value must survive the comms-loss annotation");
|
|
v.StatusCode.ShouldBe((StatusCode)StatusCodes.BadNoCommunication);
|
|
v.StatusCode.ShouldNotBe((StatusCode)StatusCodes.Bad, "the sub-code reaches the SDK verbatim (#519)");
|
|
v.Timestamp.ShouldBe(t2);
|
|
}
|
|
finally
|
|
{
|
|
await host.DisposeAsync();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Realm-qualified, like every other node-naming sink call: the same <c>s=</c> id in the other
|
|
/// namespace is a different node and must be untouched.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task WriteStatuses_only_touches_the_named_realm()
|
|
{
|
|
var (host, server) = await BootAsync();
|
|
try
|
|
{
|
|
var nm = server.NodeManager!;
|
|
var ts = new DateTime(2026, 8, 20, 10, 0, 0, DateTimeKind.Utc);
|
|
const string sharedId = "Shared/both/realms";
|
|
|
|
nm.WriteValue(sharedId, 1, OpcUaStatus.Good, ts, AddressSpaceRealm.Raw);
|
|
nm.WriteValue(sharedId, 2, OpcUaStatus.Good, ts, AddressSpaceRealm.Uns);
|
|
|
|
nm.WriteStatuses([new StatusWrite(sharedId, OpcUaStatus.BadNoCommunication, ts, AddressSpaceRealm.Raw)]);
|
|
|
|
nm.TryGetVariable(sharedId, AddressSpaceRealm.Raw)!.StatusCode
|
|
.ShouldBe((StatusCode)StatusCodes.BadNoCommunication);
|
|
nm.TryGetVariable(sharedId, AddressSpaceRealm.Uns)!.StatusCode
|
|
.ShouldBe((StatusCode)StatusCodes.Good);
|
|
}
|
|
finally
|
|
{
|
|
await host.DisposeAsync();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// An unknown NodeId is a no-op: no node is created, nothing throws, and the rest of the batch
|
|
/// still lands. A create here would materialise a node nobody authored, holding no value, from a
|
|
/// driver-index entry that is stale precisely because the node was removed.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task An_unknown_node_id_is_skipped_not_created()
|
|
{
|
|
var (host, server) = await BootAsync();
|
|
try
|
|
{
|
|
var nm = server.NodeManager!;
|
|
var ts = new DateTime(2026, 8, 20, 10, 0, 0, DateTimeKind.Utc);
|
|
nm.WriteValue("Plant/known", 7, OpcUaStatus.Good, ts, AddressSpaceRealm.Raw);
|
|
var before = nm.VariableCount;
|
|
|
|
Should.NotThrow(() => nm.WriteStatuses(
|
|
[
|
|
new StatusWrite("Plant/does/not/exist", OpcUaStatus.BadNoCommunication, ts, AddressSpaceRealm.Raw),
|
|
new StatusWrite("Plant/known", OpcUaStatus.BadNoCommunication, ts, AddressSpaceRealm.Raw),
|
|
]));
|
|
|
|
nm.VariableCount.ShouldBe(before, "a status write must never create a node");
|
|
nm.TryGetVariable("Plant/does/not/exist", AddressSpaceRealm.Raw).ShouldBeNull();
|
|
nm.TryGetVariable("Plant/known", AddressSpaceRealm.Raw)!.StatusCode
|
|
.ShouldBe((StatusCode)StatusCodes.BadNoCommunication);
|
|
}
|
|
finally
|
|
{
|
|
await host.DisposeAsync();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// A malformed item does not drop the tail — the same per-item isolation contract
|
|
/// <see cref="OtOpcUaNodeManager.WriteValues"/> carries, reported through the same
|
|
/// <see cref="ValueWriteBatchException"/> aggregate.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task A_failing_item_neither_drops_the_tail_nor_hides_which_node_failed()
|
|
{
|
|
var (host, server) = await BootAsync();
|
|
try
|
|
{
|
|
var nm = server.NodeManager!;
|
|
var ts = new DateTime(2026, 8, 20, 10, 0, 0, DateTimeKind.Utc);
|
|
nm.WriteValue("Plant/first", 1, OpcUaStatus.Good, ts, AddressSpaceRealm.Raw);
|
|
nm.WriteValue("Plant/third", 3, OpcUaStatus.Good, ts, AddressSpaceRealm.Raw);
|
|
|
|
var ex = Should.Throw<ValueWriteBatchException>(() => nm.WriteStatuses(
|
|
[
|
|
new StatusWrite("Plant/first", OpcUaStatus.BadNoCommunication, ts, AddressSpaceRealm.Raw),
|
|
new StatusWrite(string.Empty, OpcUaStatus.BadNoCommunication, ts, AddressSpaceRealm.Raw),
|
|
new StatusWrite("Plant/third", OpcUaStatus.BadNoCommunication, ts, AddressSpaceRealm.Raw),
|
|
]));
|
|
|
|
ex.FailedCount.ShouldBe(1);
|
|
ex.SucceededCount.ShouldBe(2);
|
|
ex.FirstFailedNodeId.ShouldBe(string.Empty);
|
|
|
|
nm.TryGetVariable("Plant/first", AddressSpaceRealm.Raw)!.Value.ShouldBe(1);
|
|
nm.TryGetVariable("Plant/third", AddressSpaceRealm.Raw)!.StatusCode
|
|
.ShouldBe((StatusCode)StatusCodes.BadNoCommunication, "the item after the failing one must not be dropped");
|
|
}
|
|
finally
|
|
{
|
|
await host.DisposeAsync();
|
|
}
|
|
}
|
|
|
|
/// <summary>An empty batch neither throws nor touches the address space.</summary>
|
|
[Fact]
|
|
public async Task Empty_batch_is_a_no_op()
|
|
{
|
|
var (host, server) = await BootAsync();
|
|
try
|
|
{
|
|
var nm = server.NodeManager!;
|
|
var before = nm.VariableCount;
|
|
|
|
Should.NotThrow(() => nm.WriteStatuses([]));
|
|
|
|
nm.VariableCount.ShouldBe(before);
|
|
}
|
|
finally
|
|
{
|
|
await host.DisposeAsync();
|
|
}
|
|
}
|
|
|
|
private async Task<(OpcUaApplicationHost Host, OtOpcUaSdkServer Server)> BootAsync()
|
|
{
|
|
var applicationUri = $"urn:OtOpcUa.WriteStatusesTest:{Guid.NewGuid():N}";
|
|
var started = await OpcUaHostStart.StartAsync(
|
|
port => new OpcUaApplicationHost(
|
|
new OpcUaApplicationHostOptions
|
|
{
|
|
ApplicationName = "OtOpcUa.WriteStatusesTest",
|
|
ApplicationUri = applicationUri,
|
|
OpcUaPort = port,
|
|
PublicHostname = "localhost",
|
|
PkiStoreRoot = _pkiRoot,
|
|
},
|
|
NullLogger<OpcUaApplicationHost>.Instance),
|
|
() => new OtOpcUaSdkServer(), Ct);
|
|
var host = started.Host;
|
|
var server = started.Server;
|
|
return (host, server);
|
|
}
|
|
|
|
/// <summary>Cleans up the PKI root directory.</summary>
|
|
public void Dispose()
|
|
{
|
|
if (Directory.Exists(_pkiRoot))
|
|
{
|
|
try { Directory.Delete(_pkiRoot, recursive: true); }
|
|
catch { /* best-effort cleanup */ }
|
|
}
|
|
}
|
|
}
|