using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using NSubstitute; using ZB.MOM.WW.ScadaBridge.CentralUI.Services; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories; using ZB.MOM.WW.ScadaBridge.Commons.Types.Kpi; using ZB.MOM.WW.ScadaBridge.KpiHistory; namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Services; /// /// Service-level tests for (M6 K11). The /// service fetches a raw series via /// and reduces it with ; these tests pin the /// argument-forwarding contract, the bucketer pass-through, and the /// maxPoints ?? DefaultMaxSeriesPoints fallback. /// public class KpiHistoryQueryServiceTests { private static readonly DateTime From = new(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc); private static readonly DateTime To = new(2026, 5, 20, 11, 0, 0, DateTimeKind.Utc); private static IOptions Options(int defaultMax, int rollupThresholdHours = 168) => Microsoft.Extensions.Options.Options.Create(new KpiHistoryOptions { DefaultMaxSeriesPoints = defaultMax, RollupThresholdHours = rollupThresholdHours, }); private static IReadOnlyList Series(params (int minute, double value)[] pts) => pts.Select(p => new KpiSeriesPoint(From.AddMinutes(p.minute), p.value)).ToList(); [Fact] public async Task GetSeriesAsync_ForwardsAllArgs_ToRepository() { var repo = Substitute.For(); var raw = Series((0, 1d), (30, 2d)); repo.GetRawSeriesAsync( "notification-outbox", "queue_depth", "site", "plant-a", From, To, Arg.Any()) .Returns(Task.FromResult(raw)); var sut = new KpiHistoryQueryService(repo, Options(defaultMax: 200)); await sut.GetSeriesAsync( "notification-outbox", "queue_depth", "site", "plant-a", From, To); await repo.Received(1).GetRawSeriesAsync( "notification-outbox", "queue_depth", "site", "plant-a", From, To, Arg.Any()); } [Fact] public async Task GetSeriesAsync_ForwardsNullScopeKey_ForGlobalScope() { var repo = Substitute.For(); repo.GetRawSeriesAsync( Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Task.FromResult(Series())); var sut = new KpiHistoryQueryService(repo, Options(defaultMax: 200)); await sut.GetSeriesAsync( "site-call-audit", "parked_count", "global", scopeKey: null, From, To); await repo.Received(1).GetRawSeriesAsync( "site-call-audit", "parked_count", "global", Arg.Is(k => k == null), From, To, Arg.Any()); } [Fact] public async Task GetSeriesAsync_ReturnsRawUnchanged_WhenCountWithinMax() { // raw.Count (3) <= max (200) → bucketer returns the same reference. var repo = Substitute.For(); var raw = Series((0, 5d), (20, 6d), (40, 7d)); repo.GetRawSeriesAsync( Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Task.FromResult(raw)); var sut = new KpiHistoryQueryService(repo, Options(defaultMax: 200)); var result = await sut.GetSeriesAsync("src", "metric", "global", null, From, To); // Bucketer returns the raw reference unchanged when raw.Count <= maxPoints. Assert.Same(raw, result); } [Fact] public async Task GetSeriesAsync_NullMaxPoints_UsesDefaultFromOptions() { // raw has 4 points. With DefaultMaxSeriesPoints=2 the bucketer downsamples // (4 > 2), so the result is NOT the raw reference and has at most 2 points. var repo = Substitute.For(); var raw = Series((0, 1d), (15, 2d), (30, 3d), (45, 4d)); repo.GetRawSeriesAsync( Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Task.FromResult(raw)); var sut = new KpiHistoryQueryService(repo, Options(defaultMax: 2)); var result = await sut.GetSeriesAsync( "src", "metric", "global", null, From, To, maxPoints: null); Assert.NotSame(raw, result); Assert.True(result.Count <= 2); } [Fact] public async Task GetSeriesAsync_ExplicitMaxPoints_OverridesDefault() { // raw has 4 points; explicit maxPoints=10 (>= raw.Count) wins over the // option's tiny default of 2, so the bucketer returns the raw reference. var repo = Substitute.For(); var raw = Series((0, 1d), (15, 2d), (30, 3d), (45, 4d)); repo.GetRawSeriesAsync( Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Task.FromResult(raw)); var sut = new KpiHistoryQueryService(repo, Options(defaultMax: 2)); var result = await sut.GetSeriesAsync( "src", "metric", "global", null, From, To, maxPoints: 10); // Explicit max (10) >= raw.Count (4) → raw returned unchanged, proving the // explicit value beat the default of 2 (which would have downsampled). Assert.Same(raw, result); } [Fact] public async Task GetSeriesAsync_EmptySeries_ReturnsEmpty() { var repo = Substitute.For(); repo.GetRawSeriesAsync( Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Task.FromResult>(Array.Empty())); var sut = new KpiHistoryQueryService(repo, Options(defaultMax: 200)); var result = await sut.GetSeriesAsync("src", "metric", "global", null, From, To); Assert.Empty(result); } [Fact] public async Task GetSeriesAsync_OpensFreshScopePerCall_OnProductionCtor() { // The production (IServiceScopeFactory) ctor must resolve a fresh repository // per call — same scope-per-query contract AuditLogQueryService upholds, so a // chart's auto-load never shares the circuit-scoped DbContext. var resolvedRepos = new List(); var services = new ServiceCollection(); services.AddScoped(_ => { var repo = Substitute.For(); repo.GetRawSeriesAsync( Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Task.FromResult>(Array.Empty())); resolvedRepos.Add(repo); return repo; }); await using var provider = services.BuildServiceProvider(); var sut = new KpiHistoryQueryService( provider.GetRequiredService(), Options(defaultMax: 200)); await sut.GetSeriesAsync("src", "metric", "global", null, From, To); await sut.GetSeriesAsync("src", "metric", "global", null, From, To); // Each call opened its own scope → two distinct repo instances. Assert.Equal(2, resolvedRepos.Count); Assert.NotSame(resolvedRepos[0], resolvedRepos[1]); } // ---- Task 5: raw-vs-rollup range routing by RollupThresholdHours ---------------- private static IKpiHistoryRepository RepoReturningEmptyForBoth() { var repo = Substitute.For(); repo.GetRawSeriesAsync( Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Task.FromResult>(Array.Empty())); repo.GetHourlySeriesAsync( Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Task.FromResult>(Array.Empty())); return repo; } [Fact] public async Task GetSeriesAsync_24hWindow_UsesRawPath() { var repo = RepoReturningEmptyForBoth(); var from = new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Utc); var to = from.AddHours(24); var sut = new KpiHistoryQueryService(repo, Options(defaultMax: 200)); await sut.GetSeriesAsync("src", "metric", "global", null, from, to); await repo.Received(1).GetRawSeriesAsync( "src", "metric", "global", null, from, to, Arg.Any()); await repo.DidNotReceive().GetHourlySeriesAsync( Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); } [Fact] public async Task GetSeriesAsync_WindowExactlyAtThreshold_UsesRawPath() { // Boundary is strictly greater-than: exactly 168 h stays on the raw path. var repo = RepoReturningEmptyForBoth(); var from = new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Utc); var to = from.AddHours(168); var sut = new KpiHistoryQueryService(repo, Options(defaultMax: 200, rollupThresholdHours: 168)); await sut.GetSeriesAsync("src", "metric", "global", null, from, to); await repo.Received(1).GetRawSeriesAsync( "src", "metric", "global", null, from, to, Arg.Any()); await repo.DidNotReceive().GetHourlySeriesAsync( Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); } [Fact] public async Task GetSeriesAsync_WindowJustOverThreshold_UsesRollupPath() { // 169 h (threshold + 1) crosses the boundary → hourly rollup path. var repo = RepoReturningEmptyForBoth(); var from = new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Utc); var to = from.AddHours(169); var sut = new KpiHistoryQueryService(repo, Options(defaultMax: 200, rollupThresholdHours: 168)); await sut.GetSeriesAsync("src", "metric", "global", null, from, to); await repo.Received(1).GetHourlySeriesAsync( "src", "metric", "global", null, from, to, Arg.Any()); await repo.DidNotReceive().GetRawSeriesAsync( Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); } [Fact] public async Task GetSeriesAsync_90dWindow_UsesRollupPath() { var repo = RepoReturningEmptyForBoth(); var from = new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Utc); var to = from.AddDays(90); var sut = new KpiHistoryQueryService(repo, Options(defaultMax: 200)); await sut.GetSeriesAsync("src", "metric", "global", null, from, to); await repo.Received(1).GetHourlySeriesAsync( "src", "metric", "global", null, from, to, Arg.Any()); await repo.DidNotReceive().GetRawSeriesAsync( Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); } [Fact] public async Task GetSeriesAsync_RollupPath_StillBucketsWhenOverMax() { // A rollup series longer than maxPoints must still be downsampled by the // bucketer on top of the rollup read — the bucketer path is unchanged. var from = new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Utc); var to = from.AddDays(90); // 6 hourly rollup points, spread across the window in ascending order. var rollup = Enumerable.Range(0, 6) .Select(i => new KpiSeriesPoint(from.AddHours(i * 300), i)) .ToList(); var repo = Substitute.For(); repo.GetHourlySeriesAsync( Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Task.FromResult>(rollup)); var sut = new KpiHistoryQueryService(repo, Options(defaultMax: 3)); var result = await sut.GetSeriesAsync("src", "metric", "global", null, from, to); // Routed to the rollup path... await repo.Received(1).GetHourlySeriesAsync( Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); // ...and the bucketer still applied (6 rollup rows > max of 3 → downsampled). Assert.NotSame(rollup, result); Assert.True(result.Count <= 3); } [Fact] public async Task GetSeriesAsync_LongWindow_UsesRollupPath_OnProductionCtor() { // The production (IServiceScopeFactory) ctor must apply the same threshold // routing — a long window resolves the repo from a fresh scope and calls the // hourly rollup method. var repo = RepoReturningEmptyForBoth(); var services = new ServiceCollection(); services.AddScoped(_ => repo); await using var provider = services.BuildServiceProvider(); var sut = new KpiHistoryQueryService( provider.GetRequiredService(), Options(defaultMax: 200)); var from = new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Utc); var to = from.AddDays(30); await sut.GetSeriesAsync("src", "metric", "global", null, from, to); await repo.Received(1).GetHourlySeriesAsync( "src", "metric", "global", null, from, to, Arg.Any()); await repo.DidNotReceive().GetRawSeriesAsync( Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); } // ---- Task 6: catalog-driven aggregation at the bucketer boundary (R3) ----------- [Fact] public async Task GetSeriesAsync_RateMetric_SumsPerBucket_OnRollupPath() { // (SiteCallAudit, FailedLastInterval) is a catalog Rate pair. 400 hourly points of // value 1 over a 400 h (>168 h) window, maxPoints 200 → 2 points per 2 h bucket → // every output value is 2.0 (the summed pair), not 1.0 (last value). var from = new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Utc); var to = from.AddHours(400); var rollup = Enumerable.Range(0, 400) .Select(i => new KpiSeriesPoint(from.AddHours(i), 1d)) .ToList(); var repo = Substitute.For(); repo.GetHourlySeriesAsync( Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Task.FromResult>(rollup)); var sut = new KpiHistoryQueryService(repo, Options(defaultMax: 200)); var result = await sut.GetSeriesAsync( KpiSources.SiteCallAudit, KpiMetrics.SiteCallAudit.FailedLastInterval, "global", null, from, to); Assert.Equal(200, result.Count); Assert.All(result, p => Assert.Equal(2.0, p.Value)); } [Fact] public async Task GetSeriesAsync_RateMetric_PreservesTotal_AcrossRoutingBoundary() { // Same total event count (50) served as per-minute deltas on the raw path // (window == threshold) and as hourly sums on the rollup path (window just over // threshold): the summed chart total must be equal on both routes — the boundary // is visually seamless for a Rate metric. var from = new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Utc); var rawDeltas = Enumerable.Range(0, 10) .Select(i => new KpiSeriesPoint(from.AddMinutes(i * 10), 5d)).ToList(); // total 50 var hourlySums = Enumerable.Range(0, 5) .Select(i => new KpiSeriesPoint(from.AddHours(i), 10d)).ToList(); // total 50 var repo = Substitute.For(); repo.GetRawSeriesAsync( Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Task.FromResult>(rawDeltas)); repo.GetHourlySeriesAsync( Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Task.FromResult>(hourlySums)); var sut = new KpiHistoryQueryService(repo, Options(defaultMax: 200, rollupThresholdHours: 168)); var rawResult = await sut.GetSeriesAsync( KpiSources.SiteCallAudit, KpiMetrics.SiteCallAudit.FailedLastInterval, "global", null, from, from.AddHours(168)); // raw path var rollupResult = await sut.GetSeriesAsync( KpiSources.SiteCallAudit, KpiMetrics.SiteCallAudit.FailedLastInterval, "global", null, from, from.AddHours(169)); // rollup path Assert.Equal(50d, rawResult.Sum(p => p.Value)); Assert.Equal(50d, rollupResult.Sum(p => p.Value)); Assert.Equal(rawResult.Sum(p => p.Value), rollupResult.Sum(p => p.Value)); } [Fact] public async Task GetSeriesAsync_GaugeMetric_KeepsLastValuePerBucket() { // queueDepth (uncatalogued for this source pairing → Gauge) keeps the existing // last-value-per-bucket output unchanged. 4 points, maxPoints 2, raw path. var repo = Substitute.For(); var raw = Series((0, 1d), (15, 2d), (25, 99d), (35, 5d)); repo.GetRawSeriesAsync( Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Task.FromResult(raw)); var sut = new KpiHistoryQueryService(repo, Options(defaultMax: 2)); var result = await sut.GetSeriesAsync( KpiSources.NotificationOutbox, KpiMetrics.NotificationOutbox.QueueDepth, "global", null, From, To); Assert.Equal(2, result.Count); Assert.Equal(99d, result[0].Value); // last value in bucket 0, not a sum Assert.Equal(5d, result[1].Value); } }