galaxy: add GalaxyBrowseProjector for direct-children projection
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Server.Galaxy;
|
||||
|
||||
/// <summary>
|
||||
/// Result of one <see cref="GalaxyBrowseProjector.ProjectChildren"/> call. Holds a
|
||||
/// materialized page of direct children for the requested parent, along with a
|
||||
/// parallel-indexed <see cref="ChildHasChildren"/> hint and the total post-filter
|
||||
/// sibling count for paging.
|
||||
/// </summary>
|
||||
/// <param name="Children">The page of direct children, sorted areas-first then by display name.</param>
|
||||
/// <param name="ChildHasChildren">Parallel array indicating whether each child has at least one matching descendant under the same filter set.</param>
|
||||
/// <param name="TotalChildCount">Total matching direct children of the parent (post-filter).</param>
|
||||
/// <param name="FilterSignature">Stable signature of the filter and parent selector, used to bind page tokens.</param>
|
||||
public sealed record GalaxyBrowseChildrenResult(
|
||||
IReadOnlyList<GalaxyObject> Children,
|
||||
IReadOnlyList<bool> ChildHasChildren,
|
||||
int TotalChildCount,
|
||||
string FilterSignature);
|
||||
@@ -0,0 +1,266 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Grpc.Core;
|
||||
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Server.Galaxy;
|
||||
|
||||
/// <summary>
|
||||
/// Projects one level of children of a parent object out of an immutable
|
||||
/// <see cref="GalaxyHierarchyCacheEntry"/>. Pure and side-effect free. Memoizes the
|
||||
/// filtered child list per cache-entry instance so repeated paging is an O(pageSize)
|
||||
/// slice rather than an O(siblings) filter scan per page. The memo is keyed on the
|
||||
/// immutable cache entry, so when the cache publishes a new entry the stale memo
|
||||
/// becomes unreachable and is reclaimed with it.
|
||||
/// </summary>
|
||||
public static class GalaxyBrowseProjector
|
||||
{
|
||||
private static readonly ConditionalWeakTable<
|
||||
GalaxyHierarchyCacheEntry,
|
||||
ConcurrentDictionary<string, FilteredChildren>> FilteredChildrenCache = new();
|
||||
|
||||
/// <summary>Projects one page of direct children of the resolved parent.</summary>
|
||||
/// <param name="entry">The Galaxy hierarchy cache entry to query.</param>
|
||||
/// <param name="request">The browse-children request.</param>
|
||||
/// <param name="browseSubtreeGlobs">Optional API-key browse-subtree constraints.</param>
|
||||
/// <param name="offset">Zero-based offset into the filtered child list.</param>
|
||||
/// <param name="pageSize">Maximum number of children to return.</param>
|
||||
public static GalaxyBrowseChildrenResult ProjectChildren(
|
||||
GalaxyHierarchyCacheEntry entry,
|
||||
BrowseChildrenRequest request,
|
||||
IReadOnlyList<string>? browseSubtreeGlobs,
|
||||
int offset,
|
||||
int pageSize)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entry);
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
if (offset < 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(offset), offset, "Offset must be greater than or equal to zero.");
|
||||
}
|
||||
if (pageSize <= 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(pageSize), pageSize, "Page size must be greater than zero.");
|
||||
}
|
||||
|
||||
int parentId = ResolveParentId(entry, request);
|
||||
string filterSignature = ComputeFilterSignature(request, browseSubtreeGlobs, parentId);
|
||||
FilteredChildren filtered = GetFilteredChildren(entry, request, browseSubtreeGlobs, parentId, filterSignature);
|
||||
|
||||
bool includeAttributes = IncludeAttributes(request);
|
||||
int end = (int)Math.Min((long)offset + pageSize, filtered.Children.Count);
|
||||
List<GalaxyObject> page = new(Math.Max(0, end - offset));
|
||||
List<bool> hasChildren = new(Math.Max(0, end - offset));
|
||||
for (int index = offset; index < end; index++)
|
||||
{
|
||||
page.Add(CloneObject(filtered.Children[index].Object, includeAttributes));
|
||||
hasChildren.Add(filtered.HasMatchingDescendant[index]);
|
||||
}
|
||||
|
||||
return new GalaxyBrowseChildrenResult(page, hasChildren, filtered.Children.Count, filterSignature);
|
||||
}
|
||||
|
||||
private static int ResolveParentId(GalaxyHierarchyCacheEntry entry, BrowseChildrenRequest request)
|
||||
{
|
||||
switch (request.ParentCase)
|
||||
{
|
||||
case BrowseChildrenRequest.ParentOneofCase.None:
|
||||
return 0;
|
||||
case BrowseChildrenRequest.ParentOneofCase.ParentGobjectId:
|
||||
if (request.ParentGobjectId == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
if (!entry.Index.ObjectViewsById.ContainsKey(request.ParentGobjectId))
|
||||
{
|
||||
throw new RpcException(new Status(StatusCode.NotFound, "BrowseChildren parent was not found."));
|
||||
}
|
||||
return request.ParentGobjectId;
|
||||
case BrowseChildrenRequest.ParentOneofCase.ParentTagName:
|
||||
{
|
||||
GalaxyObjectView? match = entry.Index.ObjectViews.FirstOrDefault(
|
||||
view => string.Equals(view.Object.TagName, request.ParentTagName, StringComparison.OrdinalIgnoreCase));
|
||||
if (match is null)
|
||||
{
|
||||
throw new RpcException(new Status(StatusCode.NotFound, "BrowseChildren parent was not found."));
|
||||
}
|
||||
return match.Object.GobjectId;
|
||||
}
|
||||
case BrowseChildrenRequest.ParentOneofCase.ParentContainedPath:
|
||||
{
|
||||
GalaxyObjectView? match = entry.Index.ObjectViews.FirstOrDefault(
|
||||
view => string.Equals(view.ContainedPath, request.ParentContainedPath, StringComparison.OrdinalIgnoreCase));
|
||||
if (match is null)
|
||||
{
|
||||
throw new RpcException(new Status(StatusCode.NotFound, "BrowseChildren parent was not found."));
|
||||
}
|
||||
return match.Object.GobjectId;
|
||||
}
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
private static FilteredChildren GetFilteredChildren(
|
||||
GalaxyHierarchyCacheEntry entry,
|
||||
BrowseChildrenRequest request,
|
||||
IReadOnlyList<string>? browseSubtreeGlobs,
|
||||
int parentId,
|
||||
string filterSignature)
|
||||
{
|
||||
ConcurrentDictionary<string, FilteredChildren> memo =
|
||||
FilteredChildrenCache.GetValue(entry, static _ => new ConcurrentDictionary<string, FilteredChildren>(StringComparer.Ordinal));
|
||||
|
||||
return memo.GetOrAdd(
|
||||
filterSignature,
|
||||
static (_, state) =>
|
||||
{
|
||||
IReadOnlyDictionary<int, IReadOnlyList<GalaxyObjectView>> map = state.Entry.Index.ChildrenByParent;
|
||||
IReadOnlyList<GalaxyObjectView> directChildren = map.TryGetValue(state.ParentId, out IReadOnlyList<GalaxyObjectView>? list)
|
||||
? list
|
||||
: Array.Empty<GalaxyObjectView>();
|
||||
|
||||
List<GalaxyObjectView> matched = [];
|
||||
List<bool> hasMatching = [];
|
||||
foreach (GalaxyObjectView view in directChildren)
|
||||
{
|
||||
if (!MatchesBrowseSubtrees(view, state.BrowseSubtreeGlobs))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (!MatchesFilters(view.Object, state.Request))
|
||||
{
|
||||
// Even if the direct child itself fails the filter, a matching
|
||||
// descendant should still surface its ancestor — but only when
|
||||
// there is one. Mirror the dashboard browse-tree semantics: if a
|
||||
// descendant matches, include the parent with has-children true.
|
||||
if (HasMatchingDescendant(view, state.Entry.Index, state.Request, state.BrowseSubtreeGlobs))
|
||||
{
|
||||
matched.Add(view);
|
||||
hasMatching.Add(true);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
matched.Add(view);
|
||||
hasMatching.Add(HasMatchingDescendant(view, state.Entry.Index, state.Request, state.BrowseSubtreeGlobs));
|
||||
}
|
||||
|
||||
return new FilteredChildren(matched, hasMatching);
|
||||
},
|
||||
(Entry: entry, ParentId: parentId, Request: request, BrowseSubtreeGlobs: browseSubtreeGlobs));
|
||||
}
|
||||
|
||||
private static bool HasMatchingDescendant(
|
||||
GalaxyObjectView parent,
|
||||
GalaxyHierarchyIndex index,
|
||||
BrowseChildrenRequest request,
|
||||
IReadOnlyList<string>? browseSubtreeGlobs)
|
||||
{
|
||||
if (!index.ChildrenByParent.TryGetValue(parent.Object.GobjectId, out IReadOnlyList<GalaxyObjectView>? children))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
Stack<GalaxyObjectView> stack = new();
|
||||
foreach (GalaxyObjectView child in children)
|
||||
{
|
||||
stack.Push(child);
|
||||
}
|
||||
while (stack.Count > 0)
|
||||
{
|
||||
GalaxyObjectView candidate = stack.Pop();
|
||||
if (MatchesBrowseSubtrees(candidate, browseSubtreeGlobs)
|
||||
&& MatchesFilters(candidate.Object, request))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
if (index.ChildrenByParent.TryGetValue(candidate.Object.GobjectId, out IReadOnlyList<GalaxyObjectView>? grandchildren))
|
||||
{
|
||||
foreach (GalaxyObjectView grandchild in grandchildren)
|
||||
{
|
||||
stack.Push(grandchild);
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool MatchesBrowseSubtrees(GalaxyObjectView view, IReadOnlyList<string>? browseSubtreeGlobs)
|
||||
{
|
||||
return browseSubtreeGlobs is null
|
||||
|| browseSubtreeGlobs.Count == 0
|
||||
|| browseSubtreeGlobs.Any(glob => GalaxyGlobMatcher.IsMatch(view.ContainedPath, glob));
|
||||
}
|
||||
|
||||
private static bool MatchesFilters(GalaxyObject obj, BrowseChildrenRequest request)
|
||||
{
|
||||
if (request.CategoryIds.Count > 0 && !request.CategoryIds.Contains(obj.CategoryId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
foreach (string templateFilter in request.TemplateChainContains)
|
||||
{
|
||||
if (!obj.TemplateChain.Any(template => template.Contains(templateFilter, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(request.TagNameGlob)
|
||||
&& !GalaxyGlobMatcher.IsMatch(obj.TagName, request.TagNameGlob))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if (request.AlarmBearingOnly && !obj.Attributes.Any(attribute => attribute.IsAlarm))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if (request.HistorizedOnly && !obj.Attributes.Any(attribute => attribute.IsHistorized))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool IncludeAttributes(BrowseChildrenRequest request)
|
||||
{
|
||||
return !request.HasIncludeAttributes || request.IncludeAttributes;
|
||||
}
|
||||
|
||||
private static GalaxyObject CloneObject(GalaxyObject source, bool includeAttributes)
|
||||
{
|
||||
GalaxyObject clone = source.Clone();
|
||||
if (!includeAttributes)
|
||||
{
|
||||
clone.Attributes.Clear();
|
||||
}
|
||||
return clone;
|
||||
}
|
||||
|
||||
/// <summary>Computes a stable filter signature for memoization purposes.</summary>
|
||||
/// <param name="request">The browse-children request.</param>
|
||||
/// <param name="browseSubtreeGlobs">Optional API-key browse-subtree constraints.</param>
|
||||
/// <param name="parentId">Resolved parent gobject id (0 for roots).</param>
|
||||
public static string ComputeFilterSignature(
|
||||
BrowseChildrenRequest request,
|
||||
IReadOnlyList<string>? browseSubtreeGlobs,
|
||||
int parentId)
|
||||
{
|
||||
StringBuilder builder = new();
|
||||
builder.Append("parent=").Append(parentId.ToString(System.Globalization.CultureInfo.InvariantCulture));
|
||||
builder.Append("|cat=").AppendJoin(',', request.CategoryIds.Order());
|
||||
builder.Append("|tpl=").AppendJoin(',', request.TemplateChainContains.Order(StringComparer.OrdinalIgnoreCase));
|
||||
builder.Append("|glob=").Append(request.TagNameGlob);
|
||||
builder.Append("|attrs=").Append(request.HasIncludeAttributes ? request.IncludeAttributes.ToString() : "unset");
|
||||
builder.Append("|alarm=").Append(request.AlarmBearingOnly);
|
||||
builder.Append("|hist=").Append(request.HistorizedOnly);
|
||||
builder.Append("|browse=").AppendJoin(',', (browseSubtreeGlobs ?? Array.Empty<string>()).Order(StringComparer.OrdinalIgnoreCase));
|
||||
byte[] hash = SHA256.HashData(Encoding.UTF8.GetBytes(builder.ToString()));
|
||||
return Convert.ToHexString(hash, 0, 12);
|
||||
}
|
||||
|
||||
private sealed record FilteredChildren(
|
||||
IReadOnlyList<GalaxyObjectView> Children,
|
||||
IReadOnlyList<bool> HasMatchingDescendant);
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
using Grpc.Core;
|
||||
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.Galaxy;
|
||||
|
||||
/// <summary>
|
||||
/// Direct coverage for <see cref="GalaxyBrowseProjector"/>. Validates parent
|
||||
/// resolution (gobject id / tag name / contained path), paging across siblings,
|
||||
/// filter parity with <see cref="GalaxyHierarchyProjector"/>, the
|
||||
/// <c>child_has_children</c> hint, browse-subtree constraints, and the
|
||||
/// attribute-skeleton mode.
|
||||
/// </summary>
|
||||
public sealed class GalaxyBrowseProjectorTests
|
||||
{
|
||||
/// <summary>Verifies that an empty parent oneof returns the root area.</summary>
|
||||
[Fact]
|
||||
public void Project_NoParent_ReturnsRootArea()
|
||||
{
|
||||
GalaxyHierarchyCacheEntry entry = CreateEntry();
|
||||
|
||||
GalaxyBrowseChildrenResult result = GalaxyBrowseProjector.ProjectChildren(
|
||||
entry,
|
||||
new BrowseChildrenRequest(),
|
||||
browseSubtreeGlobs: null,
|
||||
offset: 0,
|
||||
pageSize: 10);
|
||||
|
||||
Assert.Single(result.Children);
|
||||
Assert.Equal("Plant", result.Children[0].TagName);
|
||||
Assert.True(result.ChildHasChildren[0]);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that resolving the parent by gobject id returns sorted direct children.</summary>
|
||||
[Fact]
|
||||
public void Project_ByParentGobjectId_ReturnsDirectChildren()
|
||||
{
|
||||
GalaxyHierarchyCacheEntry entry = CreateEntry();
|
||||
|
||||
GalaxyBrowseChildrenResult result = GalaxyBrowseProjector.ProjectChildren(
|
||||
entry,
|
||||
new BrowseChildrenRequest { ParentGobjectId = 1 },
|
||||
browseSubtreeGlobs: null,
|
||||
offset: 0,
|
||||
pageSize: 10);
|
||||
|
||||
string[] names = result.Children.Select(child => child.TagName).ToArray();
|
||||
Assert.Equal(new[] { "Plant.Line_A", "Plant.Mixer_001", "Plant.Mixer_002", "Plant.Pump_001" }, names);
|
||||
Assert.Equal(new[] { true, false, false, false }, result.ChildHasChildren.ToArray());
|
||||
Assert.Equal(4, result.TotalChildCount);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that resolving the parent by tag name returns the same direct children.</summary>
|
||||
[Fact]
|
||||
public void Project_ByParentTagName_ResolvesParent()
|
||||
{
|
||||
GalaxyHierarchyCacheEntry entry = CreateEntry();
|
||||
|
||||
GalaxyBrowseChildrenResult result = GalaxyBrowseProjector.ProjectChildren(
|
||||
entry,
|
||||
new BrowseChildrenRequest { ParentTagName = "Plant" },
|
||||
browseSubtreeGlobs: null,
|
||||
offset: 0,
|
||||
pageSize: 10);
|
||||
|
||||
string[] names = result.Children.Select(child => child.TagName).ToArray();
|
||||
Assert.Equal(new[] { "Plant.Line_A", "Plant.Mixer_001", "Plant.Mixer_002", "Plant.Pump_001" }, names);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that resolving the parent by contained path returns the same direct children.</summary>
|
||||
[Fact]
|
||||
public void Project_ByParentContainedPath_ResolvesParent()
|
||||
{
|
||||
GalaxyHierarchyCacheEntry entry = CreateEntry();
|
||||
|
||||
GalaxyBrowseChildrenResult result = GalaxyBrowseProjector.ProjectChildren(
|
||||
entry,
|
||||
new BrowseChildrenRequest { ParentContainedPath = "Plant" },
|
||||
browseSubtreeGlobs: null,
|
||||
offset: 0,
|
||||
pageSize: 10);
|
||||
|
||||
string[] names = result.Children.Select(child => child.TagName).ToArray();
|
||||
Assert.Equal(new[] { "Plant.Line_A", "Plant.Mixer_001", "Plant.Mixer_002", "Plant.Pump_001" }, names);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an unknown parent gobject id throws an RpcException with StatusCode.NotFound.</summary>
|
||||
[Fact]
|
||||
public void Project_UnknownParent_ThrowsNotFound()
|
||||
{
|
||||
GalaxyHierarchyCacheEntry entry = CreateEntry();
|
||||
|
||||
RpcException exception = Assert.Throws<RpcException>(() => GalaxyBrowseProjector.ProjectChildren(
|
||||
entry,
|
||||
new BrowseChildrenRequest { ParentGobjectId = 999 },
|
||||
browseSubtreeGlobs: null,
|
||||
offset: 0,
|
||||
pageSize: 10));
|
||||
|
||||
Assert.Equal(StatusCode.NotFound, exception.StatusCode);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that paging across siblings returns every sibling exactly once.</summary>
|
||||
[Fact]
|
||||
public void Project_PagedAcrossSiblings_ReturnsEverySiblingOnce()
|
||||
{
|
||||
GalaxyHierarchyCacheEntry entry = CreateEntry();
|
||||
|
||||
GalaxyBrowseChildrenResult first = GalaxyBrowseProjector.ProjectChildren(
|
||||
entry,
|
||||
new BrowseChildrenRequest { ParentGobjectId = 1 },
|
||||
browseSubtreeGlobs: null,
|
||||
offset: 0,
|
||||
pageSize: 2);
|
||||
GalaxyBrowseChildrenResult second = GalaxyBrowseProjector.ProjectChildren(
|
||||
entry,
|
||||
new BrowseChildrenRequest { ParentGobjectId = 1 },
|
||||
browseSubtreeGlobs: null,
|
||||
offset: 2,
|
||||
pageSize: 2);
|
||||
|
||||
List<string> collected = first.Children
|
||||
.Concat(second.Children)
|
||||
.Select(child => child.TagName)
|
||||
.ToList();
|
||||
Assert.Equal(4, collected.Count);
|
||||
Assert.Equal(collected.Count, collected.Distinct(StringComparer.Ordinal).Count());
|
||||
Assert.Equal(
|
||||
new HashSet<string>(StringComparer.Ordinal)
|
||||
{
|
||||
"Plant.Line_A",
|
||||
"Plant.Mixer_001",
|
||||
"Plant.Mixer_002",
|
||||
"Plant.Pump_001",
|
||||
},
|
||||
new HashSet<string>(collected, StringComparer.Ordinal));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a tag-name glob filters direct children and clears the has-children hint.</summary>
|
||||
[Fact]
|
||||
public void Project_TagNameGlobFiltersChildren_AndUpdatesHasChildren()
|
||||
{
|
||||
GalaxyHierarchyCacheEntry entry = CreateEntry();
|
||||
|
||||
GalaxyBrowseChildrenResult result = GalaxyBrowseProjector.ProjectChildren(
|
||||
entry,
|
||||
new BrowseChildrenRequest
|
||||
{
|
||||
ParentGobjectId = 1,
|
||||
TagNameGlob = "*Mixer*",
|
||||
},
|
||||
browseSubtreeGlobs: null,
|
||||
offset: 0,
|
||||
pageSize: 10);
|
||||
|
||||
string[] names = result.Children.Select(child => child.TagName).ToArray();
|
||||
Assert.Equal(new[] { "Plant.Mixer_001", "Plant.Mixer_002" }, names);
|
||||
Assert.Equal(new[] { false, false }, result.ChildHasChildren.ToArray());
|
||||
}
|
||||
|
||||
/// <summary>Verifies that historized-only filtering also drives the has-children hint via descendants.</summary>
|
||||
[Fact]
|
||||
public void Project_HistorizedOnlyFiltersDescendants_HasChildrenReflectsFilter()
|
||||
{
|
||||
GalaxyHierarchyCacheEntry entry = CreateEntry();
|
||||
|
||||
GalaxyBrowseChildrenResult result = GalaxyBrowseProjector.ProjectChildren(
|
||||
entry,
|
||||
new BrowseChildrenRequest
|
||||
{
|
||||
ParentGobjectId = 1,
|
||||
HistorizedOnly = true,
|
||||
},
|
||||
browseSubtreeGlobs: null,
|
||||
offset: 0,
|
||||
pageSize: 10);
|
||||
|
||||
// Line_A itself has no historized attributes, but its descendant Sensor_A1 does,
|
||||
// so the subtree match keeps Line_A in the result with has-children = true.
|
||||
// Mixer_001/Mixer_002/Pump_001 have no historized attributes themselves and
|
||||
// no historized descendants -> filtered out entirely.
|
||||
Assert.Single(result.Children);
|
||||
Assert.Equal("Plant.Line_A", result.Children[0].TagName);
|
||||
Assert.Equal(new[] { true }, result.ChildHasChildren.ToArray());
|
||||
}
|
||||
|
||||
/// <summary>Verifies that <c>IncludeAttributes=false</c> returns object skeletons.</summary>
|
||||
[Fact]
|
||||
public void Project_IncludeAttributesFalse_ReturnsSkeletons()
|
||||
{
|
||||
GalaxyHierarchyCacheEntry entry = CreateEntry();
|
||||
|
||||
GalaxyBrowseChildrenResult result = GalaxyBrowseProjector.ProjectChildren(
|
||||
entry,
|
||||
new BrowseChildrenRequest
|
||||
{
|
||||
ParentGobjectId = 1,
|
||||
IncludeAttributes = false,
|
||||
},
|
||||
browseSubtreeGlobs: null,
|
||||
offset: 0,
|
||||
pageSize: 10);
|
||||
|
||||
GalaxyObject mixer = result.Children.Single(child => child.TagName == "Plant.Mixer_001");
|
||||
Assert.Empty(mixer.Attributes);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that browse-subtree globs constrain the returned children.</summary>
|
||||
[Fact]
|
||||
public void Project_BrowseSubtrees_ExcludesChildrenOutsideAllowedGlobs()
|
||||
{
|
||||
GalaxyHierarchyCacheEntry entry = CreateEntry();
|
||||
|
||||
GalaxyBrowseChildrenResult result = GalaxyBrowseProjector.ProjectChildren(
|
||||
entry,
|
||||
new BrowseChildrenRequest { ParentGobjectId = 1 },
|
||||
browseSubtreeGlobs: new[] { "Plant/Line_*" },
|
||||
offset: 0,
|
||||
pageSize: 10);
|
||||
|
||||
Assert.Single(result.Children);
|
||||
Assert.Equal("Plant.Line_A", result.Children[0].TagName);
|
||||
}
|
||||
|
||||
private static GalaxyHierarchyCacheEntry CreateEntry()
|
||||
{
|
||||
IReadOnlyList<GalaxyObject> objects = CreateObjects();
|
||||
return GalaxyHierarchyCacheEntry.Empty with
|
||||
{
|
||||
Status = GalaxyCacheStatus.Healthy,
|
||||
Sequence = 1,
|
||||
LastSuccessAt = DateTimeOffset.UtcNow,
|
||||
Objects = objects,
|
||||
Index = GalaxyHierarchyIndex.Build(objects),
|
||||
DashboardSummary = DashboardGalaxySummary.Unknown with
|
||||
{
|
||||
Status = DashboardGalaxyStatus.Healthy,
|
||||
ObjectCount = objects.Count,
|
||||
},
|
||||
ObjectCount = objects.Count,
|
||||
};
|
||||
}
|
||||
|
||||
private static IReadOnlyList<GalaxyObject> CreateObjects()
|
||||
{
|
||||
GalaxyObject plant = new()
|
||||
{
|
||||
GobjectId = 1,
|
||||
ParentGobjectId = 0,
|
||||
IsArea = true,
|
||||
ContainedName = "Plant",
|
||||
BrowseName = "Plant",
|
||||
TagName = "Plant",
|
||||
};
|
||||
GalaxyObject mixer001 = new()
|
||||
{
|
||||
GobjectId = 2,
|
||||
ParentGobjectId = 1,
|
||||
ContainedName = "Mixer_001",
|
||||
BrowseName = "Mixer_001",
|
||||
TagName = "Plant.Mixer_001",
|
||||
};
|
||||
mixer001.Attributes.Add(new GalaxyAttribute
|
||||
{
|
||||
AttributeName = "Speed",
|
||||
FullTagReference = "Plant.Mixer_001.Speed",
|
||||
});
|
||||
GalaxyObject mixer002 = new()
|
||||
{
|
||||
GobjectId = 3,
|
||||
ParentGobjectId = 1,
|
||||
ContainedName = "Mixer_002",
|
||||
BrowseName = "Mixer_002",
|
||||
TagName = "Plant.Mixer_002",
|
||||
};
|
||||
GalaxyObject lineA = new()
|
||||
{
|
||||
GobjectId = 4,
|
||||
ParentGobjectId = 1,
|
||||
IsArea = true,
|
||||
ContainedName = "Line_A",
|
||||
BrowseName = "Line_A",
|
||||
TagName = "Plant.Line_A",
|
||||
};
|
||||
GalaxyObject sensorA1 = new()
|
||||
{
|
||||
GobjectId = 5,
|
||||
ParentGobjectId = 4,
|
||||
ContainedName = "Sensor_A1",
|
||||
BrowseName = "Sensor_A1",
|
||||
TagName = "Plant.Line_A.Sensor_A1",
|
||||
};
|
||||
sensorA1.Attributes.Add(new GalaxyAttribute
|
||||
{
|
||||
AttributeName = "Value",
|
||||
FullTagReference = "Plant.Line_A.Sensor_A1.Value",
|
||||
IsHistorized = true,
|
||||
});
|
||||
GalaxyObject pump001 = new()
|
||||
{
|
||||
GobjectId = 6,
|
||||
ParentGobjectId = 1,
|
||||
ContainedName = "Pump_001",
|
||||
BrowseName = "Pump_001",
|
||||
TagName = "Plant.Pump_001",
|
||||
};
|
||||
|
||||
return new[] { plant, mixer001, mixer002, lineA, sensorA1, pump001 };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user