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", }); }