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()); Assert.False(roots[0].IsExpanded); Assert.Empty(roots[0].Children); } /// /// 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 ten concurrent ExpandAsync calls issue exactly one RPC, not ten. /// [Fact] public async Task Expand_CalledConcurrently_OnlyFiresOneRpc() { FakeGalaxyRepositoryTransport transport = CreateTransport(); transport.BrowseChildrenReplies.Enqueue(BuildReply( children: [BuildObject(1, "Plant", isArea: true)], childHasChildren: [true], cacheSequence: 7)); transport.BrowseChildrenReplies.Enqueue(BuildReply( children: [BuildObject(2, "Mixer_001")], childHasChildren: [false], cacheSequence: 7)); await using GalaxyRepositoryClient client = CreateClient(transport); IReadOnlyList roots = await client.BrowseAsync(); // Fire ten concurrent expands of the same node. Task[] tasks = Enumerable.Range(0, 10) .Select(_ => roots[0].ExpandAsync()) .ToArray(); await Task.WhenAll(tasks); Assert.True(roots[0].IsExpanded); Assert.Single(roots[0].Children); // 1 roots fetch + exactly 1 expand fetch = 2 total Assert.Equal(2, transport.BrowseChildrenCalls.Count); } /// /// Verifies that reading Children/IsExpanded concurrently with an in-flight ExpandAsync /// never throws (no torn enumeration of a mid-append list) and, once IsExpanded flips to /// true, the published Children snapshot is fully populated. Pins the safe-publication /// contract on the lock-free readers (Client.Dotnet-025). /// [Fact] public async Task Expand_ConcurrentReadOfChildren_NeverTearsAndPublishesAtomically() { FakeGalaxyRepositoryTransport transport = CreateTransport(); transport.BrowseChildrenReplies.Enqueue(BuildReply( children: [BuildObject(1, "Plant", isArea: true)], childHasChildren: [true], cacheSequence: 1)); // Multi-page child set so the expand loop spends meaningful time appending, // widening the window for a concurrent reader to observe a torn list. BrowseChildrenReply childPage1 = BuildReply( children: [BuildObject(10, "A"), BuildObject(11, "B"), BuildObject(12, "C")], childHasChildren: [false, false, false], cacheSequence: 1); childPage1.NextPageToken = "1:p:3"; transport.BrowseChildrenReplies.Enqueue(childPage1); transport.BrowseChildrenReplies.Enqueue(BuildReply( children: [BuildObject(13, "D"), BuildObject(14, "E")], childHasChildren: [false, false], cacheSequence: 1)); await using GalaxyRepositoryClient client = CreateClient(transport); IReadOnlyList roots = await client.BrowseAsync(); LazyBrowseNode node = roots[0]; // Gate the child-page RPCs so the expand stays mid-flight while the reader spins. using SemaphoreSlim release = new(0, 1); bool firstChildCall = true; transport.BrowseChildrenGate = async () => { if (firstChildCall) { firstChildCall = false; await release.WaitAsync().ConfigureAwait(false); } }; using CancellationTokenSource readerStop = new(); Exception? readerFailure = null; Task reader = Task.Run(() => { try { while (!readerStop.IsCancellationRequested) { bool expanded = node.IsExpanded; // Enumerate the snapshot; a torn/mid-append list would throw here. int count = 0; foreach (LazyBrowseNode _ in node.Children) { count++; } // If the node reports expanded, the published snapshot must be complete. if (expanded) { Assert.Equal(5, count); } } } catch (Exception ex) { readerFailure = ex; } }); Task expand = node.ExpandAsync(); // Let the reader spin against the empty pre-publication snapshot for a moment. await Task.Delay(50); release.Release(); await expand; // Let the reader observe the post-publication state, then stop it. await Task.Delay(50); readerStop.Cancel(); await reader; Assert.Null(readerFailure); Assert.True(node.IsExpanded); Assert.Equal(5, node.Children.Count); } /// /// 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", }); }