feat: scope GalaxyRepositoryGrpcService results via IGalaxyBrowseScopeProvider
This commit is contained in:
+207
@@ -0,0 +1,207 @@
|
||||
using Grpc.Core;
|
||||
using ZB.MOM.WW.GalaxyRepository;
|
||||
using ZB.MOM.WW.GalaxyRepository.Grpc;
|
||||
|
||||
namespace ZB.MOM.WW.GalaxyRepository.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that <see cref="GalaxyRepositoryGrpcService"/> scopes browse/discover
|
||||
/// results through the injected <see cref="IGalaxyBrowseScopeProvider"/>. 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.
|
||||
/// </summary>
|
||||
public sealed class GalaxyRepositoryGrpcServiceScopeTests
|
||||
{
|
||||
/// <summary>
|
||||
/// A scope provider built with a <see langword="null"/> result behaves like the
|
||||
/// default <see cref="NullGalaxyBrowseScopeProvider"/>: DiscoverHierarchy returns
|
||||
/// the full hierarchy.
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A scope provider returning a glob that matches no contained path filters the
|
||||
/// children to empty, mirroring mxaccessgw's browse-subtree constraint behavior.
|
||||
/// </summary>
|
||||
[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<GalaxyHierarchyRow> 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<GalaxyAttributeRow> 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<string>? templates = null) => new()
|
||||
{
|
||||
GobjectId = id,
|
||||
TagName = tagName,
|
||||
ContainedName = tagName,
|
||||
BrowseName = tagName,
|
||||
ParentGobjectId = parent,
|
||||
IsArea = isArea,
|
||||
CategoryId = category,
|
||||
TemplateChain = templates ?? Array.Empty<string>(),
|
||||
};
|
||||
|
||||
private static GalaxyAttributeRow Attribute(int gobjectId, string fullTagReference) => new()
|
||||
{
|
||||
GobjectId = gobjectId,
|
||||
AttributeName = fullTagReference.Split('.')[^1],
|
||||
FullTagReference = fullTagReference,
|
||||
};
|
||||
|
||||
/// <summary>An <see cref="IGalaxyBrowseScopeProvider"/> that returns a fixed glob list.</summary>
|
||||
private sealed class FakeBrowseScopeProvider(IReadOnlyList<string>? subtrees) : IGalaxyBrowseScopeProvider
|
||||
{
|
||||
public IReadOnlyList<string>? ResolveBrowseSubtrees(ServerCallContext context) => subtrees;
|
||||
}
|
||||
|
||||
/// <summary>Serves a fixed cache entry; never blocks on first load.</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Minimal in-memory <see cref="ServerCallContext"/> for direct service unit tests.</summary>
|
||||
private sealed class TestServerCallContext : ServerCallContext
|
||||
{
|
||||
private readonly Metadata _requestHeaders = [];
|
||||
private readonly Metadata _responseTrailers = [];
|
||||
private readonly Dictionary<object, object> _userState = [];
|
||||
private Status _status;
|
||||
private WriteOptions? _writeOptions;
|
||||
|
||||
protected override string MethodCore => "/zb.galaxy.v1.GalaxyRepository/Test";
|
||||
|
||||
protected override string HostCore => "localhost";
|
||||
|
||||
protected override string PeerCore => "ipv4:127.0.0.1:5000";
|
||||
|
||||
protected override DateTime DeadlineCore => DateTime.UtcNow.AddMinutes(1);
|
||||
|
||||
protected override Metadata RequestHeadersCore => _requestHeaders;
|
||||
|
||||
protected override CancellationToken CancellationTokenCore => CancellationToken.None;
|
||||
|
||||
protected override Metadata ResponseTrailersCore => _responseTrailers;
|
||||
|
||||
protected override Status StatusCore
|
||||
{
|
||||
get => _status;
|
||||
set => _status = value;
|
||||
}
|
||||
|
||||
protected override WriteOptions? WriteOptionsCore
|
||||
{
|
||||
get => _writeOptions;
|
||||
set => _writeOptions = value;
|
||||
}
|
||||
|
||||
protected override AuthContext AuthContextCore { get; } = new(
|
||||
string.Empty,
|
||||
new Dictionary<string, List<AuthProperty>>(StringComparer.Ordinal));
|
||||
|
||||
protected override IDictionary<object, object> UserStateCore => _userState;
|
||||
|
||||
protected override Task WriteResponseHeadersAsyncCore(Metadata responseHeaders) => Task.CompletedTask;
|
||||
|
||||
protected override ContextPropagationToken CreatePropagationTokenCore(ContextPropagationOptions? options) =>
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user