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"]); // All 12 metrics emitted, exactly once each, for the one populated site. Assert.Equal(12, byMetric.Count); Assert.Equal(12, samples.Count); } [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(); } }