using Microsoft.Extensions.Options; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Kpi; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories; using ZB.MOM.WW.ScadaBridge.Commons.Types; using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit; using ZB.MOM.WW.ScadaBridge.Commons.Types.Kpi; using ZB.MOM.WW.ScadaBridge.KpiHistory; using ZB.MOM.WW.ScadaBridge.SiteCallAudit.Kpi; namespace ZB.MOM.WW.ScadaBridge.SiteCallAudit.Tests.Kpi; /// /// Unit tests for (KPI history). /// A hand-rolled fake (matching the /// project's existing test-double style — no mocking library) returns known /// global + per-site + per-node snapshots; the assertions pin the exact /// (Metric, Scope, ScopeKey, Value) tuples the recorder will persist, the /// cutoffs derived from , and the /// oldest-pending-age omission when its source is null. /// public class SiteCallAuditKpiSampleSourceTests { private static readonly DateTime CapturedAt = new(2026, 6, 15, 12, 0, 0, DateTimeKind.Utc); private static SiteCallAuditOptions Options( TimeSpan? stuckAge = null, TimeSpan? kpiInterval = null) => new() { StuckAgeThreshold = stuckAge ?? TimeSpan.FromMinutes(10), KpiInterval = kpiInterval ?? TimeSpan.FromMinutes(1), }; // Matches Options().KpiInterval (1 minute) so the default-config tests below see an // identity rescale factor (1.0) and their pre-existing raw-count assertions hold. private static readonly KpiHistoryOptions DefaultKpiHistoryOptions = new() { SampleInterval = TimeSpan.FromMinutes(1), }; private static SiteCallAuditKpiSampleSource CreateSource( ISiteCallAuditRepository repo, SiteCallAuditOptions? options = null, KpiHistoryOptions? kpiHistoryOptions = null) => new( repo, OptionsWrap(options ?? Options()), Microsoft.Extensions.Options.Options.Create(kpiHistoryOptions ?? DefaultKpiHistoryOptions)); private static IOptions OptionsWrap(SiteCallAuditOptions o) => Microsoft.Extensions.Options.Options.Create(o); // --------------------------------------------------------------------- // 1. Source identifier is the canonical SiteCallAudit constant. // --------------------------------------------------------------------- /// Verifies that the source identifier is the SiteCallAudit constant. [Fact] public void Source_IsSiteCallAuditConstant() { var source = CreateSource(new StubRepo()); Assert.Equal(KpiSources.SiteCallAudit, source.Source); } // --------------------------------------------------------------------- // 2. Full snapshot: global + per-site + per-node, exact tuples. // --------------------------------------------------------------------- /// Verifies that the expected tuples are emitted for the global, per-site and per-node KPIs. /// A task that represents the asynchronous operation. [Fact] public async Task CollectAsync_EmitsExpectedTuples_ForGlobalSiteAndNode() { var repo = new StubRepo { Global = new SiteCallKpiSnapshot( BufferedCount: 5, ParkedCount: 2, FailedLastInterval: 1, DeliveredLastInterval: 9, OldestPendingAge: TimeSpan.FromSeconds(42), StuckCount: 3), PerSite = [ new SiteCallSiteKpiSnapshot( SourceSite: "site-a", BufferedCount: 4, ParkedCount: 1, FailedLastInterval: 0, DeliveredLastInterval: 7, OldestPendingAge: TimeSpan.FromSeconds(30), StuckCount: 2), ], PerNode = [ new SiteCallNodeKpiSnapshot( SourceNode: "node-a", BufferedCount: 3, ParkedCount: 1, FailedLastInterval: 1, DeliveredLastInterval: 5, OldestPendingAge: TimeSpan.FromSeconds(20), StuckCount: 1), ], }; var samples = await CreateSource(repo).CollectAsync(CapturedAt); // Every sample carries Source + CapturedAtUtc. Assert.All(samples, s => { Assert.Equal(KpiSources.SiteCallAudit, s.Source); Assert.Equal(CapturedAt, s.CapturedAtUtc); }); // Global (null ScopeKey): six metrics (age present). AssertHas(samples, "buffered", KpiScopes.Global, null, 5); AssertHas(samples, "parked", KpiScopes.Global, null, 2); AssertHas(samples, "failedLastInterval", KpiScopes.Global, null, 1); AssertHas(samples, "deliveredLastInterval", KpiScopes.Global, null, 9); AssertHas(samples, "oldestPendingAgeSeconds", KpiScopes.Global, null, 42); AssertHas(samples, "stuck", KpiScopes.Global, null, 3); // Per-site (ScopeKey = site id). AssertHas(samples, "buffered", KpiScopes.Site, "site-a", 4); AssertHas(samples, "parked", KpiScopes.Site, "site-a", 1); AssertHas(samples, "failedLastInterval", KpiScopes.Site, "site-a", 0); AssertHas(samples, "deliveredLastInterval", KpiScopes.Site, "site-a", 7); AssertHas(samples, "oldestPendingAgeSeconds", KpiScopes.Site, "site-a", 30); AssertHas(samples, "stuck", KpiScopes.Site, "site-a", 2); // Per-node (ScopeKey = node name). AssertHas(samples, "buffered", KpiScopes.Node, "node-a", 3); AssertHas(samples, "parked", KpiScopes.Node, "node-a", 1); AssertHas(samples, "failedLastInterval", KpiScopes.Node, "node-a", 1); AssertHas(samples, "deliveredLastInterval", KpiScopes.Node, "node-a", 5); AssertHas(samples, "oldestPendingAgeSeconds", KpiScopes.Node, "node-a", 20); AssertHas(samples, "stuck", KpiScopes.Node, "node-a", 1); // 6 metrics × 3 scopes, all ages present. Assert.Equal(18, samples.Count); } // --------------------------------------------------------------------- // 3. Null oldest-pending-age is omitted (not written as zero). // --------------------------------------------------------------------- /// Verifies that the oldest pending age is omitted when it is null. /// A task that represents the asynchronous operation. [Fact] public async Task CollectAsync_OmitsOldestPendingAge_WhenNull() { var repo = new StubRepo { Global = new SiteCallKpiSnapshot(0, 0, 0, 0, OldestPendingAge: null, 0), PerSite = [ new SiteCallSiteKpiSnapshot("site-a", 0, 0, 0, 0, OldestPendingAge: null, 0), ], PerNode = [ new SiteCallNodeKpiSnapshot("node-a", 0, 0, 0, 0, OldestPendingAge: null, 0), ], }; var samples = await CreateSource(repo).CollectAsync(CapturedAt); // No oldestPendingAgeSeconds rows at any scope when the age is null. Assert.DoesNotContain(samples, s => s.Metric == "oldestPendingAgeSeconds"); // The five count metrics are still present at each of the 3 scopes. Assert.Equal(15, samples.Count); AssertHas(samples, "buffered", KpiScopes.Global, null, 0); AssertHas(samples, "stuck", KpiScopes.Node, "node-a", 0); } // --------------------------------------------------------------------- // 4. Empty per-site/per-node: only the global six metrics emitted. // --------------------------------------------------------------------- /// Verifies that only global tuples are emitted when there are no per-site or per-node rows. /// A task that represents the asynchronous operation. [Fact] public async Task CollectAsync_GlobalOnly_WhenNoPerSiteOrPerNodeRows() { var repo = new StubRepo { Global = new SiteCallKpiSnapshot(1, 0, 0, 0, TimeSpan.FromSeconds(5), 0), PerSite = [], PerNode = [], }; var samples = await CreateSource(repo).CollectAsync(CapturedAt); Assert.All(samples, s => Assert.Equal(KpiScopes.Global, s.Scope)); Assert.Equal(6, samples.Count); } // --------------------------------------------------------------------- // 5. Cutoffs are anchored on capturedAtUtc using the options windows. // --------------------------------------------------------------------- /// Verifies that the cutoffs are derived from the options, anchored on the captured-at time. /// A task that represents the asynchronous operation. [Fact] public async Task CollectAsync_DerivesCutoffs_FromOptionsAnchoredOnCapturedAt() { var repo = new StubRepo(); var options = Options( stuckAge: TimeSpan.FromMinutes(10), kpiInterval: TimeSpan.FromMinutes(2)); await CreateSource(repo, options).CollectAsync(CapturedAt); var expectedStuck = CapturedAt - TimeSpan.FromMinutes(10); var expectedSince = CapturedAt - TimeSpan.FromMinutes(2); // Every repository call must have received the identical anchored cutoffs. Assert.NotEmpty(repo.Calls); Assert.All(repo.Calls, c => { Assert.Equal(expectedStuck, c.StuckCutoff); Assert.Equal(expectedSince, c.IntervalSince); }); // All three KPI methods were invoked. Assert.Equal(3, repo.Calls.Count); } // --------------------------------------------------------------------- // Rescaling: failedLastInterval/deliveredLastInterval are rescaled onto // KpiHistoryOptions.SampleInterval before recording, so the central hourly rollup fold's // per-hour Sum stays correct regardless of how KpiInterval is tuned. Before the fix, this // class recorded the RAW KpiInterval-windowed counts verbatim; with a 20-minute // KpiInterval sampled every 1-minute SampleInterval, summing 60 overlapping 20-minute // counts across an hour would over-count ~20x. Proven against the unfixed computation: // temporarily short-circuiting IntervalScaleFactor to always return 1.0 (i.e. "record the // raw count verbatim", the pre-fix behavior) makes this test fail (40/1 != 2/0.05); // restoring the real scale factor passes it. // --------------------------------------------------------------------- /// Verifies that the failed and delivered last-interval counts are rescaled onto the KPI history sample interval. /// A task that represents the asynchronous operation. [Fact] public async Task CollectAsync_RescalesFailedAndDeliveredLastInterval_OntoKpiHistorySampleInterval() { var repo = new StubRepo { Global = new SiteCallKpiSnapshot( BufferedCount: 0, ParkedCount: 0, FailedLastInterval: 1, DeliveredLastInterval: 40, OldestPendingAge: null, StuckCount: 0), }; // KpiInterval (20 min) is 20x KpiHistoryOptions.SampleInterval (1 min) — a // non-default SampleInterval configuration deliberately chosen to prove the rescale // math, not just the identity (factor == 1) default-config case the other tests use. var options = Options(kpiInterval: TimeSpan.FromMinutes(20)); var kpiHistoryOptions = new KpiHistoryOptions { SampleInterval = TimeSpan.FromMinutes(1) }; var samples = await CreateSource(repo, options, kpiHistoryOptions).CollectAsync(CapturedAt); // 1 failure / 40 deliveries counted over a 20-minute window, rescaled onto a // 1-minute recorder tick: divide by 20. Summing 60 such 1-minute-tick samples across // an hour therefore reconstructs the true hourly totals instead of the ~20x // over-count a raw (unscaled) 20-minute count would produce if simply summed 60 times. AssertHas(samples, "failedLastInterval", KpiScopes.Global, null, 0.05); AssertHas(samples, "deliveredLastInterval", KpiScopes.Global, null, 2.0); } // --------------------------------------------------------------------- // 6. Multiple sites/nodes are all emitted with distinct ScopeKeys. // --------------------------------------------------------------------- /// Verifies that tuples are emitted for all sites and nodes. /// A task that represents the asynchronous operation. [Fact] public async Task CollectAsync_EmitsAllSitesAndNodes() { var repo = new StubRepo { Global = new SiteCallKpiSnapshot(0, 0, 0, 0, null, 0), PerSite = [ new SiteCallSiteKpiSnapshot("site-a", 1, 0, 0, 0, null, 0), new SiteCallSiteKpiSnapshot("site-b", 2, 0, 0, 0, null, 0), ], PerNode = [ new SiteCallNodeKpiSnapshot("node-a", 0, 0, 0, 0, null, 0), new SiteCallNodeKpiSnapshot("node-b", 0, 0, 0, 0, null, 0), ], }; var samples = await CreateSource(repo).CollectAsync(CapturedAt); AssertHas(samples, "buffered", KpiScopes.Site, "site-a", 1); AssertHas(samples, "buffered", KpiScopes.Site, "site-b", 2); Assert.Contains(samples, s => s is { Scope: "Node", ScopeKey: "node-a" }); Assert.Contains(samples, s => s is { Scope: "Node", ScopeKey: "node-b" }); } // --------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------- private static void AssertHas( IReadOnlyList samples, string metric, string scope, string? scopeKey, double value) { var match = samples.SingleOrDefault(s => s.Metric == metric && s.Scope == scope && s.ScopeKey == scopeKey); Assert.True( match is not null, $"expected a sample for ({metric}, {scope}, {scopeKey ?? ""})"); Assert.Equal(value, match!.Value); } /// Captured arguments of one KPI computation call. private readonly record struct KpiCall(DateTime StuckCutoff, DateTime IntervalSince); /// /// Hand-rolled stub returning /// configurable snapshots and recording the cutoffs each KPI method received. /// Non-KPI members are inert (this source only reads KPIs). /// private sealed class StubRepo : ISiteCallAuditRepository { /// Gets or sets the global KPI snapshot returned by the stub. public SiteCallKpiSnapshot Global { get; set; } = new(0, 0, 0, 0, null, 0); /// Gets or sets the per-site KPI snapshots returned by the stub. public IReadOnlyList PerSite { get; set; } = Array.Empty(); /// Gets or sets the per-node KPI snapshots returned by the stub. public IReadOnlyList PerNode { get; set; } = Array.Empty(); /// Gets the recorded KPI calls in the order they were made. public List Calls { get; } = new(); /// public Task ComputeKpisAsync( DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) { Calls.Add(new KpiCall(stuckCutoff, intervalSince)); return Task.FromResult(Global); } /// public Task> ComputePerSiteKpisAsync( DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) { Calls.Add(new KpiCall(stuckCutoff, intervalSince)); return Task.FromResult(PerSite); } /// public Task> ComputePerNodeKpisAsync( DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) { Calls.Add(new KpiCall(stuckCutoff, intervalSince)); return Task.FromResult(PerNode); } // ── Inert non-KPI members ── /// public Task UpsertAsync(SiteCall siteCall, CancellationToken ct = default) => Task.CompletedTask; /// public Task GetAsync(TrackedOperationId id, CancellationToken ct = default) => Task.FromResult(null); /// public Task> QueryAsync( SiteCallQueryFilter filter, SiteCallPaging paging, CancellationToken ct = default) => Task.FromResult>(Array.Empty()); /// public Task PurgeTerminalAsync(DateTime olderThanUtc, CancellationToken ct = default) => Task.FromResult(0); } }