312 lines
12 KiB
C#
312 lines
12 KiB
C#
using Grpc.Core;
|
|
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
|
|
|
|
namespace ZB.MOM.WW.MxGateway.Client.Tests;
|
|
|
|
/// <summary>
|
|
/// Tests for the <see cref="LazyBrowseNode"/> walker over the BrowseChildren RPC.
|
|
/// </summary>
|
|
public sealed class LazyBrowseNodeTests
|
|
{
|
|
/// <summary>
|
|
/// Verifies that calling BrowseAsync with no parent returns the root nodes
|
|
/// from the first BrowseChildren reply and surfaces the per-child has-children hint.
|
|
/// </summary>
|
|
[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<LazyBrowseNode> 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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that ExpandAsync populates Children and marks the node expanded after one RPC.
|
|
/// </summary>
|
|
[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<LazyBrowseNode> 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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that a second ExpandAsync call is a no-op and issues no additional RPC.
|
|
/// </summary>
|
|
[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<LazyBrowseNode> roots = await client.BrowseAsync();
|
|
await roots[0].ExpandAsync();
|
|
await roots[0].ExpandAsync();
|
|
|
|
Assert.Equal(2, transport.BrowseChildrenCalls.Count);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that an RPC failure (NotFound) during expand is wrapped in MxGatewayException.
|
|
/// </summary>
|
|
[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<LazyBrowseNode> 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<MxGatewayException>(async () => await roots[0].ExpandAsync());
|
|
Assert.False(roots[0].IsExpanded);
|
|
Assert.Empty(roots[0].Children);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that ExpandAsync drains multi-page sibling replies and forwards the page token.
|
|
/// </summary>
|
|
[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<LazyBrowseNode> 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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that ten concurrent ExpandAsync calls issue exactly one RPC, not ten.
|
|
/// </summary>
|
|
[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<LazyBrowseNode> 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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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).
|
|
/// </summary>
|
|
[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<LazyBrowseNode> 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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that BrowseChildrenOptions filter fields are forwarded to the BrowseChildren request.
|
|
/// </summary>
|
|
[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<GalaxyObject> children,
|
|
IReadOnlyList<bool> 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",
|
|
});
|
|
}
|