dashboard: lazy-load BrowsePage via DashboardBrowseService
This commit is contained in:
@@ -2,6 +2,8 @@
|
||||
@implements IAsyncDisposable
|
||||
@inject IGalaxyHierarchyCache GalaxyCache
|
||||
@inject IDashboardLiveDataService LiveData
|
||||
@inject IDashboardBrowseService BrowseService
|
||||
@inject IGalaxyDeployNotifier DeployNotifier
|
||||
@using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy
|
||||
@using ZB.MOM.WW.MxGateway.Server.Galaxy
|
||||
|
||||
@@ -71,12 +73,18 @@
|
||||
}
|
||||
else
|
||||
{
|
||||
@if (!string.IsNullOrEmpty(_staleBanner))
|
||||
{
|
||||
<div class="alert alert-info browse-stale-banner" role="status"
|
||||
@onclick="ClearStaleBanner">@_staleBanner</div>
|
||||
}
|
||||
<div class="browse-tree">
|
||||
@foreach (DashboardBrowseNode root in _roots)
|
||||
{
|
||||
<BrowseTreeNodeView Node="root"
|
||||
OnAddTag="AddTagAsync"
|
||||
OnTagContextMenu="OnTagContextMenu" />
|
||||
OnTagContextMenu="OnTagContextMenu"
|
||||
OnLoadChildren="LoadChildrenAsync" />
|
||||
}
|
||||
</div>
|
||||
<div class="browse-search-note">Double-click a tag, or right-click for the menu.</div>
|
||||
@@ -186,7 +194,11 @@
|
||||
@code {
|
||||
private const int SearchResultLimit = 300;
|
||||
|
||||
private IReadOnlyList<DashboardBrowseNode> _roots = [];
|
||||
private List<DashboardBrowseNode> _roots = [];
|
||||
private ulong _cacheSequence;
|
||||
private string? _staleBanner;
|
||||
private CancellationTokenSource _deployCts = new();
|
||||
private Task? _deployTask;
|
||||
private string _search = string.Empty;
|
||||
private IReadOnlyList<GalaxyAttribute> _searchMatches = [];
|
||||
private readonly List<string> _subscribed = [];
|
||||
@@ -210,8 +222,58 @@
|
||||
/// <inheritdoc />
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
_roots = DashboardBrowseTreeBuilder.Build(GalaxyCache.Current.Objects);
|
||||
BrowseLevelResult roots = BrowseService.GetRoots(new BrowseFilterArgs());
|
||||
_roots = [.. roots.Nodes];
|
||||
_cacheSequence = roots.CacheSequence;
|
||||
_pollTask = PollLoopAsync();
|
||||
_deployTask = SubscribeToDeployEventsAsync();
|
||||
}
|
||||
|
||||
private async Task LoadChildrenAsync(DashboardBrowseNode node)
|
||||
{
|
||||
BrowseLevelResult result = BrowseService.GetChildren(node.Object.GobjectId, new BrowseFilterArgs());
|
||||
node.Children.Clear();
|
||||
foreach (DashboardBrowseNode child in result.Nodes)
|
||||
{
|
||||
node.Children.Add(child);
|
||||
}
|
||||
|
||||
// First expand interaction also dismisses the stale banner — the user
|
||||
// is clearly engaging with the tree, no need to keep nagging.
|
||||
_staleBanner = null;
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
private async Task SubscribeToDeployEventsAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
await foreach (GalaxyDeployEventInfo info in DeployNotifier
|
||||
.SubscribeAsync(_deployCts.Token)
|
||||
.ConfigureAwait(false))
|
||||
{
|
||||
// First Latest replay echoes the sequence we already projected
|
||||
// from — skip those to avoid a spurious "redeployed" banner.
|
||||
if (info.Sequence == 0 || (ulong)info.Sequence == _cacheSequence)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
BrowseLevelResult roots = BrowseService.GetRoots(new BrowseFilterArgs());
|
||||
_roots = [.. roots.Nodes];
|
||||
_cacheSequence = roots.CacheSequence;
|
||||
_staleBanner = "Galaxy redeployed — tree refreshed.";
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private void ClearStaleBanner()
|
||||
{
|
||||
_staleBanner = null;
|
||||
}
|
||||
|
||||
private string HeaderLine()
|
||||
@@ -405,6 +467,7 @@
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await _cts.CancelAsync();
|
||||
await _deployCts.CancelAsync();
|
||||
if (_pollTask is not null)
|
||||
{
|
||||
try
|
||||
@@ -415,8 +478,19 @@
|
||||
{
|
||||
}
|
||||
}
|
||||
if (_deployTask is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _deployTask;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
_cts.Dispose();
|
||||
_deployCts.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
|
||||
+72
-9
@@ -2,15 +2,21 @@
|
||||
|
||||
@*
|
||||
Recursive Browse hierarchy node. Renders one Galaxy object, its child
|
||||
objects (recursively), and its attributes as right-clickable tag rows.
|
||||
Expansion state is local; children render only while expanded.
|
||||
objects (recursively, lazy-loaded on first expand), and its attributes as
|
||||
right-clickable tag rows. Expansion state is local; children render only
|
||||
while expanded.
|
||||
|
||||
The expand triangle is shown whenever the server's child_has_children
|
||||
projector hint is set (HasChildrenHint), even before children have been
|
||||
loaded — clicking it triggers OnLoadChildren so the parent page can fill
|
||||
in Node.Children, then the view re-renders.
|
||||
*@
|
||||
|
||||
<div class="tree-node">
|
||||
<div class="tree-row @(Node.IsArea ? "tree-row-area" : "tree-row-object")">
|
||||
@if (Node.HasChildren)
|
||||
@if (ShowToggle())
|
||||
{
|
||||
<button type="button" class="tree-toggle" @onclick="Toggle" aria-label="Toggle">
|
||||
<button type="button" class="tree-toggle" @onclick="ToggleAsync" aria-label="Toggle">
|
||||
@(_expanded ? "▾" : "▸")
|
||||
</button>
|
||||
}
|
||||
@@ -18,7 +24,7 @@
|
||||
{
|
||||
<span class="tree-toggle tree-toggle-empty"></span>
|
||||
}
|
||||
<span class="tree-label" @onclick="Toggle">
|
||||
<span class="tree-label" @onclick="ToggleAsync">
|
||||
<span class="tree-icon">@(Node.IsArea ? "▣" : "◇")</span>
|
||||
<span class="tree-name">@Node.DisplayName</span>
|
||||
@if (!string.IsNullOrWhiteSpace(Node.Object.TagName)
|
||||
@@ -31,9 +37,27 @@
|
||||
@if (_expanded)
|
||||
{
|
||||
<div class="tree-children">
|
||||
@if (Node.LoadState == BrowseLoadState.Loading)
|
||||
{
|
||||
<div class="tree-load-status text-secondary">
|
||||
<span class="tree-toggle tree-toggle-empty"></span>
|
||||
<span>⌛ Loading…</span>
|
||||
</div>
|
||||
}
|
||||
else if (Node.LoadState == BrowseLoadState.Error)
|
||||
{
|
||||
<div class="tree-load-status text-danger">
|
||||
<span class="tree-toggle tree-toggle-empty"></span>
|
||||
<span>Failed to load: @Node.LoadError</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@foreach (DashboardBrowseNode child in Node.Children)
|
||||
{
|
||||
<BrowseTreeNodeView Node="child" OnAddTag="OnAddTag" OnTagContextMenu="OnTagContextMenu" />
|
||||
<BrowseTreeNodeView Node="child"
|
||||
OnAddTag="OnAddTag"
|
||||
OnTagContextMenu="OnTagContextMenu"
|
||||
OnLoadChildren="OnLoadChildren" />
|
||||
}
|
||||
@foreach (GalaxyAttribute attr in Node.Attributes)
|
||||
{
|
||||
@@ -75,13 +99,52 @@
|
||||
[Parameter]
|
||||
public EventCallback<(MouseEventArgs Event, GalaxyAttribute Attribute)> OnTagContextMenu { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Invoked on first expand when the projector hint says this node has children
|
||||
/// but they have not been fetched yet. The callback is expected to populate
|
||||
/// <see cref="DashboardBrowseNode.Children"/> on the node it receives and then
|
||||
/// trigger a re-render.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public Func<DashboardBrowseNode, Task>? OnLoadChildren { get; set; }
|
||||
|
||||
private bool _expanded;
|
||||
|
||||
private void Toggle()
|
||||
// The triangle is shown whenever the projector says children exist (even
|
||||
// pre-load), or attributes are already present, or already-loaded children
|
||||
// are sitting on the node.
|
||||
private bool ShowToggle()
|
||||
{
|
||||
if (Node.HasChildren)
|
||||
return Node.HasChildrenHint
|
||||
|| Node.Attributes.Count > 0
|
||||
|| Node.Children.Count > 0;
|
||||
}
|
||||
|
||||
private async Task ToggleAsync()
|
||||
{
|
||||
if (!ShowToggle())
|
||||
{
|
||||
_expanded = !_expanded;
|
||||
return;
|
||||
}
|
||||
|
||||
_expanded = !_expanded;
|
||||
|
||||
if (_expanded
|
||||
&& Node.HasChildrenHint
|
||||
&& Node.LoadState == BrowseLoadState.NotLoaded
|
||||
&& OnLoadChildren is not null)
|
||||
{
|
||||
Node.LoadState = BrowseLoadState.Loading;
|
||||
try
|
||||
{
|
||||
await OnLoadChildren(Node);
|
||||
Node.LoadState = BrowseLoadState.Loaded;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Node.LoadState = BrowseLoadState.Error;
|
||||
Node.LoadError = ex.Message;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,33 @@ public sealed class DashboardBrowseNode
|
||||
|
||||
/// <summary>True when the node has child objects or attributes to expand.</summary>
|
||||
public bool HasChildren => Children.Count > 0 || Object.Attributes.Count > 0;
|
||||
|
||||
/// <summary>Whether this node has at least one matching descendant, per the
|
||||
/// server's <c>child_has_children</c> projector hint. Controls whether the UI
|
||||
/// shows an expand triangle before children have actually loaded.</summary>
|
||||
public bool HasChildrenHint { get; init; }
|
||||
|
||||
/// <summary>The lazy-load state for this node's children.</summary>
|
||||
public BrowseLoadState LoadState { get; set; } = BrowseLoadState.NotLoaded;
|
||||
|
||||
/// <summary>Short error string if the last load attempt failed; null otherwise.</summary>
|
||||
public string? LoadError { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Lazy-load lifecycle of a browse node's children.</summary>
|
||||
public enum BrowseLoadState
|
||||
{
|
||||
/// <summary>Children have not been requested yet.</summary>
|
||||
NotLoaded,
|
||||
|
||||
/// <summary>A load is in progress.</summary>
|
||||
Loading,
|
||||
|
||||
/// <summary>Children have been loaded into <see cref="DashboardBrowseNode.Children"/>.</summary>
|
||||
Loaded,
|
||||
|
||||
/// <summary>The last load attempt failed; see <see cref="DashboardBrowseNode.LoadError"/>.</summary>
|
||||
Error,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
using Grpc.Core;
|
||||
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
|
||||
using ZB.MOM.WW.MxGateway.Server.Galaxy;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||
|
||||
/// <summary>
|
||||
/// Default <see cref="IDashboardBrowseService"/>. Delegates to
|
||||
/// <see cref="GalaxyBrowseProjector"/> via the shared
|
||||
/// <see cref="IGalaxyHierarchyCache"/>; no SQL hop, no gRPC self-call. Translates
|
||||
/// the projector's <see cref="RpcException"/> on unknown parent into a friendly
|
||||
/// <see cref="BrowseLevelResult.Error"/> so the Blazor circuit does not see an
|
||||
/// unhandled exception.
|
||||
/// </summary>
|
||||
public sealed class DashboardBrowseService(IGalaxyHierarchyCache cache) : IDashboardBrowseService
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public ulong CurrentCacheSequence => (ulong)cache.Current.Sequence;
|
||||
|
||||
/// <inheritdoc />
|
||||
public BrowseLevelResult GetRoots(BrowseFilterArgs filter)
|
||||
=> ProjectLevel(parentId: null, filter);
|
||||
|
||||
/// <inheritdoc />
|
||||
public BrowseLevelResult GetChildren(int parentGobjectId, BrowseFilterArgs filter)
|
||||
=> ProjectLevel(parentId: parentGobjectId, filter);
|
||||
|
||||
private BrowseLevelResult ProjectLevel(int? parentId, BrowseFilterArgs filter)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(filter);
|
||||
|
||||
GalaxyHierarchyCacheEntry entry = cache.Current;
|
||||
if (!entry.HasData)
|
||||
{
|
||||
return new BrowseLevelResult(
|
||||
Array.Empty<DashboardBrowseNode>(),
|
||||
0,
|
||||
(ulong)entry.Sequence,
|
||||
Error: "Galaxy hierarchy is not loaded yet.");
|
||||
}
|
||||
|
||||
BrowseChildrenRequest request = new()
|
||||
{
|
||||
TagNameGlob = filter.TagNameGlob ?? string.Empty,
|
||||
AlarmBearingOnly = filter.AlarmBearingOnly,
|
||||
HistorizedOnly = filter.HistorizedOnly,
|
||||
};
|
||||
if (parentId is int pid)
|
||||
{
|
||||
request.ParentGobjectId = pid;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
GalaxyBrowseChildrenResult result = GalaxyBrowseProjector.ProjectChildren(
|
||||
entry,
|
||||
request,
|
||||
browseSubtreeGlobs: null,
|
||||
offset: 0,
|
||||
pageSize: int.MaxValue);
|
||||
|
||||
List<DashboardBrowseNode> nodes = new(result.Children.Count);
|
||||
for (int i = 0; i < result.Children.Count; i++)
|
||||
{
|
||||
nodes.Add(new DashboardBrowseNode
|
||||
{
|
||||
Object = result.Children[i],
|
||||
HasChildrenHint = result.ChildHasChildren[i],
|
||||
});
|
||||
}
|
||||
return new BrowseLevelResult(nodes, result.TotalChildCount, (ulong)entry.Sequence);
|
||||
}
|
||||
catch (RpcException ex) when (ex.StatusCode == StatusCode.NotFound)
|
||||
{
|
||||
return new BrowseLevelResult(
|
||||
Array.Empty<DashboardBrowseNode>(),
|
||||
0,
|
||||
(ulong)entry.Sequence,
|
||||
Error: ex.Status.Detail);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@ public static class DashboardServiceCollectionExtensions
|
||||
services.AddSingleton<IDashboardSessionAdminService, DashboardSessionAdminService>();
|
||||
services.AddSingleton<HubTokenService>();
|
||||
services.AddScoped<Hubs.DashboardHubConnectionFactory>();
|
||||
services.AddScoped<IDashboardBrowseService, DashboardBrowseService>();
|
||||
services.AddSingleton<Hubs.IDashboardEventBroadcaster, Hubs.DashboardEventBroadcaster>();
|
||||
services.AddHostedService<Hubs.DashboardSnapshotPublisher>();
|
||||
services.AddHostedService<Hubs.AlarmsHubPublisher>();
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
using ZB.MOM.WW.MxGateway.Server.Galaxy;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||
|
||||
/// <summary>
|
||||
/// In-process facade over <see cref="GalaxyBrowseProjector"/> for the dashboard's
|
||||
/// BrowsePage. Provides one-level-at-a-time browse without going through the
|
||||
/// gRPC stack. Backed by the same shared <see cref="IGalaxyHierarchyCache"/> the
|
||||
/// gRPC service uses, so dashboard and external clients render identical results.
|
||||
/// </summary>
|
||||
public interface IDashboardBrowseService
|
||||
{
|
||||
/// <summary>Returns root browse nodes (objects with no parent).</summary>
|
||||
/// <param name="filter">Filter arguments forwarded to the projector.</param>
|
||||
BrowseLevelResult GetRoots(BrowseFilterArgs filter);
|
||||
|
||||
/// <summary>Returns the direct children of the given parent gobject id.</summary>
|
||||
/// <param name="parentGobjectId">The Galaxy gobject id of the parent to expand.</param>
|
||||
/// <param name="filter">Filter arguments forwarded to the projector.</param>
|
||||
BrowseLevelResult GetChildren(int parentGobjectId, BrowseFilterArgs filter);
|
||||
|
||||
/// <summary>Current Galaxy cache sequence. Bumps after each successful refresh.</summary>
|
||||
ulong CurrentCacheSequence { get; }
|
||||
}
|
||||
|
||||
/// <summary>Filter arguments forwarded into the projector.</summary>
|
||||
/// <param name="TagNameGlob">Optional tag-name glob filter (case-insensitive).</param>
|
||||
/// <param name="AlarmBearingOnly">When true, only return objects with at least one alarm-bearing attribute.</param>
|
||||
/// <param name="HistorizedOnly">When true, only return objects with at least one historized attribute.</param>
|
||||
public sealed record BrowseFilterArgs(
|
||||
string? TagNameGlob = null,
|
||||
bool AlarmBearingOnly = false,
|
||||
bool HistorizedOnly = false);
|
||||
|
||||
/// <summary>One level of browse data plus the cache sequence it was projected from.</summary>
|
||||
/// <param name="Nodes">The direct-child nodes for the requested parent (or roots when no parent given).</param>
|
||||
/// <param name="TotalCount">Total matching sibling count, post-filter.</param>
|
||||
/// <param name="CacheSequence">The cache entry sequence this result was projected from.</param>
|
||||
/// <param name="Error">Friendly error string if the projection failed; null on success.</param>
|
||||
public sealed record BrowseLevelResult(
|
||||
IReadOnlyList<DashboardBrowseNode> Nodes,
|
||||
int TotalCount,
|
||||
ulong CacheSequence,
|
||||
string? Error = null);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user