test: cover WatchDeployEvents scoped-count re-projection

This commit is contained in:
Joseph Doherty
2026-06-25 10:54:51 -04:00
parent 480f7c7a49
commit be993d4d54
@@ -63,18 +63,78 @@ public sealed class GalaxyRepositoryGrpcServiceScopeTests
Assert.Equal(0, scoped.TotalChildCount);
}
/// <summary>
/// When the scope provider returns a non-empty glob, the deploy event's
/// object/attribute counts are re-projected against the scoped subtree and override
/// the raw counts the notifier published.
/// </summary>
[Fact]
public async Task WatchDeployEvents_ScopedProvider_EmitsFilteredCounts()
{
GalaxyHierarchyCacheEntry entry = BuildSampleEntry();
// Sanity: the full hierarchy projects to six objects / four attributes.
GalaxyHierarchyQueryResult full = GalaxyHierarchyProjector.Project(
entry,
new DiscoverHierarchyRequest());
Assert.Equal(6, full.TotalObjectCount);
Assert.Equal(4, full.Objects.Sum(obj => obj.Attributes.Count));
// The glob selects only LineA's two leaf objects (Pump01, Valve01), each with one
// attribute. That scoped projection (2 objects / 2 attributes) is a non-empty subset
// distinct from both the full count and the raw notifier values below.
GalaxyHierarchyQueryResult scopedProjection = GalaxyHierarchyProjector.Project(
entry,
new DiscoverHierarchyRequest(),
browseSubtreeGlobs: ["PlantArea/LineA/*"]);
Assert.Equal(2, scopedProjection.TotalObjectCount);
Assert.Equal(2, scopedProjection.Objects.Sum(obj => obj.Attributes.Count));
// Publish a deploy event whose RAW counts differ from both full and scoped, so an
// assertion on the scoped values proves the override actually happened.
RecordingDeployNotifier notifier = new();
notifier.Publish(new GalaxyDeployEventInfo(
Sequence: 42,
ObservedAt: new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero),
TimeOfLastDeploy: new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero),
ObjectCount: 999,
AttributeCount: 888));
GalaxyRepositoryGrpcService service = CreateService(
entry,
new FakeBrowseScopeProvider(subtrees: ["PlantArea/LineA/*"]),
notifier);
// RecordingDeployNotifier yields the latest event then completes, so the stream
// ends after the single event without needing cancellation.
CapturingStreamWriter responseStream = new();
await service.WatchDeployEvents(
new WatchDeployEventsRequest(),
responseStream,
new TestServerCallContext());
DeployEvent emitted = Assert.Single(responseStream.Written);
Assert.Equal(scopedProjection.TotalObjectCount, emitted.ObjectCount);
Assert.Equal(2, emitted.AttributeCount);
// The raw notifier values were overridden by the scoped re-projection.
Assert.NotEqual(999, emitted.ObjectCount);
Assert.NotEqual(888, emitted.AttributeCount);
}
private static GalaxyRepositoryGrpcService CreateService(
GalaxyHierarchyCacheEntry entry,
IGalaxyBrowseScopeProvider scope)
IGalaxyBrowseScopeProvider scope,
IGalaxyDeployNotifier? notifier = null)
{
GalaxyRepositoryOptions options = new()
{
ConnectionString = "Server=localhost;Database=ZB;Integrated Security=True;Encrypt=False;",
};
// No test here calls TestConnection, so a fake repository (no real SQL) is enough
// and removes any latent localhost-connection risk.
return new GalaxyRepositoryGrpcService(
new GalaxyRepository(options),
new FakeGalaxyRepository(
Array.Empty<GalaxyHierarchyRow>(),
Array.Empty<GalaxyAttributeRow>(),
deployTime: null),
new StubGalaxyHierarchyCache(entry),
new RecordingDeployNotifier(),
notifier ?? new RecordingDeployNotifier(),
scope);
}
@@ -158,6 +218,20 @@ public sealed class GalaxyRepositoryGrpcServiceScopeTests
public Task WaitForFirstLoadAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}
/// <summary>Records every <see cref="DeployEvent"/> the service streams.</summary>
private sealed class CapturingStreamWriter : IServerStreamWriter<DeployEvent>
{
public List<DeployEvent> Written { get; } = [];
public WriteOptions? WriteOptions { get; set; }
public Task WriteAsync(DeployEvent message)
{
Written.Add(message);
return Task.CompletedTask;
}
}
/// <summary>Minimal in-memory <see cref="ServerCallContext"/> for direct service unit tests.</summary>
private sealed class TestServerCallContext : ServerCallContext
{