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; /// /// — the status-only batch write behind the /// comms-loss annotation for value variables (archreview 03/S15). /// /// Keeping the value is the whole point. 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. /// /// Never creates. 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 , /// which lazily creates. /// /// Boots a real , like /// , so the assertions read the SDK node state a client /// would receive. /// 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}"); /// /// 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. /// [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(); } } /// /// Realm-qualified, like every other node-naming sink call: the same s= id in the other /// namespace is a different node and must be untouched. /// [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(); } } /// /// 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. /// [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(); } } /// /// A malformed item does not drop the tail — the same per-item isolation contract /// carries, reported through the same /// aggregate. /// [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(() => 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(); } } /// An empty batch neither throws nor touches the address space. [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 host = new OpcUaApplicationHost( new OpcUaApplicationHostOptions { ApplicationName = "OtOpcUa.WriteStatusesTest", ApplicationUri = $"urn:OtOpcUa.WriteStatusesTest:{Guid.NewGuid():N}", OpcUaPort = AllocateFreePort(), PublicHostname = "localhost", PkiStoreRoot = _pkiRoot, }, NullLogger.Instance); var server = new OtOpcUaSdkServer(); await host.StartAsync(server, Ct); return (host, server); } private static int AllocateFreePort() { using var listener = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Loopback, 0); listener.Start(); var port = ((System.Net.IPEndPoint)listener.LocalEndpoint).Port; listener.Stop(); return port; } /// Cleans up the PKI root directory. public void Dispose() { if (Directory.Exists(_pkiRoot)) { try { Directory.Delete(_pkiRoot, recursive: true); } catch { /* best-effort cleanup */ } } } }