using Moq;
using Opc.Ua;
using Opc.Ua.Client;
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.Tests;
///
/// Driver.OpcUaClient.Browser-002: when the caller cancels during the BrowseNextAsync
/// pagination loop, the server-side continuation point must be released
/// (releaseContinuationPoints: true) before the cancellation propagates. Mirrors the
/// runtime driver's OpcUaClientContinuationPointReleaseTests; no live server needed.
///
[Trait("Category", "Unit")]
public sealed class OpcUaClientBrowseSessionPaginationTests
{
private static readonly byte[] FakeContinuationPoint = [0x01, 0x02, 0x03];
private static Mock BuildSession(bool cancelNext)
{
var mock = new Mock(MockBehavior.Loose);
mock.SetupGet(s => s.MessageContext).Returns(new ServiceMessageContext());
mock.SetupGet(s => s.NamespaceUris).Returns(new NamespaceTable());
var firstRef = new ReferenceDescription
{
NodeId = new ExpandedNodeId(new NodeId(1000u)),
BrowseName = new QualifiedName("TestObject"),
DisplayName = new LocalizedText("TestObject"),
NodeClass = NodeClass.Object,
};
mock.Setup(s => s.BrowseAsync(
It.IsAny(), It.IsAny(), It.IsAny(),
It.IsAny(), It.IsAny()))
.ReturnsAsync(new BrowseResponse
{
ResponseHeader = new ResponseHeader(),
Results = new BrowseResultCollection
{
new BrowseResult
{
ContinuationPoint = FakeContinuationPoint,
References = new ReferenceDescriptionCollection { firstRef },
},
},
DiagnosticInfos = new DiagnosticInfoCollection(),
});
var emptyPage = new BrowseNextResponse
{
ResponseHeader = new ResponseHeader(),
Results = new BrowseResultCollection
{
new BrowseResult
{
ContinuationPoint = Array.Empty(),
References = new ReferenceDescriptionCollection(),
},
},
DiagnosticInfos = new DiagnosticInfoCollection(),
};
var pageSetup = mock.Setup(s => s.BrowseNextAsync(
It.IsAny(), It.Is(r => !r),
It.IsAny(), It.IsAny()));
if (cancelNext) pageSetup.ThrowsAsync(new OperationCanceledException("cancelled mid-page"));
else pageSetup.ReturnsAsync(emptyPage);
mock.Setup(s => s.BrowseNextAsync(
It.IsAny(), It.Is(r => r),
It.IsAny(), It.IsAny()))
.ReturnsAsync(emptyPage);
return mock;
}
private static OpcUaClientBrowseSession NewSession(Mock mock)
=> new(mock.Object, NamespaceMap.FromTable(new NamespaceTable()), ObjectIds.ObjectsFolder);
/// Cancellation mid-pagination releases the continuation point exactly once.
[Fact]
public async Task RootAsync_releases_continuation_point_when_cancelled_mid_pagination()
{
var mock = BuildSession(cancelNext: true);
await using var session = NewSession(mock);
await Should.ThrowAsync(async () =>
await session.RootAsync(TestContext.Current.CancellationToken));
mock.Verify(s => s.BrowseNextAsync(
It.IsAny(), It.Is(r => r),
It.IsAny(), It.IsAny()), Times.Once);
}
/// Positive control: an uncancelled pagination never issues a release call.
[Fact]
public async Task RootAsync_does_not_release_when_pagination_completes()
{
var mock = BuildSession(cancelNext: false);
await using var session = NewSession(mock);
var nodes = await session.RootAsync(TestContext.Current.CancellationToken);
nodes.Count.ShouldBe(1);
mock.Verify(s => s.BrowseNextAsync(
It.IsAny(), It.Is(r => !r),
It.IsAny(), It.IsAny()), Times.Once);
mock.Verify(s => s.BrowseNextAsync(
It.IsAny(), It.Is(r => r),
It.IsAny(), It.IsAny()), Times.Never);
}
}