feat(dcl): BrowseNext continuation paging + StubOpcUaClient canned browse (T15)

This commit is contained in:
Joseph Doherty
2026-06-18 02:21:59 -04:00
parent 3c9122bc07
commit 2cfe0de927
8 changed files with 258 additions and 37 deletions
@@ -132,15 +132,21 @@ public interface IOpcUaClient : IAsyncDisposable
/// <summary>
/// Enumerates the immediate children of <paramref name="parentNodeId"/>
/// (or the server's ObjectsFolder when null). Throws
/// (or the server's ObjectsFolder when null). Pageable via the OPC UA
/// continuation-point mechanism: when a prior call returned a non-null
/// <see cref="BrowseChildrenResult.ContinuationToken"/>, pass it back via
/// <paramref name="continuationToken"/> to fetch the next page (BrowseNext);
/// a null/empty token starts a fresh browse. Throws
/// <see cref="ConnectionNotConnectedException"/> when the session is not
/// currently up.
/// </summary>
/// <param name="parentNodeId">Node id whose children to browse, or null for the server root.</param>
/// <param name="continuationToken">Opaque token from a prior <see cref="BrowseChildrenResult.ContinuationToken"/> to fetch the next page via BrowseNext; null/empty starts a fresh browse.</param>
/// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param>
/// <returns>A task that completes with the immediate children of the requested node.</returns>
/// <returns>A task that completes with the immediate children of the requested node and a continuation token for the next page (null when exhausted).</returns>
Task<BrowseChildrenResult> BrowseChildrenAsync(
string? parentNodeId,
string? continuationToken = null,
CancellationToken cancellationToken = default);
}
@@ -235,8 +241,48 @@ internal class StubOpcUaClient : IOpcUaClient
/// <inheritdoc />
public Task<BrowseChildrenResult> BrowseChildrenAsync(
string? parentNodeId, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
string? parentNodeId, string? continuationToken = null, CancellationToken cancellationToken = default)
{
// Canned address-space tree so DCL browse/paging flows can be exercised
// without a live OPC UA server (T15):
// (root)
// ├─ Folder1 (Object, HasChildren=true)
// │ ├─ Tag1 (Variable) ← page 1
// │ └─ Tag2 (Variable) ← page 2 (continuation token "STUB_PAGE2")
// └─ Folder2 (Object, HasChildren=false, leaf)
// Folder1 fakes BrowseNext continuation paging so the continuation-token
// round-trip is testable; everything else is a single, exhausted page.
if (string.IsNullOrEmpty(parentNodeId))
{
return Task.FromResult(new BrowseChildrenResult(
new[]
{
new BrowseNode("Folder1", "Folder1", BrowseNodeClass.Object, HasChildren: true),
new BrowseNode("Folder2", "Folder2", BrowseNodeClass.Object, HasChildren: false),
},
Truncated: false));
}
if (parentNodeId == "Folder1")
{
// Page 2: the caller passed back the continuation token from page 1.
if (continuationToken == "STUB_PAGE2")
{
return Task.FromResult(new BrowseChildrenResult(
new[] { new BrowseNode("Tag2", "Tag2", BrowseNodeClass.Variable, HasChildren: false) },
Truncated: false));
}
// Page 1: return only Tag1 and a continuation token for the next page.
return Task.FromResult(new BrowseChildrenResult(
new[] { new BrowseNode("Tag1", "Tag1", BrowseNodeClass.Variable, HasChildren: false) },
Truncated: true,
ContinuationToken: "STUB_PAGE2"));
}
// Leaves (Folder2, Variable nodes, anything unknown) have no children.
return Task.FromResult(new BrowseChildrenResult(Array.Empty<BrowseNode>(), Truncated: false));
}
/// <summary>Disposes this stub client and marks the connection as closed.</summary>
/// <returns>A completed <see cref="ValueTask"/>.</returns>