dashboard: lazy-load BrowsePage via DashboardBrowseService

This commit is contained in:
Joseph Doherty
2026-05-28 13:10:10 -04:00
parent ba157b4b4f
commit 310dfab8b4
7 changed files with 444 additions and 12 deletions
@@ -0,0 +1,141 @@
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
using ZB.MOM.WW.MxGateway.Server.Dashboard;
using ZB.MOM.WW.MxGateway.Server.Galaxy;
namespace ZB.MOM.WW.MxGateway.Tests.Dashboard;
/// <summary>
/// Coverage for <see cref="DashboardBrowseService"/> — the in-process facade the
/// Blazor BrowsePage uses to walk the Galaxy hierarchy one level at a time. The
/// service must surface the projector's <c>child_has_children</c> hint, expose the
/// current cache sequence, and translate the projector's
/// <see cref="Grpc.Core.RpcException"/> on unknown parent into a friendly error
/// rather than propagating it into the Blazor circuit.
/// </summary>
public sealed class DashboardBrowseServiceTests
{
/// <summary>Verifies that <see cref="DashboardBrowseService.GetRoots"/> returns root-level
/// objects with the <c>HasChildrenHint</c> projector bit set, and reports the cache
/// sequence of the entry it projected from.</summary>
[Fact]
public void GetRoots_ReturnsRootObjects_WithHasChildrenHint()
{
StubGalaxyHierarchyCache cache = new(CreateEntry(CreateObjects(), sequence: 11));
DashboardBrowseService service = new(cache);
BrowseLevelResult result = service.GetRoots(new BrowseFilterArgs());
Assert.Single(result.Nodes);
Assert.Equal("Plant", result.Nodes[0].Object.TagName);
Assert.True(result.Nodes[0].HasChildrenHint);
Assert.Equal(11UL, result.CacheSequence);
Assert.Null(result.Error);
}
/// <summary>Verifies that <see cref="DashboardBrowseService.GetChildren"/> returns the
/// direct children of the requested parent and that leaf nodes report
/// <c>HasChildrenHint == false</c>.</summary>
[Fact]
public void GetChildren_ByParentGobjectId_ReturnsDirectChildren()
{
StubGalaxyHierarchyCache cache = new(CreateEntry(CreateObjects(), sequence: 11));
DashboardBrowseService service = new(cache);
BrowseLevelResult result = service.GetChildren(parentGobjectId: 1, new BrowseFilterArgs());
Assert.Single(result.Nodes);
Assert.Equal("Mixer_001", result.Nodes[0].Object.TagName);
Assert.False(result.Nodes[0].HasChildrenHint);
Assert.Null(result.Error);
}
/// <summary>Verifies that an unknown parent id does not surface the projector's
/// <see cref="Grpc.Core.RpcException"/> — the service catches the NotFound and
/// returns an empty <see cref="BrowseLevelResult"/> with the error string set.</summary>
[Fact]
public void GetChildren_UnknownParent_ReturnsEmptyResultWithErrorFlag()
{
StubGalaxyHierarchyCache cache = new(CreateEntry(CreateObjects(), sequence: 11));
DashboardBrowseService service = new(cache);
BrowseLevelResult result = service.GetChildren(parentGobjectId: 999, new BrowseFilterArgs());
Assert.Empty(result.Nodes);
Assert.NotNull(result.Error);
Assert.False(string.IsNullOrEmpty(result.Error));
}
/// <summary>Verifies that swapping the cache's <c>Current</c> entry (as the refresh loop
/// does after a deploy bump) causes subsequent queries to observe the new sequence.</summary>
[Fact]
public void CacheSequence_AdvancesAfterRefresh_NewQueriesReflectIt()
{
StubGalaxyHierarchyCache cache = new(CreateEntry(CreateObjects(), sequence: 11));
DashboardBrowseService service = new(cache);
BrowseLevelResult before = service.GetRoots(new BrowseFilterArgs());
Assert.Equal(11UL, before.CacheSequence);
cache.Current = CreateEntry(CreateObjects(), sequence: 12);
BrowseLevelResult after = service.GetRoots(new BrowseFilterArgs());
Assert.Equal(12UL, after.CacheSequence);
}
private static IReadOnlyList<GalaxyObject> CreateObjects()
{
// Fixture: an Area "Plant" (id 1, parent 0, IsArea=true) containing one
// Instance "Mixer_001" (id 2, parent 1). Both with no attributes — the
// service is exercised through the projector, which only needs id /
// parent / IsArea / display name to build the level slice.
return
[
new GalaxyObject
{
GobjectId = 1,
ParentGobjectId = 0,
TagName = "Plant",
BrowseName = "Plant",
IsArea = true,
},
new GalaxyObject
{
GobjectId = 2,
ParentGobjectId = 1,
TagName = "Mixer_001",
BrowseName = "Mixer_001",
IsArea = false,
},
];
}
private static GalaxyHierarchyCacheEntry CreateEntry(IReadOnlyList<GalaxyObject> objects, long sequence)
{
return GalaxyHierarchyCacheEntry.Empty with
{
Status = GalaxyCacheStatus.Healthy,
Sequence = sequence,
LastSuccessAt = DateTimeOffset.UtcNow,
Objects = objects,
Index = GalaxyHierarchyIndex.Build(objects),
DashboardSummary = DashboardGalaxySummary.Unknown with
{
Status = DashboardGalaxyStatus.Healthy,
ObjectCount = objects.Count,
},
ObjectCount = objects.Count,
};
}
private sealed class StubGalaxyHierarchyCache(GalaxyHierarchyCacheEntry initial) : IGalaxyHierarchyCache
{
/// <summary>Mutable so a single test can swap the entry mid-flight.</summary>
public GalaxyHierarchyCacheEntry Current { get; set; } = initial;
/// <inheritdoc />
public Task RefreshAsync(CancellationToken cancellationToken) => Task.CompletedTask;
/// <inheritdoc />
public Task WaitForFirstLoadAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}
}