ba62022e88
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
164 lines
7.5 KiB
C#
164 lines
7.5 KiB
C#
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;
|
|
|
|
/// <summary>#275 — the driver splits Read / Write calls to the server's declared OperationLimits.</summary>
|
|
[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<DataValue>
|
|
{
|
|
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<ISession> Session, List<int> ReadSizes, List<int> 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<int>();
|
|
var writeSizes = new List<int>();
|
|
var mock = new Mock<ISession>(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<RequestHeader>(), It.IsAny<double>(), It.IsAny<TimestampsToReturn>(),
|
|
It.IsAny<ReadValueIdCollection>(), It.IsAny<CancellationToken>()))
|
|
.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<RequestHeader>(), It.IsAny<WriteValueCollection>(), It.IsAny<CancellationToken>()))
|
|
.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<RequestHeader>(), It.IsAny<double>(), It.IsAny<TimestampsToReturn>(),
|
|
It.IsAny<ReadValueIdCollection>(), It.IsAny<CancellationToken>()))
|
|
.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);
|
|
}
|
|
}
|