using Moq; using Opc.Ua; using Opc.Ua.Client; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests; /// #275 — the driver splits Read / Write calls to the server's declared OperationLimits. [Trait("Category", "Unit")] public sealed class OpcUaClientOperationLimitsTests { [Theory] [InlineData(0, 0u, new int[0])] // nothing to send => no call [InlineData(5, 0u, new[] { 5 })] // 0 = unlimited [InlineData(5, 5u, new[] { 5 })] // exactly fits [InlineData(5, 100u, new[] { 5 })] // limit larger than the batch [InlineData(10, 4u, new[] { 4, 4, 2 })] // remainder chunk [InlineData(9, 3u, new[] { 3, 3, 3 })] [InlineData(3, 1u, new[] { 1, 1, 1 })] public void Chunk_splits_to_the_limit(int total, uint limit, int[] expectedSizes) { var chunks = OpcUaClientOperationLimits.Chunk(total, limit).ToList(); chunks.Select(c => c.Count).ToArray().ShouldBe(expectedSizes); // Contiguous cover of [0, total): every item exactly once, in order. var next = 0; foreach (var (start, count) in chunks) { start.ShouldBe(next); next += count; } next.ShouldBe(total); } [Fact] public void FromValues_reads_good_uints_and_treats_bad_or_missing_as_unlimited() { var values = new List { new(new Variant(100u)), new(new Variant(50u)), new(StatusCodes.BadNodeIdUnknown), // server doesn't publish MaxNodesPerBrowse new(new Variant(7u)), }; var limits = OpcUaClientOperationLimits.FromValues(values); limits.MaxNodesPerRead.ShouldBe(100u); limits.MaxNodesPerWrite.ShouldBe(50u); limits.MaxNodesPerBrowse.ShouldBe(0u); limits.MaxMonitoredItemsPerCall.ShouldBe(7u); limits.MaxNodesPerHistoryReadData.ShouldBe(0u); // short list => 0 limits.MaxNodesPerHistoryReadEvents.ShouldBe(0u); } private static (OpcUaClientDriver Drv, Mock Session, List ReadSizes, List WriteSizes) Rig( uint readLimit, uint writeLimit, int tagCount) { var tags = Enumerable.Range(0, tagCount) .Select(i => new RawTagEntry($"raw/t{i}", $$"""{"nodeId":"ns=2;s=T{{i}}"}""", WriteIdempotent: false)) .ToList(); var options = new OpcUaClientDriverOptions { RawTags = tags }; var drv = new OpcUaClientDriver(options, "opcua-limits"); drv.SetNamespaceMapForTest(ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.NamespaceMap.FromTable(new NamespaceTable(), tags)); drv.SetOperationLimitsForTest(OpcUaClientOperationLimits.None with { MaxNodesPerRead = readLimit, MaxNodesPerWrite = writeLimit, }); var readSizes = new List(); var writeSizes = new List(); var mock = new Mock(MockBehavior.Loose); mock.SetupGet(s => s.MessageContext) .Returns(new ServiceMessageContext(SdkTelemetry.Context)); mock.SetupGet(s => s.NamespaceUris).Returns(new NamespaceTable()); mock.Setup(s => s.ReadAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync((RequestHeader _, double _, TimestampsToReturn _, ReadValueIdCollection nodes, CancellationToken _) => { readSizes.Add(nodes.Count); // Echo each node's identifier number back as the value so ordering across chunks is checkable. var results = new DataValueCollection(); foreach (var n in nodes) results.Add(new DataValue(new Variant((string)n.NodeId.Identifier))); return new ReadResponse { ResponseHeader = new ResponseHeader(), Results = results }; }); mock.Setup(s => s.WriteAsync(It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync((RequestHeader _, WriteValueCollection nodes, CancellationToken _) => { writeSizes.Add(nodes.Count); var codes = new StatusCodeCollection(); foreach (var _ in nodes) codes.Add(StatusCodes.Good); return new WriteResponse { ResponseHeader = new ResponseHeader(), Results = codes }; }); drv.SetSessionForTest(mock.Object); return (drv, mock, readSizes, writeSizes); } [Fact] public async Task ReadAsync_splits_to_MaxNodesPerRead_and_keeps_result_order() { var (drv, _, readSizes, _) = Rig(readLimit: 4, writeLimit: 0, tagCount: 10); using var _ = drv; var refs = Enumerable.Range(0, 10).Select(i => $"raw/t{i}").ToList(); var res = await drv.ReadAsync(refs, TestContext.Current.CancellationToken); readSizes.ShouldBe([4, 4, 2]); res.Count.ShouldBe(10); for (var i = 0; i < 10; i++) res[i].Value.ShouldBe($"T{i}"); // positional mapping survives chunking drv.GetHealth().State.ShouldBe(DriverState.Healthy); drv.GetHealth().Diagnostics!["ChunkedRequestCount"].ShouldBe(1); } [Fact] public async Task ReadAsync_with_no_declared_limit_sends_one_request() { var (drv, _, readSizes, _) = Rig(readLimit: 0, writeLimit: 0, tagCount: 10); using var _ = drv; await drv.ReadAsync(Enumerable.Range(0, 10).Select(i => $"raw/t{i}").ToList(), TestContext.Current.CancellationToken); readSizes.ShouldBe([10]); } [Fact] public async Task ReadAsync_a_failing_chunk_only_fails_its_own_tags() { var (drv, session, _, _) = Rig(readLimit: 2, writeLimit: 0, tagCount: 4); using var _ = drv; var call = 0; session.Setup(s => s.ReadAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .Returns((RequestHeader _, double _, TimestampsToReturn _, ReadValueIdCollection nodes, CancellationToken _) => { if (Interlocked.Increment(ref call) == 2) throw new ServiceResultException(StatusCodes.BadConnectionClosed); var results = new DataValueCollection(); foreach (var n in nodes) results.Add(new DataValue(new Variant((string)n.NodeId.Identifier))); return Task.FromResult(new ReadResponse { ResponseHeader = new ResponseHeader(), Results = results }); }); var res = await drv.ReadAsync(["raw/t0", "raw/t1", "raw/t2", "raw/t3"], TestContext.Current.CancellationToken); res[0].Value.ShouldBe("T0"); res[1].Value.ShouldBe("T1"); res[2].StatusCode.ShouldBe(0x80050000u); // BadCommunicationError, chunk 2 only res[3].StatusCode.ShouldBe(0x80050000u); drv.GetHealth().State.ShouldBe(DriverState.Degraded); drv.GetHealth().Diagnostics!["ReadFailureCount"].ShouldBe(1); } [Fact] public async Task WriteAsync_splits_to_MaxNodesPerWrite() { var (drv, _, _, writeSizes) = Rig(readLimit: 0, writeLimit: 3, tagCount: 7); using var _ = drv; var res = await drv.WriteAsync( Enumerable.Range(0, 7).Select(i => new Core.Abstractions.WriteRequest($"raw/t{i}", i)).ToList(), TestContext.Current.CancellationToken); writeSizes.ShouldBe([3, 3, 1]); res.Count.ShouldBe(7); res.ShouldAllBe(r => r.StatusCode == 0u); } }