using Grpc.Core;
using ZB.MOM.WW.GalaxyRepository;
using ZB.MOM.WW.GalaxyRepository.Grpc;
namespace ZB.MOM.WW.GalaxyRepository.Tests;
///
/// Verifies that scopes browse/discover
/// results through the injected . The default
/// (null-returning) provider must preserve full-hierarchy behavior, and a provider
/// returning a glob that matches nothing must filter the result to empty.
///
public sealed class GalaxyRepositoryGrpcServiceScopeTests
{
///
/// A scope provider built with a result behaves like the
/// default : DiscoverHierarchy returns
/// the full hierarchy.
///
[Fact]
public async Task DiscoverHierarchy_DefaultScope_ReturnsFullHierarchy()
{
GalaxyRepositoryGrpcService service = CreateService(
BuildSampleEntry(),
new FakeBrowseScopeProvider(subtrees: null));
DiscoverHierarchyReply reply = await service.DiscoverHierarchy(
new DiscoverHierarchyRequest { PageSize = 100 },
new TestServerCallContext());
// The sample hierarchy has six objects; with no scoping all are returned.
Assert.Equal(6, reply.TotalObjectCount);
Assert.Equal(6, reply.Objects.Count);
}
///
/// A scope provider returning a glob that matches no contained path filters the
/// children to empty, mirroring mxaccessgw's browse-subtree constraint behavior.
///
[Fact]
public async Task BrowseChildren_ScopedProvider_FiltersChildren()
{
GalaxyHierarchyCacheEntry entry = BuildSampleEntry();
// Sanity: with the default (unscoped) provider, LineA(2) has two children.
GalaxyRepositoryGrpcService unscopedService = CreateService(
entry,
new FakeBrowseScopeProvider(subtrees: null));
BrowseChildrenReply unscoped = await unscopedService.BrowseChildren(
new BrowseChildrenRequest { ParentGobjectId = 2 },
new TestServerCallContext());
Assert.Equal(2, unscoped.Children.Count);
// A glob matching nothing scopes the result to empty.
GalaxyRepositoryGrpcService scopedService = CreateService(
entry,
new FakeBrowseScopeProvider(subtrees: ["NonExistent"]));
BrowseChildrenReply scoped = await scopedService.BrowseChildren(
new BrowseChildrenRequest { ParentGobjectId = 2 },
new TestServerCallContext());
Assert.Empty(scoped.Children);
Assert.Equal(0, scoped.TotalChildCount);
}
private static GalaxyRepositoryGrpcService CreateService(
GalaxyHierarchyCacheEntry entry,
IGalaxyBrowseScopeProvider scope)
{
GalaxyRepositoryOptions options = new()
{
ConnectionString = "Server=localhost;Database=ZB;Integrated Security=True;Encrypt=False;",
};
return new GalaxyRepositoryGrpcService(
new GalaxyRepository(options),
new StubGalaxyHierarchyCache(entry),
new RecordingDeployNotifier(),
scope);
}
// A small but representative galaxy, materialized through the real cache refresh path
// so the projectors run against a real GalaxyHierarchyIndex:
// PlantArea (area, id 1)
// ├─ LineA (area, id 2)
// │ ├─ Pump01 (id 10)
// │ └─ Valve01 (id 11)
// └─ Mixer01 (id 12)
// StandaloneTank (id 20, root)
private static GalaxyHierarchyCacheEntry BuildSampleEntry()
{
List hierarchy =
[
Hierarchy(1, "PlantArea", parent: 0, isArea: true, category: 100),
Hierarchy(2, "LineA", parent: 1, isArea: true, category: 100),
Hierarchy(10, "Pump01", parent: 2, category: 200, templates: ["$Pump"]),
Hierarchy(11, "Valve01", parent: 2, category: 201, templates: ["$Valve"]),
Hierarchy(12, "Mixer01", parent: 1, category: 202, templates: ["$Mixer"]),
Hierarchy(20, "StandaloneTank", parent: 0, category: 203, templates: ["$Tank"]),
];
List attributes =
[
Attribute(10, "Pump01.PV"),
Attribute(11, "Valve01.Cmd"),
Attribute(12, "Mixer01.Fault"),
Attribute(20, "StandaloneTank.Level"),
];
FakeGalaxyRepository repository = new(
hierarchy,
attributes,
deployTime: new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc));
using GalaxyHierarchyCache cache = new(repository, new RecordingDeployNotifier());
cache.RefreshAsync(CancellationToken.None).GetAwaiter().GetResult();
GalaxyHierarchyCacheEntry entry = cache.Current;
Assert.True(entry.HasData);
return entry;
}
private static GalaxyHierarchyRow Hierarchy(
int id,
string tagName,
int parent,
bool isArea = false,
int category = 0,
IReadOnlyList? templates = null) => new()
{
GobjectId = id,
TagName = tagName,
ContainedName = tagName,
BrowseName = tagName,
ParentGobjectId = parent,
IsArea = isArea,
CategoryId = category,
TemplateChain = templates ?? Array.Empty(),
};
private static GalaxyAttributeRow Attribute(int gobjectId, string fullTagReference) => new()
{
GobjectId = gobjectId,
AttributeName = fullTagReference.Split('.')[^1],
FullTagReference = fullTagReference,
};
/// An that returns a fixed glob list.
private sealed class FakeBrowseScopeProvider(IReadOnlyList? subtrees) : IGalaxyBrowseScopeProvider
{
public IReadOnlyList? ResolveBrowseSubtrees(ServerCallContext context) => subtrees;
}
/// Serves a fixed cache entry; never blocks on first load.
private sealed class StubGalaxyHierarchyCache(GalaxyHierarchyCacheEntry current) : IGalaxyHierarchyCache
{
public GalaxyHierarchyCacheEntry Current { get; } = current;
public Task RefreshAsync(CancellationToken cancellationToken) => Task.CompletedTask;
public Task WaitForFirstLoadAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}
/// Minimal in-memory for direct service unit tests.
private sealed class TestServerCallContext : ServerCallContext
{
private readonly Metadata _requestHeaders = [];
private readonly Metadata _responseTrailers = [];
private readonly Dictionary