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.SiteCallAudit.Kpi; namespace ZB.MOM.WW.ScadaBridge.SiteCallAudit.Tests.Kpi; /// /// Unit tests for (M6 KPI History, /// #K7). 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), }; private static SiteCallAuditKpiSampleSource CreateSource( ISiteCallAuditRepository repo, SiteCallAuditOptions? options = null) => new(repo, OptionsWrap(options ?? Options())); private static IOptions OptionsWrap(SiteCallAuditOptions o) => Microsoft.Extensions.Options.Options.Create(o); // --------------------------------------------------------------------- // 1. Source identifier is the canonical 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. // --------------------------------------------------------------------- [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). // --------------------------------------------------------------------- [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. // --------------------------------------------------------------------- [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. // --------------------------------------------------------------------- [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); } // --------------------------------------------------------------------- // 6. Multiple sites/nodes are all emitted with distinct ScopeKeys. // --------------------------------------------------------------------- [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 { public SiteCallKpiSnapshot Global { get; set; } = new(0, 0, 0, 0, null, 0); public IReadOnlyList PerSite { get; set; } = Array.Empty(); public IReadOnlyList PerNode { get; set; } = Array.Empty(); 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); } }