513fa1aa16
Comment/XML-doc changes only: - add summaries to ~10k test methods, fixtures, fakes and helpers (+ <param> on theories, <returns> on async Task methods, <inheritdoc/> on interface implementations the checker can resolve) - strip ~2.6k project-tracking references (WP*, P*-*, row-NN, Task N, #NN, Component-0NN) from comments, keeping the explanatory text; reword false positives in comments only - real docs on fakes implementing BCL/library interfaces (IDisposable, ILogger, IOptionsMonitor, IAsyncLifetime) - reword the last 5 src/ false positives (CWE-1236, failures-1, year-0001, M365) Only non-comment edits: four one-line nested classes split across lines so members can carry docs. Build 0 errors / 0 warnings.
388 lines
17 KiB
C#
388 lines
17 KiB
C#
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;
|
||
|
||
/// <summary>
|
||
/// Unit tests for <see cref="SiteCallAuditKpiSampleSource"/> (KPI history).
|
||
/// A hand-rolled <see cref="ISiteCallAuditRepository"/> 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
|
||
/// <c>(Metric, Scope, ScopeKey, Value)</c> tuples the recorder will persist, the
|
||
/// cutoffs derived from <see cref="SiteCallAuditOptions"/>, and the
|
||
/// oldest-pending-age omission when its source is <c>null</c>.
|
||
/// </summary>
|
||
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<SiteCallAuditOptions> OptionsWrap(SiteCallAuditOptions o) =>
|
||
Microsoft.Extensions.Options.Options.Create(o);
|
||
|
||
// ---------------------------------------------------------------------
|
||
// 1. Source identifier is the canonical SiteCallAudit constant.
|
||
// ---------------------------------------------------------------------
|
||
|
||
/// <summary>Verifies that the source identifier is the SiteCallAudit constant.</summary>
|
||
[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.
|
||
// ---------------------------------------------------------------------
|
||
|
||
/// <summary>Verifies that the expected tuples are emitted for the global, per-site and per-node KPIs.</summary>
|
||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||
[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).
|
||
// ---------------------------------------------------------------------
|
||
|
||
/// <summary>Verifies that the oldest pending age is omitted when it is null.</summary>
|
||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||
[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.
|
||
// ---------------------------------------------------------------------
|
||
|
||
/// <summary>Verifies that only global tuples are emitted when there are no per-site or per-node rows.</summary>
|
||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||
[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.
|
||
// ---------------------------------------------------------------------
|
||
|
||
/// <summary>Verifies that the cutoffs are derived from the options, anchored on the captured-at time.</summary>
|
||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||
[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.
|
||
// ---------------------------------------------------------------------
|
||
|
||
/// <summary>Verifies that the failed and delivered last-interval counts are rescaled onto the KPI history sample interval.</summary>
|
||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||
[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.
|
||
// ---------------------------------------------------------------------
|
||
|
||
/// <summary>Verifies that tuples are emitted for all sites and nodes.</summary>
|
||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||
[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<KpiSample> 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 ?? "<null>"})");
|
||
Assert.Equal(value, match!.Value);
|
||
}
|
||
|
||
/// <summary>Captured arguments of one KPI computation call.</summary>
|
||
private readonly record struct KpiCall(DateTime StuckCutoff, DateTime IntervalSince);
|
||
|
||
/// <summary>
|
||
/// Hand-rolled <see cref="ISiteCallAuditRepository"/> stub returning
|
||
/// configurable snapshots and recording the cutoffs each KPI method received.
|
||
/// Non-KPI members are inert (this source only reads KPIs).
|
||
/// </summary>
|
||
private sealed class StubRepo : ISiteCallAuditRepository
|
||
{
|
||
/// <summary>Gets or sets the global KPI snapshot returned by the stub.</summary>
|
||
public SiteCallKpiSnapshot Global { get; set; } =
|
||
new(0, 0, 0, 0, null, 0);
|
||
/// <summary>Gets or sets the per-site KPI snapshots returned by the stub.</summary>
|
||
public IReadOnlyList<SiteCallSiteKpiSnapshot> PerSite { get; set; } =
|
||
Array.Empty<SiteCallSiteKpiSnapshot>();
|
||
/// <summary>Gets or sets the per-node KPI snapshots returned by the stub.</summary>
|
||
public IReadOnlyList<SiteCallNodeKpiSnapshot> PerNode { get; set; } =
|
||
Array.Empty<SiteCallNodeKpiSnapshot>();
|
||
|
||
/// <summary>Gets the recorded KPI calls in the order they were made.</summary>
|
||
public List<KpiCall> Calls { get; } = new();
|
||
|
||
/// <inheritdoc />
|
||
public Task<SiteCallKpiSnapshot> ComputeKpisAsync(
|
||
DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default)
|
||
{
|
||
Calls.Add(new KpiCall(stuckCutoff, intervalSince));
|
||
return Task.FromResult(Global);
|
||
}
|
||
|
||
/// <inheritdoc />
|
||
public Task<IReadOnlyList<SiteCallSiteKpiSnapshot>> ComputePerSiteKpisAsync(
|
||
DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default)
|
||
{
|
||
Calls.Add(new KpiCall(stuckCutoff, intervalSince));
|
||
return Task.FromResult(PerSite);
|
||
}
|
||
|
||
/// <inheritdoc />
|
||
public Task<IReadOnlyList<SiteCallNodeKpiSnapshot>> ComputePerNodeKpisAsync(
|
||
DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default)
|
||
{
|
||
Calls.Add(new KpiCall(stuckCutoff, intervalSince));
|
||
return Task.FromResult(PerNode);
|
||
}
|
||
|
||
// ── Inert non-KPI members ──
|
||
/// <inheritdoc />
|
||
public Task UpsertAsync(SiteCall siteCall, CancellationToken ct = default) =>
|
||
Task.CompletedTask;
|
||
|
||
/// <inheritdoc />
|
||
public Task<SiteCall?> GetAsync(TrackedOperationId id, CancellationToken ct = default) =>
|
||
Task.FromResult<SiteCall?>(null);
|
||
|
||
/// <inheritdoc />
|
||
public Task<IReadOnlyList<SiteCall>> QueryAsync(
|
||
SiteCallQueryFilter filter, SiteCallPaging paging, CancellationToken ct = default) =>
|
||
Task.FromResult<IReadOnlyList<SiteCall>>(Array.Empty<SiteCall>());
|
||
|
||
/// <inheritdoc />
|
||
public Task<int> PurgeTerminalAsync(DateTime olderThanUtc, CancellationToken ct = default) =>
|
||
Task.FromResult(0);
|
||
}
|
||
}
|