diff --git a/clients/dotnet/ZB.MOM.WW.MxGateway.Client.Tests/FakeGalaxyRepositoryTransport.cs b/clients/dotnet/ZB.MOM.WW.MxGateway.Client.Tests/FakeGalaxyRepositoryTransport.cs index ac12537..1ec00d1 100644 --- a/clients/dotnet/ZB.MOM.WW.MxGateway.Client.Tests/FakeGalaxyRepositoryTransport.cs +++ b/clients/dotnet/ZB.MOM.WW.MxGateway.Client.Tests/FakeGalaxyRepositoryTransport.cs @@ -123,6 +123,39 @@ internal sealed class FakeGalaxyRepositoryTransport(MxGatewayClientOptions optio : DiscoverHierarchyReply); } + /// Records BrowseChildren RPC calls made by the client. + public List<(BrowseChildrenRequest Request, CallOptions CallOptions)> BrowseChildrenCalls { get; } = []; + + /// Default reply returned from BrowseChildren when the queue is empty. + public BrowseChildrenReply BrowseChildrenReply { get; set; } = new(); + + /// Queue of replies returned from BrowseChildren; dequeued in FIFO order. + public Queue BrowseChildrenReplies { get; } = new(); + + /// Queue of exceptions to throw from BrowseChildren; dequeued in FIFO order. + public Queue BrowseChildrenExceptions { get; } = new(); + + /// + /// Records the request and either throws a queued exception or returns the configured reply. + /// + /// The BrowseChildrenRequest to process. + /// Call options specifying RPC behavior. + public Task BrowseChildrenAsync( + BrowseChildrenRequest request, + CallOptions callOptions) + { + BrowseChildrenCalls.Add((request, callOptions)); + if (BrowseChildrenExceptions.TryDequeue(out Exception? exception)) + { + return Task.FromException(exception); + } + + return Task.FromResult( + BrowseChildrenReplies.TryDequeue(out BrowseChildrenReply? reply) + ? reply + : BrowseChildrenReply); + } + /// /// Gets the list of WatchDeployEvents RPC calls made by the client. /// diff --git a/clients/dotnet/ZB.MOM.WW.MxGateway.Client.Tests/LazyBrowseNodeTests.cs b/clients/dotnet/ZB.MOM.WW.MxGateway.Client.Tests/LazyBrowseNodeTests.cs new file mode 100644 index 0000000..96060b9 --- /dev/null +++ b/clients/dotnet/ZB.MOM.WW.MxGateway.Client.Tests/LazyBrowseNodeTests.cs @@ -0,0 +1,188 @@ +using Grpc.Core; +using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy; + +namespace ZB.MOM.WW.MxGateway.Client.Tests; + +/// +/// Tests for the walker over the BrowseChildren RPC. +/// +public sealed class LazyBrowseNodeTests +{ + /// + /// Verifies that calling BrowseAsync with no parent returns the root nodes + /// from the first BrowseChildren reply and surfaces the per-child has-children hint. + /// + [Fact] + public async Task Browse_NoParent_ReturnsRoots() + { + FakeGalaxyRepositoryTransport transport = CreateTransport(); + transport.BrowseChildrenReplies.Enqueue(BuildReply( + children: [BuildObject(1, "Plant", isArea: true), BuildObject(2, "Other")], + childHasChildren: [true, false], + cacheSequence: 1)); + await using GalaxyRepositoryClient client = CreateClient(transport); + + IReadOnlyList roots = await client.BrowseAsync(); + + Assert.Equal(2, roots.Count); + Assert.Equal("Plant", roots[0].Object.TagName); + Assert.True(roots[0].HasChildrenHint); + Assert.False(roots[0].IsExpanded); + Assert.Equal("Other", roots[1].Object.TagName); + Assert.False(roots[1].HasChildrenHint); + Assert.False(roots[1].IsExpanded); + } + + /// + /// Verifies that ExpandAsync populates Children and marks the node expanded after one RPC. + /// + [Fact] + public async Task Expand_PopulatesChildrenAndMarksExpanded() + { + FakeGalaxyRepositoryTransport transport = CreateTransport(); + transport.BrowseChildrenReplies.Enqueue(BuildReply( + children: [BuildObject(1, "Plant", isArea: true)], + childHasChildren: [true], + cacheSequence: 1)); + transport.BrowseChildrenReplies.Enqueue(BuildReply( + children: [BuildObject(10, "Line1")], + childHasChildren: [false], + cacheSequence: 1)); + await using GalaxyRepositoryClient client = CreateClient(transport); + + IReadOnlyList roots = await client.BrowseAsync(); + await roots[0].ExpandAsync(); + + Assert.True(roots[0].IsExpanded); + Assert.Single(roots[0].Children); + Assert.Equal("Line1", roots[0].Children[0].Object.TagName); + Assert.Equal(2, transport.BrowseChildrenCalls.Count); + } + + /// + /// Verifies that a second ExpandAsync call is a no-op and issues no additional RPC. + /// + [Fact] + public async Task Expand_CalledTwice_NoSecondRpc() + { + FakeGalaxyRepositoryTransport transport = CreateTransport(); + transport.BrowseChildrenReplies.Enqueue(BuildReply( + children: [BuildObject(1, "Plant", isArea: true)], + childHasChildren: [true], + cacheSequence: 1)); + transport.BrowseChildrenReplies.Enqueue(BuildReply( + children: [BuildObject(10, "Line1")], + childHasChildren: [false], + cacheSequence: 1)); + await using GalaxyRepositoryClient client = CreateClient(transport); + + IReadOnlyList roots = await client.BrowseAsync(); + await roots[0].ExpandAsync(); + await roots[0].ExpandAsync(); + + Assert.Equal(2, transport.BrowseChildrenCalls.Count); + } + + /// + /// Verifies that an RPC failure (NotFound) during expand is wrapped in MxGatewayException. + /// + [Fact] + public async Task Expand_UnknownParent_ThrowsMxGatewayException() + { + FakeGalaxyRepositoryTransport transport = CreateTransport(); + transport.BrowseChildrenReplies.Enqueue(BuildReply( + children: [BuildObject(1, "Plant", isArea: true)], + childHasChildren: [true], + cacheSequence: 1)); + await using GalaxyRepositoryClient client = CreateClient(transport); + + IReadOnlyList roots = await client.BrowseAsync(); + + // Queue the failure for the upcoming ExpandAsync call so it consumes + // the exception on its first RPC rather than the BrowseAsync above. + transport.BrowseChildrenExceptions.Enqueue( + new MxGatewayException( + "Parent not found", + new RpcException(new Status(StatusCode.NotFound, "Parent not found")))); + + await Assert.ThrowsAsync(async () => await roots[0].ExpandAsync()); + } + + /// + /// Verifies that ExpandAsync drains multi-page sibling replies and forwards the page token. + /// + [Fact] + public async Task Expand_MultiPageSiblings_GathersAllPages() + { + FakeGalaxyRepositoryTransport transport = CreateTransport(); + // Roots + transport.BrowseChildrenReplies.Enqueue(BuildReply( + children: [BuildObject(7, "Plant", isArea: true)], + childHasChildren: [true], + cacheSequence: 1)); + // First child page (2 children) with a next token + BrowseChildrenReply childPage1 = BuildReply( + children: [BuildObject(70, "ChildA"), BuildObject(71, "ChildB")], + childHasChildren: [false, false], + cacheSequence: 1); + childPage1.NextPageToken = "7:abc:2"; + transport.BrowseChildrenReplies.Enqueue(childPage1); + // Second child page (1 child) with no next token + transport.BrowseChildrenReplies.Enqueue(BuildReply( + children: [BuildObject(72, "ChildC")], + childHasChildren: [false], + cacheSequence: 1)); + await using GalaxyRepositoryClient client = CreateClient(transport); + + IReadOnlyList roots = await client.BrowseAsync(); + await roots[0].ExpandAsync(); + + Assert.Equal(3, roots[0].Children.Count); + Assert.Equal(3, transport.BrowseChildrenCalls.Count); + Assert.Equal("7:abc:2", transport.BrowseChildrenCalls[2].Request.PageToken); + } + + /// + /// Verifies that BrowseChildrenOptions filter fields are forwarded to the BrowseChildren request. + /// + [Fact] + public async Task Browse_WithFilter_ForwardsToRequest() + { + FakeGalaxyRepositoryTransport transport = CreateTransport(); + await using GalaxyRepositoryClient client = CreateClient(transport); + + await client.BrowseAsync(new BrowseChildrenOptions + { + TagNameGlob = "Mixer*", + AlarmBearingOnly = true, + }); + + BrowseChildrenRequest request = Assert.Single(transport.BrowseChildrenCalls).Request; + Assert.Equal("Mixer*", request.TagNameGlob); + Assert.True(request.AlarmBearingOnly); + } + + private static GalaxyObject BuildObject(int id, string tag, bool isArea = false) + => new() { GobjectId = id, TagName = tag, BrowseName = tag, IsArea = isArea }; + + private static BrowseChildrenReply BuildReply( + IReadOnlyList children, + IReadOnlyList childHasChildren, + ulong cacheSequence) + { + BrowseChildrenReply reply = new() { TotalChildCount = children.Count, CacheSequence = cacheSequence }; + reply.Children.AddRange(children); + reply.ChildHasChildren.AddRange(childHasChildren); + return reply; + } + + private static GalaxyRepositoryClient CreateClient(FakeGalaxyRepositoryTransport transport) + => new(transport.Options, transport); + + private static FakeGalaxyRepositoryTransport CreateTransport() + => new(new MxGatewayClientOptions + { + Endpoint = new Uri("http://localhost:5000"), + ApiKey = "test-api-key", + }); +} diff --git a/clients/dotnet/ZB.MOM.WW.MxGateway.Client/BrowseChildrenOptions.cs b/clients/dotnet/ZB.MOM.WW.MxGateway.Client/BrowseChildrenOptions.cs new file mode 100644 index 0000000..ea0b01c --- /dev/null +++ b/clients/dotnet/ZB.MOM.WW.MxGateway.Client/BrowseChildrenOptions.cs @@ -0,0 +1,26 @@ +namespace ZB.MOM.WW.MxGateway.Client; + +/// +/// Filters and shape options for . +/// Mirror of for the lazy-browse path. +/// +public sealed class BrowseChildrenOptions +{ + /// Restrict to children whose Galaxy category is in this set. + public IReadOnlyList CategoryIds { get; init; } = []; + + /// Restrict to children whose template chain contains any of these tokens. + public IReadOnlyList TemplateChainContains { get; init; } = []; + + /// Optional glob-style filter on tag_name. + public string? TagNameGlob { get; init; } + + /// Whether to populate each GalaxyObject.Attributes. Null leaves the server default. + public bool? IncludeAttributes { get; init; } + + /// Restrict to children that bear at least one alarm attribute. + public bool AlarmBearingOnly { get; init; } + + /// Restrict to children that have at least one historized attribute. + public bool HistorizedOnly { get; init; } +} diff --git a/clients/dotnet/ZB.MOM.WW.MxGateway.Client/GalaxyRepositoryClient.cs b/clients/dotnet/ZB.MOM.WW.MxGateway.Client/GalaxyRepositoryClient.cs index 37eb422..ac5029b 100644 --- a/clients/dotnet/ZB.MOM.WW.MxGateway.Client/GalaxyRepositoryClient.cs +++ b/clients/dotnet/ZB.MOM.WW.MxGateway.Client/GalaxyRepositoryClient.cs @@ -19,6 +19,7 @@ namespace ZB.MOM.WW.MxGateway.Client; public sealed class GalaxyRepositoryClient : IAsyncDisposable { private const int DiscoverHierarchyPageSize = 5000; + private const int BrowseChildrenPageSize = 500; private readonly GrpcChannel? _channel; private readonly IGalaxyRepositoryClientTransport _transport; @@ -278,6 +279,89 @@ public sealed class GalaxyRepositoryClient : IAsyncDisposable cancellationToken); } + /// Returns root-level browse nodes (objects with no parent). + /// Cancellation token. + /// The list of root instances. + public Task> BrowseAsync(CancellationToken cancellationToken = default) + => BrowseAsync(null, cancellationToken); + + /// Returns root-level browse nodes filtered by the given options. + /// Browse filter options. Null applies no filter. + /// Cancellation token. + /// The list of root instances. + public async Task> BrowseAsync( + BrowseChildrenOptions? options, + CancellationToken cancellationToken = default) + { + BrowseChildrenOptions effective = options ?? new BrowseChildrenOptions(); + List roots = []; + string pageToken = string.Empty; + HashSet seenPageTokens = new(StringComparer.Ordinal); + do + { + BrowseChildrenRequest request = BuildBrowseChildrenRequest(effective); + request.PageToken = pageToken; + BrowseChildrenReply reply = await BrowseChildrenRawAsync(request, cancellationToken).ConfigureAwait(false); + + for (int i = 0; i < reply.Children.Count; i++) + { + bool hint = i < reply.ChildHasChildren.Count && reply.ChildHasChildren[i]; + roots.Add(new LazyBrowseNode(this, reply.Children[i], hint, effective)); + } + + pageToken = reply.NextPageToken; + if (!string.IsNullOrWhiteSpace(pageToken) && !seenPageTokens.Add(pageToken)) + { + throw new MxGatewayException( + $"Galaxy BrowseChildren returned a repeated page token '{pageToken}'."); + } + } + while (!string.IsNullOrWhiteSpace(pageToken)); + + return roots; + } + + /// Issues a raw BrowseChildren RPC without result wrapping. + /// The browse-children request. + /// Cancellation token. + /// The raw server reply. + public Task BrowseChildrenRawAsync( + BrowseChildrenRequest request, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + ThrowIfDisposed(); + + return ExecuteSafeUnaryAsync( + token => _transport.BrowseChildrenAsync(request, CreateCallOptions(token)), + cancellationToken); + } + + internal static BrowseChildrenRequest BuildBrowseChildrenRequest(BrowseChildrenOptions options) + { + ArgumentNullException.ThrowIfNull(options); + + BrowseChildrenRequest request = new() + { + PageSize = BrowseChildrenPageSize, + AlarmBearingOnly = options.AlarmBearingOnly, + HistorizedOnly = options.HistorizedOnly, + }; + request.CategoryIds.Add(options.CategoryIds); + request.TemplateChainContains.Add(options.TemplateChainContains); + if (!string.IsNullOrWhiteSpace(options.TagNameGlob)) + { + request.TagNameGlob = options.TagNameGlob; + } + + if (options.IncludeAttributes.HasValue) + { + request.IncludeAttributes = options.IncludeAttributes.Value; + } + + return request; + } + /// /// Subscribes to Galaxy deploy events. The server emits a bootstrap event with the /// current state on subscribe so callers can prime their cache, then emits one event diff --git a/clients/dotnet/ZB.MOM.WW.MxGateway.Client/GrpcGalaxyRepositoryClientTransport.cs b/clients/dotnet/ZB.MOM.WW.MxGateway.Client/GrpcGalaxyRepositoryClientTransport.cs index 066ab71..e51337e 100644 --- a/clients/dotnet/ZB.MOM.WW.MxGateway.Client/GrpcGalaxyRepositoryClientTransport.cs +++ b/clients/dotnet/ZB.MOM.WW.MxGateway.Client/GrpcGalaxyRepositoryClientTransport.cs @@ -74,6 +74,23 @@ internal sealed class GrpcGalaxyRepositoryClientTransport( } } + /// + public async Task BrowseChildrenAsync( + BrowseChildrenRequest request, + CallOptions callOptions) + { + try + { + return await RawClient.BrowseChildrenAsync(request, callOptions) + .ResponseAsync + .ConfigureAwait(false); + } + catch (RpcException exception) + { + throw MapRpcException(exception, callOptions.CancellationToken); + } + } + /// public async IAsyncEnumerable WatchDeployEventsAsync( WatchDeployEventsRequest request, diff --git a/clients/dotnet/ZB.MOM.WW.MxGateway.Client/IGalaxyRepositoryClientTransport.cs b/clients/dotnet/ZB.MOM.WW.MxGateway.Client/IGalaxyRepositoryClientTransport.cs index 4c7a42a..ddc5dcc 100644 --- a/clients/dotnet/ZB.MOM.WW.MxGateway.Client/IGalaxyRepositoryClientTransport.cs +++ b/clients/dotnet/ZB.MOM.WW.MxGateway.Client/IGalaxyRepositoryClientTransport.cs @@ -33,6 +33,13 @@ internal interface IGalaxyRepositoryClientTransport DiscoverHierarchyRequest request, CallOptions callOptions); + /// Returns direct children of a parent in the Galaxy hierarchy. + /// The browse children request. + /// gRPC call options (timeout, cancellation, etc.). + Task BrowseChildrenAsync( + BrowseChildrenRequest request, + CallOptions callOptions); + /// Watches for deployment events from the Galaxy Repository server. /// The watch deploy events request. /// gRPC call options (timeout, cancellation, etc.). diff --git a/clients/dotnet/ZB.MOM.WW.MxGateway.Client/LazyBrowseNode.cs b/clients/dotnet/ZB.MOM.WW.MxGateway.Client/LazyBrowseNode.cs new file mode 100644 index 0000000..35e3bb9 --- /dev/null +++ b/clients/dotnet/ZB.MOM.WW.MxGateway.Client/LazyBrowseNode.cs @@ -0,0 +1,83 @@ +using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy; + +namespace ZB.MOM.WW.MxGateway.Client; + +/// +/// One node in a lazy-loaded Galaxy browse tree. Holds the underlying +/// and exposes to fetch +/// its direct children on demand. Expansion is one-shot: a second call is a +/// no-op. Pagination of large sibling sets is handled internally. +/// +public sealed class LazyBrowseNode +{ + private readonly GalaxyRepositoryClient _client; + private readonly BrowseChildrenOptions _options; + private readonly List _children = []; + private bool _isExpanded; + + internal LazyBrowseNode( + GalaxyRepositoryClient client, + GalaxyObject @object, + bool hasChildrenHint, + BrowseChildrenOptions options) + { + _client = client; + Object = @object; + HasChildrenHint = hasChildrenHint; + _options = options; + } + + /// The underlying Galaxy object for this node. + public GalaxyObject Object { get; } + + /// True when the server reports this node has at least one matching descendant. + public bool HasChildrenHint { get; } + + /// Direct children loaded by ; empty until then. + public IReadOnlyList Children => _children; + + /// True after the first call completes. + public bool IsExpanded => _isExpanded; + + /// + /// Fetches direct children from the gateway and populates . + /// Idempotent: subsequent calls are no-ops. + /// + /// Token to observe for cancellation. + public async Task ExpandAsync(CancellationToken cancellationToken = default) + { + if (_isExpanded) + { + return; + } + + string pageToken = string.Empty; + HashSet seenPageTokens = new(StringComparer.Ordinal); + do + { + BrowseChildrenRequest request = GalaxyRepositoryClient.BuildBrowseChildrenRequest(_options); + request.ParentGobjectId = Object.GobjectId; + request.PageToken = pageToken; + + BrowseChildrenReply reply = await _client + .BrowseChildrenRawAsync(request, cancellationToken) + .ConfigureAwait(false); + + for (int i = 0; i < reply.Children.Count; i++) + { + bool hint = i < reply.ChildHasChildren.Count && reply.ChildHasChildren[i]; + _children.Add(new LazyBrowseNode(_client, reply.Children[i], hint, _options)); + } + + pageToken = reply.NextPageToken; + if (!string.IsNullOrWhiteSpace(pageToken) && !seenPageTokens.Add(pageToken)) + { + throw new MxGatewayException( + $"Galaxy BrowseChildren returned a repeated page token '{pageToken}'."); + } + } + while (!string.IsNullOrWhiteSpace(pageToken)); + + _isExpanded = true; + } +}