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; /// /// M6 "KPI History & Trends" (K9) coverage for /// . The source reads the in-memory /// and emits per-site /// () rows for each site that /// has reported a ; sites known only via heartbeats /// (null ) contribute nothing. /// public class SiteHealthKpiSampleSourceTests { private static readonly DateTime CapturedAt = new(2026, 6, 17, 12, 0, 0, DateTimeKind.Utc); [Fact] public void Source_Is_SiteHealth() { var source = new SiteHealthKpiSampleSource(new StubAggregator()); Assert.Equal(KpiSources.SiteHealth, source.Source); } [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 { ["conn-1"] = ConnectionHealth.Connected, ["conn-2"] = ConnectionHealth.Connected, ["conn-3"] = ConnectionHealth.Disconnected, ["conn-4"] = ConnectionHealth.Connecting, ["conn-5"] = ConnectionHealth.Error, }, TagResolutionCounts: new Dictionary(), ScriptErrorCount: 3, AlarmEvaluationErrorCount: 4, StoreAndForwardBufferDepths: new Dictionary { ["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) // ------------------------------------------------------------------ [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); } [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); } [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); } [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); } [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); } [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(), TagResolutionCounts: new Dictionary(), ScriptErrorCount: 0, AlarmEvaluationErrorCount: 0, StoreAndForwardBufferDepths: new Dictionary(), DeadLetterCount: 0, DeployedInstanceCount: 0, EnabledInstanceCount: 0, DisabledInstanceCount: 0); /// /// Hand-rolled stub — the /// HealthMonitoring.Tests project has no mocking library. Only /// is exercised by the source under test. /// private sealed class StubAggregator : ICentralHealthAggregator { public Dictionary States { get; } = new(); public IReadOnlyDictionary GetAllSiteStates() => States; public SiteHealthState? GetSiteState(string siteId) => States.TryGetValue(siteId, out var state) ? state : null; public void ProcessReport(SiteHealthReport report) => throw new NotSupportedException(); public void MarkHeartbeat(string siteId, DateTimeOffset receivedAt) => throw new NotSupportedException(); public void PruneUnknownSites(IReadOnlyCollection knownSiteIds) => throw new NotSupportedException(); } }