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.
279 lines
12 KiB
C#
279 lines
12 KiB
C#
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Kpi;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Health;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Kpi;
|
|
using ZB.MOM.WW.ScadaBridge.HealthMonitoring.Kpi;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.HealthMonitoring.Tests.Kpi;
|
|
|
|
/// <summary>
|
|
/// "KPI History & Trends" coverage for
|
|
/// <see cref="SiteHealthKpiSampleSource"/>. The source reads the in-memory
|
|
/// <see cref="ICentralHealthAggregator"/> and emits per-site
|
|
/// (<see cref="KpiScopes.Site"/>) <see cref="KpiSample"/> rows for each site that
|
|
/// has reported a <see cref="SiteHealthReport"/>; sites known only via heartbeats
|
|
/// (null <see cref="SiteHealthState.LatestReport"/>) contribute nothing.
|
|
/// </summary>
|
|
public class SiteHealthKpiSampleSourceTests
|
|
{
|
|
private static readonly DateTime CapturedAt =
|
|
new(2026, 6, 17, 12, 0, 0, DateTimeKind.Utc);
|
|
|
|
/// <summary>Verifies that the source identifier is the site health source.</summary>
|
|
[Fact]
|
|
public void Source_Is_SiteHealth()
|
|
{
|
|
var source = new SiteHealthKpiSampleSource(new StubAggregator());
|
|
Assert.Equal(KpiSources.SiteHealth, source.Source);
|
|
}
|
|
|
|
/// <summary>Verifies that collecting emits every metric for a populated site and nothing for a site with a null report.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task CollectAsync_PopulatedSite_EmitsAllMetrics_NullReportSite_EmitsNothing()
|
|
{
|
|
// site-a: fully-populated report. site-b: heartbeat-only (null report).
|
|
var report = new SiteHealthReport(
|
|
SiteId: "site-a",
|
|
SequenceNumber: 5,
|
|
ReportTimestamp: CapturedAt,
|
|
DataConnectionStatuses: new Dictionary<string, ConnectionHealth>
|
|
{
|
|
["conn-1"] = ConnectionHealth.Connected,
|
|
["conn-2"] = ConnectionHealth.Connected,
|
|
["conn-3"] = ConnectionHealth.Disconnected,
|
|
["conn-4"] = ConnectionHealth.Connecting,
|
|
["conn-5"] = ConnectionHealth.Error,
|
|
},
|
|
TagResolutionCounts: new Dictionary<string, TagResolutionStatus>(),
|
|
ScriptErrorCount: 3,
|
|
AlarmEvaluationErrorCount: 4,
|
|
StoreAndForwardBufferDepths: new Dictionary<string, int>
|
|
{
|
|
["buf-1"] = 10,
|
|
["buf-2"] = 15,
|
|
},
|
|
DeadLetterCount: 6,
|
|
DeployedInstanceCount: 20,
|
|
EnabledInstanceCount: 18,
|
|
DisabledInstanceCount: 2,
|
|
ParkedMessageCount: 7,
|
|
SiteAuditBacklog: new SiteAuditBacklogSnapshot(
|
|
PendingCount: 9, OldestPendingUtc: null, OnDiskBytes: 0),
|
|
SiteEventLogWriteFailures: 11);
|
|
|
|
var aggregator = new StubAggregator
|
|
{
|
|
States =
|
|
{
|
|
["site-a"] = new SiteHealthState { SiteId = "site-a", LatestReport = report },
|
|
["site-b"] = new SiteHealthState { SiteId = "site-b", LatestReport = null },
|
|
},
|
|
};
|
|
|
|
var source = new SiteHealthKpiSampleSource(aggregator);
|
|
|
|
var samples = await source.CollectAsync(CapturedAt);
|
|
|
|
// Every sample is for site-a only — the null-report site yields nothing.
|
|
Assert.All(samples, s =>
|
|
{
|
|
Assert.Equal(KpiSources.SiteHealth, s.Source);
|
|
Assert.Equal(KpiScopes.Site, s.Scope);
|
|
Assert.Equal("site-a", s.ScopeKey);
|
|
Assert.Equal(CapturedAt, s.CapturedAtUtc);
|
|
});
|
|
Assert.DoesNotContain(samples, s => s.ScopeKey == "site-b");
|
|
|
|
// Exact (Metric, Value) tuples for the populated site.
|
|
var byMetric = samples.ToDictionary(s => s.Metric, s => s.Value);
|
|
|
|
Assert.Equal(2, byMetric["connectionsUp"]); // 2 Connected
|
|
Assert.Equal(3, byMetric["connectionsDown"]); // Disconnected + Connecting + Error
|
|
Assert.Equal(3, byMetric["scriptErrors"]);
|
|
Assert.Equal(4, byMetric["alarmEvalErrors"]);
|
|
Assert.Equal(25, byMetric["sfBufferDepth"]); // 10 + 15
|
|
Assert.Equal(6, byMetric["deadLetters"]);
|
|
Assert.Equal(7, byMetric["parkedMessages"]);
|
|
Assert.Equal(20, byMetric["deployedInstances"]);
|
|
Assert.Equal(18, byMetric["enabledInstances"]);
|
|
Assert.Equal(2, byMetric["disabledInstances"]);
|
|
Assert.Equal(9, byMetric["auditBacklogPending"]);
|
|
Assert.Equal(11, byMetric["eventLogWriteFailures"]);
|
|
|
|
// 12 metrics for an UNREPLICATED site — this fixture leaves both LocalDb replication
|
|
// fields null, and register row 33 emits those two conditionally rather than coercing
|
|
// null to 0. A replicated site emits 14; see the LocalDb tests below.
|
|
Assert.Equal(12, byMetric.Count);
|
|
Assert.Equal(12, samples.Count);
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// LocalDb replication metrics (register row 33)
|
|
// ------------------------------------------------------------------
|
|
|
|
/// <summary>Verifies that collecting emits neither LocalDb metric when the LocalDb fields are null.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task CollectAsync_NullLocalDbFields_EmitsNeitherLocalDbMetric()
|
|
{
|
|
// THE null-semantics case, and the reason these two are not `?? 0` like the audit
|
|
// backlog above. Null means "no reading" — an unreplicated site (rig site-b/site-c) or
|
|
// a failed poll. A fabricated 0 backlog would render a pair that cannot read its own
|
|
// oplog, or one deliberately not replicated, as perfectly healthy once a minute forever.
|
|
var report = MinimalReport("site-a") with
|
|
{
|
|
LocalDbOplogBacklog = null,
|
|
LocalDbReplicationConnected = null,
|
|
};
|
|
var aggregator = new StubAggregator
|
|
{
|
|
States = { ["site-a"] = new SiteHealthState { SiteId = "site-a", LatestReport = report } },
|
|
};
|
|
|
|
var samples = await new SiteHealthKpiSampleSource(aggregator).CollectAsync(CapturedAt);
|
|
|
|
Assert.DoesNotContain(samples, s => s.Metric == "localDbOplogBacklog");
|
|
Assert.DoesNotContain(samples, s => s.Metric == "localDbReplicationConnected");
|
|
Assert.Equal(12, samples.Count);
|
|
}
|
|
|
|
/// <summary>Verifies that collecting emits both LocalDb metrics for a replicated site.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task CollectAsync_ReplicatedSite_EmitsBothLocalDbMetrics()
|
|
{
|
|
var report = MinimalReport("site-a") with
|
|
{
|
|
LocalDbOplogBacklog = 4242,
|
|
LocalDbReplicationConnected = true,
|
|
};
|
|
var aggregator = new StubAggregator
|
|
{
|
|
States = { ["site-a"] = new SiteHealthState { SiteId = "site-a", LatestReport = report } },
|
|
};
|
|
|
|
var samples = await new SiteHealthKpiSampleSource(aggregator).CollectAsync(CapturedAt);
|
|
|
|
Assert.Equal(4242, samples.Single(s => s.Metric == "localDbOplogBacklog").Value);
|
|
Assert.Equal(1, samples.Single(s => s.Metric == "localDbReplicationConnected").Value);
|
|
Assert.Equal(14, samples.Count);
|
|
}
|
|
|
|
/// <summary>Verifies that collecting still emits a zero backlog value.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task CollectAsync_ZeroBacklog_IsStillEmitted()
|
|
{
|
|
// A real, measured 0 is genuinely different from "no reading" and must be recorded —
|
|
// otherwise a healthy replicated site would be indistinguishable from an unreplicated
|
|
// one, and the drain half of a purge burst would never appear in the series.
|
|
var report = MinimalReport("site-a") with { LocalDbOplogBacklog = 0 };
|
|
var aggregator = new StubAggregator
|
|
{
|
|
States = { ["site-a"] = new SiteHealthState { SiteId = "site-a", LatestReport = report } },
|
|
};
|
|
|
|
var samples = await new SiteHealthKpiSampleSource(aggregator).CollectAsync(CapturedAt);
|
|
|
|
Assert.Equal(0, samples.Single(s => s.Metric == "localDbOplogBacklog").Value);
|
|
}
|
|
|
|
/// <summary>Verifies that collecting emits zero rather than omitting the metric when replication is disconnected.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task CollectAsync_DisconnectedReplication_EmitsZeroNotAbsent()
|
|
{
|
|
// false and null are different states: a configured link that is DOWN versus no link
|
|
// at all. Only the former is a fault, and it must show up as a 0 sample rather than a
|
|
// gap, or a broken link would look exactly like an unreplicated site.
|
|
var report = MinimalReport("site-a") with { LocalDbReplicationConnected = false };
|
|
var aggregator = new StubAggregator
|
|
{
|
|
States = { ["site-a"] = new SiteHealthState { SiteId = "site-a", LatestReport = report } },
|
|
};
|
|
|
|
var samples = await new SiteHealthKpiSampleSource(aggregator).CollectAsync(CapturedAt);
|
|
|
|
Assert.Equal(0, samples.Single(s => s.Metric == "localDbReplicationConnected").Value);
|
|
}
|
|
|
|
/// <summary>Verifies that collecting emits zero pending audit backlog when the audit backlog is null.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task CollectAsync_NullAuditBacklog_EmitsZeroAuditBacklogPending()
|
|
{
|
|
var report = MinimalReport("site-a") with { SiteAuditBacklog = null };
|
|
var aggregator = new StubAggregator
|
|
{
|
|
States = { ["site-a"] = new SiteHealthState { SiteId = "site-a", LatestReport = report } },
|
|
};
|
|
|
|
var samples = await new SiteHealthKpiSampleSource(aggregator).CollectAsync(CapturedAt);
|
|
|
|
Assert.Equal(0, samples.Single(s => s.Metric == "auditBacklogPending").Value);
|
|
}
|
|
|
|
/// <summary>Verifies that collecting returns an empty list when no site has a report.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task CollectAsync_NoSitesWithReports_ReturnsEmptyList()
|
|
{
|
|
var aggregator = new StubAggregator
|
|
{
|
|
States = { ["site-b"] = new SiteHealthState { SiteId = "site-b", LatestReport = null } },
|
|
};
|
|
|
|
var samples = await new SiteHealthKpiSampleSource(aggregator).CollectAsync(CapturedAt);
|
|
|
|
Assert.NotNull(samples);
|
|
Assert.Empty(samples);
|
|
}
|
|
|
|
private static SiteHealthReport MinimalReport(string siteId) =>
|
|
new(
|
|
SiteId: siteId,
|
|
SequenceNumber: 1,
|
|
ReportTimestamp: CapturedAt,
|
|
DataConnectionStatuses: new Dictionary<string, ConnectionHealth>(),
|
|
TagResolutionCounts: new Dictionary<string, TagResolutionStatus>(),
|
|
ScriptErrorCount: 0,
|
|
AlarmEvaluationErrorCount: 0,
|
|
StoreAndForwardBufferDepths: new Dictionary<string, int>(),
|
|
DeadLetterCount: 0,
|
|
DeployedInstanceCount: 0,
|
|
EnabledInstanceCount: 0,
|
|
DisabledInstanceCount: 0);
|
|
|
|
/// <summary>
|
|
/// Hand-rolled <see cref="ICentralHealthAggregator"/> stub — the
|
|
/// HealthMonitoring.Tests project has no mocking library. Only
|
|
/// <see cref="GetAllSiteStates"/> is exercised by the source under test.
|
|
/// </summary>
|
|
private sealed class StubAggregator : ICentralHealthAggregator
|
|
{
|
|
/// <summary>Gets the canned per-site health states returned by the fake.</summary>
|
|
public Dictionary<string, SiteHealthState> States { get; } = new();
|
|
|
|
/// <inheritdoc />
|
|
public IReadOnlyDictionary<string, SiteHealthState> GetAllSiteStates() => States;
|
|
|
|
/// <inheritdoc />
|
|
public SiteHealthState? GetSiteState(string siteId) =>
|
|
States.TryGetValue(siteId, out var state) ? state : null;
|
|
|
|
/// <inheritdoc />
|
|
public void ProcessReport(SiteHealthReport report) =>
|
|
throw new NotSupportedException();
|
|
|
|
/// <inheritdoc />
|
|
public void MarkHeartbeat(string siteId, DateTimeOffset receivedAt) =>
|
|
throw new NotSupportedException();
|
|
|
|
/// <inheritdoc />
|
|
public void PruneUnknownSites(IReadOnlyCollection<string> knownSiteIds) =>
|
|
throw new NotSupportedException();
|
|
}
|
|
}
|