diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/ServiceCollectionExtensions.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/ServiceCollectionExtensions.cs index 48ae823d..4731c285 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/ServiceCollectionExtensions.cs +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/ServiceCollectionExtensions.cs @@ -1,10 +1,12 @@ using Microsoft.AspNetCore.Components.Authorization; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; using ZB.MOM.WW.ScadaBridge.CentralUI.Auth; using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared; using ZB.MOM.WW.ScadaBridge.CentralUI.ScriptAnalysis; using ZB.MOM.WW.ScadaBridge.CentralUI.Services; using ZB.MOM.WW.ScadaBridge.HealthMonitoring; +using ZB.MOM.WW.ScadaBridge.KpiHistory; namespace ZB.MOM.WW.ScadaBridge.CentralUI; @@ -51,6 +53,19 @@ public static class ServiceCollectionExtensions // Backs the Audit Log page's Export button via GET /api/centralui/audit/export. services.AddScoped(); + // KPI History (M6, K11): CentralUI facade over IKpiHistoryRepository that + // fetches a raw series and reduces it with KpiSeriesBucketer for the trend chart. + // + // Registered with an explicit factory so the IServiceScopeFactory ctor is + // always chosen — KpiHistoryQueryService has a second (test-seam) ctor that + // takes IKpiHistoryRepository directly, and both are constructor-resolvable, + // so default activation would be ambiguous. The scope-factory ctor opens a + // fresh DbContext per query, mirroring AuditLogQueryService so a chart's + // auto-load never races other reads on the shared circuit-scoped context. + services.AddScoped(sp => new KpiHistoryQueryService( + sp.GetRequiredService(), + sp.GetRequiredService>())); + // OPC UA Tag Browser (Task 14): facade over CommunicationService.BrowseNodeAsync // that enforces the CentralUI-side Design-role trust boundary and translates // transport failures into typed BrowseFailure results for the dialog. diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/IKpiHistoryQueryService.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/IKpiHistoryQueryService.cs new file mode 100644 index 00000000..fdd8f874 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/IKpiHistoryQueryService.cs @@ -0,0 +1,51 @@ +using ZB.MOM.WW.ScadaBridge.Commons.Types.Kpi; + +namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services; + +/// +/// CentralUI facade over +/// +/// (M6 "KPI History & Trends", K11). The reusable trend chart talks to this +/// service rather than the repository directly so tests can substitute a fake +/// without spinning up EF Core, and so the bucketing / downsampling step lives in +/// one place rather than being re-implemented per page. +/// +/// +/// The query path fetches the raw series for one (source, metric, scope, scopeKey) +/// tuple over [fromUtc, toUtc] and reduces it with +/// to at most maxPoints points. Mirroring +/// , the production implementation opens its own +/// DI scope per call so a chart's auto-load never contends with the circuit-scoped +/// ScadaBridgeDbContext; a second (test-seam) constructor injects the +/// repository directly. +/// +public interface IKpiHistoryQueryService +{ + /// + /// Returns the bucketed series for one + /// (, , , + /// ) tuple over [, + /// ], reduced to at most + /// points (defaulting to + /// KpiHistoryOptions.DefaultMaxSeriesPoints when null). + /// + /// + /// The window must be non-degenerate ( strictly after + /// ) and the effective max must be at least 2 — the + /// page layer guarantees both; this service passes them straight through to + /// , which throws + /// on violation. + /// + /// Source identifier — a value from KpiSources. + /// Metric name from the source's catalog. + /// Scope discriminator — a value from KpiScopes. + /// Scope qualifier (site id / node name); null for the Global scope. + /// Inclusive lower bound of the time window (UTC). + /// Inclusive upper bound of the time window (UTC); must be after . + /// Optional ceiling on returned points; defaults to the configured default when null. + /// Cancellation token. + /// A task resolving to the bucketed series in ascending bucket-start order. + Task> GetSeriesAsync( + string source, string metric, string scope, string? scopeKey, + DateTime fromUtc, DateTime toUtc, int? maxPoints = null, CancellationToken cancellationToken = default); +} diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/KpiHistoryQueryService.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/KpiHistoryQueryService.cs new file mode 100644 index 00000000..3d6f613e --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/KpiHistoryQueryService.cs @@ -0,0 +1,95 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +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.Services; + +/// +/// Default implementation (M6 K11) — fetches +/// the raw series via and +/// reduces it with to at most the requested +/// (or configured-default) number of points. +/// +/// +/// +/// Mirrors : the production constructor takes an +/// and opens a fresh DI scope per query — +/// resolving a fresh (and therefore a fresh +/// ScadaBridgeDbContext) — so a trend chart's auto-load never contends with +/// the circuit-scoped context the rest of the page uses. +/// +/// +/// A second constructor injects an directly — a +/// test seam (same dual-ctor pattern as ) so unit +/// tests can substitute a stub without standing up a DI container. Both ctors take +/// for the default series-point ceiling. +/// +/// +public sealed class KpiHistoryQueryService : IKpiHistoryQueryService +{ + // Production path: open a fresh scope per operation. Null in the test-seam ctor. + private readonly IServiceScopeFactory? _scopeFactory; + + // Test seam: a directly-injected repository whose lifetime the test owns. + // Null in the production ctor. + private readonly IKpiHistoryRepository? _injectedRepository; + + private readonly KpiHistoryOptions _options; + + /// + /// Production constructor — resolves from a + /// fresh DI scope on every call so each query gets its own + /// ScadaBridgeDbContext and never contends with the circuit-scoped context. + /// + /// Factory used to open a fresh DI scope per query. + /// KPI History options supplying the default series-point ceiling. + public KpiHistoryQueryService( + IServiceScopeFactory scopeFactory, + IOptions options) + { + _scopeFactory = scopeFactory ?? throw new ArgumentNullException(nameof(scopeFactory)); + ArgumentNullException.ThrowIfNull(options); + _options = options.Value ?? throw new ArgumentNullException(nameof(options)); + } + + /// + /// Test-seam constructor — injects a repository instance whose lifetime the + /// caller owns. Used by unit tests that substitute a stub repository. + /// + /// The KPI history repository instance to use directly. + /// KPI History options supplying the default series-point ceiling. + public KpiHistoryQueryService( + IKpiHistoryRepository repository, + IOptions options) + { + _injectedRepository = repository ?? throw new ArgumentNullException(nameof(repository)); + ArgumentNullException.ThrowIfNull(options); + _options = options.Value ?? throw new ArgumentNullException(nameof(options)); + } + + /// + public async Task> GetSeriesAsync( + string source, string metric, string scope, string? scopeKey, + DateTime fromUtc, DateTime toUtc, int? maxPoints = null, CancellationToken cancellationToken = default) + { + var effectiveMax = maxPoints ?? _options.DefaultMaxSeriesPoints; + + // Test-seam ctor: use the injected repository directly. + if (_injectedRepository is not null) + { + var injectedRaw = await _injectedRepository.GetRawSeriesAsync( + source, metric, scope, scopeKey, fromUtc, toUtc, cancellationToken); + return KpiSeriesBucketer.Bucket(injectedRaw, fromUtc, toUtc, effectiveMax); + } + + // Production: a fresh scope (and thus a fresh DbContext) per query so a + // chart's auto-load never shares the circuit-scoped context. + await using var serviceScope = _scopeFactory!.CreateAsyncScope(); + var repository = serviceScope.ServiceProvider.GetRequiredService(); + var raw = await repository.GetRawSeriesAsync( + source, metric, scope, scopeKey, fromUtc, toUtc, cancellationToken); + return KpiSeriesBucketer.Bucket(raw, fromUtc, toUtc, effectiveMax); + } +} diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/ZB.MOM.WW.ScadaBridge.CentralUI.csproj b/src/ZB.MOM.WW.ScadaBridge.CentralUI/ZB.MOM.WW.ScadaBridge.CentralUI.csproj index 49d53880..58521198 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/ZB.MOM.WW.ScadaBridge.CentralUI.csproj +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/ZB.MOM.WW.ScadaBridge.CentralUI.csproj @@ -28,6 +28,7 @@ + diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/KpiHistoryQueryServiceTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/KpiHistoryQueryServiceTests.cs new file mode 100644 index 00000000..8d32e129 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/KpiHistoryQueryServiceTests.cs @@ -0,0 +1,179 @@ +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) => + Microsoft.Extensions.Options.Options.Create(new KpiHistoryOptions { DefaultMaxSeriesPoints = defaultMax }); + + 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]); + } +}