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);
}
}