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; /// /// Value-pipeline batching (Task 6) — applies a batch under /// one acquisition of the manager-wide SDK Lock per 512-item chunk instead of one per value (the /// per-value take was the steady-state throughput ceiling on the publish path; the chunk cap keeps the /// first poll cycle's force-raise-everything batch from stalling every SDK service thread). /// These tests pin the observable contract rather than the locking: every item lands with /// its own Value / StatusCode / Timestamp, an item for an undeclared NodeId is lazily created exactly /// as does, and an empty batch does nothing at all. /// A non-Good status is included so the #519 verbatim-sub-code passthrough is proven on the batched /// path too — a batch that re-projected status through a 3-state enum would collapse it to a bare /// Bad. /// Boots a real through — the /// same harness uses — so the assertions read the real SDK node /// state an OPC UA client would receive. /// public sealed class NodeManagerBatchWriteTests : IDisposable { private static CancellationToken Ct => TestContext.Current.CancellationToken; private readonly string _pkiRoot = Path.Combine( Path.GetTempPath(), $"otopcua-batchwrite-{Guid.NewGuid():N}"); /// Every item of a multi-item batch lands on its own node with its own value, status code /// (including a non-Good sub-code) and source timestamp — no item is dropped, and none overwrites /// another's state. [Fact] public async Task WriteValues_lands_every_value() { var (host, server) = await BootAsync(); try { var nm = server.NodeManager!; foreach (var name in new[] { "a", "b", "c" }) { nm.EnsureVariable($"eq-1/{name}", parentFolderNodeId: null, displayName: name, dataType: "Double", writable: false, realm: AddressSpaceRealm.Uns); } var t1 = new DateTime(2026, 8, 15, 10, 0, 0, DateTimeKind.Utc); var t2 = new DateTime(2026, 8, 15, 10, 0, 1, DateTimeKind.Utc); var t3 = new DateTime(2026, 8, 15, 10, 0, 2, DateTimeKind.Utc); nm.WriteValues(new List { new("eq-1/a", 1.5d, OpcUaStatus.Good, t1, AddressSpaceRealm.Uns), new("eq-1/b", null, OpcUaStatus.BadNoCommunication, t2, AddressSpaceRealm.Uns), new("eq-1/c", 3.5d, 0x80740000u /* BadTypeMismatch */, t3, AddressSpaceRealm.Uns), }); var a = nm.TryGetVariable("eq-1/a"); a.ShouldNotBeNull(); a!.Value.ShouldBe(1.5d); a.StatusCode.ShouldBe((StatusCode)StatusCodes.Good); a.Timestamp.ShouldBe(t1); var b = nm.TryGetVariable("eq-1/b"); b.ShouldNotBeNull(); b!.Value.ShouldBeNull(); b.StatusCode.ShouldBe((StatusCode)StatusCodes.BadNoCommunication); b.StatusCode.ShouldNotBe((StatusCode)StatusCodes.Bad, "the batched path must hand the status sub-code to the SDK verbatim (#519)"); b.Timestamp.ShouldBe(t2); var c = nm.TryGetVariable("eq-1/c"); c.ShouldNotBeNull(); c!.Value.ShouldBe(3.5d); c.StatusCode.ShouldBe((StatusCode)StatusCodes.BadTypeMismatch); c.Timestamp.ShouldBe(t3); } finally { await host.DisposeAsync(); } } /// A batch item naming a NodeId that was never declared lazily creates the variable, exactly /// as the single-value create-on-first-write path does — /// and it does so PER REALM. Both items here carry the SAME s= id and differ only by realm, so a /// key that ignored the realm would create one node and have the second item overwrite the first. The /// assertion is therefore two nodes with DISTINCT namespace indices, not merely "both reads returned /// something" (which a realm-blind map satisfies, since the shared key resolves for either lookup). [Fact] public async Task WriteValues_creates_missing_variables() { var (host, server) = await BootAsync(); try { var nm = server.NodeManager!; const string SharedId = "Shared/lazy/id"; nm.TryGetVariable(SharedId, AddressSpaceRealm.Uns).ShouldBeNull(); nm.TryGetVariable(SharedId, AddressSpaceRealm.Raw).ShouldBeNull(); var ts = new DateTime(2026, 8, 15, 11, 0, 0, DateTimeKind.Utc); nm.WriteValues(new List { new(SharedId, 42, OpcUaStatus.Good, ts, AddressSpaceRealm.Uns), new(SharedId, 7, OpcUaStatus.Good, ts, AddressSpaceRealm.Raw), }); var uns = nm.TryGetVariable(SharedId, AddressSpaceRealm.Uns); uns.ShouldNotBeNull(); uns!.Value.ShouldBe(42); uns.StatusCode.ShouldBe((StatusCode)StatusCodes.Good); uns.Timestamp.ShouldBe(ts); var raw = nm.TryGetVariable(SharedId, AddressSpaceRealm.Raw); raw.ShouldNotBeNull(); raw!.Value.ShouldBe(7); // Two DISTINCT nodes, one per namespace — the realm-blind failure mode collapses these to one. raw.NodeId.NamespaceIndex.ShouldNotBe(uns.NodeId.NamespaceIndex); raw.NodeId.NamespaceIndex.ShouldBe(nm.NamespaceIndexForRealm(AddressSpaceRealm.Raw)); uns.NodeId.NamespaceIndex.ShouldBe(nm.NamespaceIndexForRealm(AddressSpaceRealm.Uns)); } finally { await host.DisposeAsync(); } } /// A bad item does NOT drop the tail of the batch. Item 2's NodeId is empty; items 1 and 3 must /// still land, and the aggregate surfaces as a naming the offender /// and the failed/succeeded split. /// This is the durability-critical half: the poll engine updates its last-value cache BEFORE /// raising, so an item dropped here is never re-raised until that tag's value changes again — the node /// would serve a stale value indefinitely, with nothing in the log naming it. [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, 15, 12, 0, 0, DateTimeKind.Utc); var ex = Should.Throw(() => nm.WriteValues(new List { new("eq-iso/first", 1, OpcUaStatus.Good, ts, AddressSpaceRealm.Uns), new(string.Empty, 2, OpcUaStatus.Good, ts, AddressSpaceRealm.Uns), new("eq-iso/third", 3, OpcUaStatus.Good, ts, AddressSpaceRealm.Uns), })); ex.FailedCount.ShouldBe(1); ex.SucceededCount.ShouldBe(2); ex.FirstFailedNodeId.ShouldBe(string.Empty); // The load-bearing half: BOTH good items applied — including the one AFTER the failure. var first = nm.TryGetVariable("eq-iso/first"); first.ShouldNotBeNull(); first!.Value.ShouldBe(1); var third = nm.TryGetVariable("eq-iso/third"); third.ShouldNotBeNull("the item after the failing one must not be dropped"); third!.Value.ShouldBe(3); } finally { await host.DisposeAsync(); } } /// A batch larger than MaxWritesPerLockAcquisition (512) spans several lock acquisitions; /// chunking must be invisible in the outcome — every item still lands exactly once, with its own value. /// Chunk boundaries are the natural off-by-one hazard, so the size is deliberately not a multiple of 512. /// [Fact] public async Task A_batch_spanning_several_chunks_lands_every_item() { var (host, server) = await BootAsync(); try { var nm = server.NodeManager!; var ts = new DateTime(2026, 8, 15, 13, 0, 0, DateTimeKind.Utc); const int Count = 1300; // > 2 chunks of 512, and not a multiple of it var items = new List(Count); for (var i = 0; i < Count; i++) { items.Add(new ValueWrite($"eq-chunk/{i}", i, OpcUaStatus.Good, ts, AddressSpaceRealm.Uns)); } nm.WriteValues(items); nm.VariableCount.ShouldBe(Count); foreach (var probe in new[] { 0, 511, 512, 1023, 1024, Count - 1 }) { var v = nm.TryGetVariable($"eq-chunk/{probe}"); v.ShouldNotBeNull($"item {probe} (a chunk boundary) must have landed"); v!.Value.ShouldBe(probe); } } 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.WriteValues([])); nm.VariableCount.ShouldBe(before); } finally { await host.DisposeAsync(); } } private async Task<(OpcUaApplicationHost Host, OtOpcUaSdkServer Server)> BootAsync() { var applicationUri = $"urn:OtOpcUa.BatchWriteTest:{Guid.NewGuid():N}"; var started = await OpcUaHostStart.StartAsync( port => new OpcUaApplicationHost( new OpcUaApplicationHostOptions { ApplicationName = "OtOpcUa.BatchWriteTest", ApplicationUri = applicationUri, OpcUaPort = port, PublicHostname = "localhost", PkiStoreRoot = _pkiRoot, }, NullLogger.Instance), () => new OtOpcUaSdkServer(), Ct); var host = started.Host; var server = started.Server; return (host, server); } /// Cleans up the PKI root directory. public void Dispose() { if (Directory.Exists(_pkiRoot)) { try { Directory.Delete(_pkiRoot, recursive: true); } catch { /* best-effort cleanup */ } } } }