using System.Diagnostics.Metrics; using ZB.MOM.WW.ScadaBridge.Commons.Observability; using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Observability; /// /// Register row 54: the real per-site data-connection gauges. Before these existed the only /// connection-ish series was scadabridge_site_connection_up, which counted open /// central→site gRPC subscriber streams and sat at 0 on a healthy unwatched site — so there /// was no metric that answered "are this site's devices actually connected?" at all. /// /// /// Every assertion here is a DELTA against a baseline taken inside the test, and every /// connection name is unique to the test. That is deliberate: the registry behind these /// gauges is a process-global static, so absolute assertions would inherit the register /// row 32 flake that makes QueueDepthGaugeTests pass alone and fail under the full /// concurrent suite. Each test also removes its own connections in a finally so it /// leaves the shared registry exactly as it found it. /// public sealed class DataConnectionGaugeTests { private const string ConnectedGauge = "scadabridge.site.data_connections.connected"; private const string TotalGauge = "scadabridge.site.data_connections.total"; /// Verifies that the connected state raises both the connected and total gauges. [Fact] public void ConnectedState_RaisesConnectedAndTotal() { var name = UniqueName(); var connectedBefore = ReadGauge(ConnectedGauge); var totalBefore = ReadGauge(TotalGauge); try { ScadaBridgeTelemetry.RecordDataConnectionState(name, ConnectionHealth.Connected); Assert.Equal(connectedBefore + 1, ReadGauge(ConnectedGauge)); Assert.Equal(totalBefore + 1, ReadGauge(TotalGauge)); } finally { ScadaBridgeTelemetry.RemoveDataConnection(name); } } /// Verifies that a non-connected state raises only the total gauge. /// The state. [Theory] [InlineData(ConnectionHealth.Disconnected)] [InlineData(ConnectionHealth.Connecting)] [InlineData(ConnectionHealth.Error)] public void NonConnectedState_RaisesTotalOnly(ConnectionHealth state) { // The load-bearing shape of the pair: a device that is configured but not talking // must show up in the denominator and NOT in the numerator, because connected < total // is the whole alert condition. A gauge that only counted connected rows would read // identically for "one device down" and "device deleted". var name = UniqueName(); var connectedBefore = ReadGauge(ConnectedGauge); var totalBefore = ReadGauge(TotalGauge); try { ScadaBridgeTelemetry.RecordDataConnectionState(name, state); Assert.Equal(connectedBefore, ReadGauge(ConnectedGauge)); Assert.Equal(totalBefore + 1, ReadGauge(TotalGauge)); } finally { ScadaBridgeTelemetry.RemoveDataConnection(name); } } /// Verifies that a transition from connected to disconnected drops the connected gauge but keeps the total. [Fact] public void TransitionFromConnectedToDisconnected_DropsConnectedButKeepsTotal() { // The DCL calls Record* once per state transition on the SAME name, so a transition // must overwrite rather than accumulate — otherwise a flapping connection would // inflate the total without bound. var name = UniqueName(); var connectedBefore = ReadGauge(ConnectedGauge); var totalBefore = ReadGauge(TotalGauge); try { ScadaBridgeTelemetry.RecordDataConnectionState(name, ConnectionHealth.Connected); ScadaBridgeTelemetry.RecordDataConnectionState(name, ConnectionHealth.Disconnected); Assert.Equal(connectedBefore, ReadGauge(ConnectedGauge)); Assert.Equal(totalBefore + 1, ReadGauge(TotalGauge)); } finally { ScadaBridgeTelemetry.RemoveDataConnection(name); } } /// Verifies that removing a data connection drops it from both gauges. [Fact] public void RemoveDataConnection_DropsItFromBothGauges() { // An undeployed connection must leave the denominator. If it lingered as a // permanently not-connected row, connected < total would latch on forever and the // alert would be useless after the first undeploy. var name = UniqueName(); var connectedBefore = ReadGauge(ConnectedGauge); var totalBefore = ReadGauge(TotalGauge); ScadaBridgeTelemetry.RecordDataConnectionState(name, ConnectionHealth.Connected); ScadaBridgeTelemetry.RemoveDataConnection(name); Assert.Equal(connectedBefore, ReadGauge(ConnectedGauge)); Assert.Equal(totalBefore, ReadGauge(TotalGauge)); } /// Verifies that removing a data connection is idempotent. [Fact] public void RemoveDataConnection_IsIdempotent() { var name = UniqueName(); ScadaBridgeTelemetry.RecordDataConnectionState(name, ConnectionHealth.Connected); ScadaBridgeTelemetry.RemoveDataConnection(name); var ex = Record.Exception(() => ScadaBridgeTelemetry.RemoveDataConnection(name)); Assert.Null(ex); } /// Verifies that a null or empty connection name is ignored. [Fact] public void NullOrEmptyConnectionName_IsIgnored() { // Guards the gauges against a malformed caller creating a phantom row that could // never be removed by name. var totalBefore = ReadGauge(TotalGauge); ScadaBridgeTelemetry.RecordDataConnectionState(string.Empty, ConnectionHealth.Connected); ScadaBridgeTelemetry.RecordDataConnectionState(null!, ConnectionHealth.Connected); Assert.Equal(totalBefore, ReadGauge(TotalGauge)); } /// Verifies that the stream subscriber gauge is published under its renamed name. [Fact] public void StreamSubscriberGauge_IsPublishedUnderTheRenamedName() { // Register row 54's rename half. Pins the honest name so a future edit cannot // silently restore scadabridge.site.connection.up, whose meaning (open gRPC // subscriber streams) never matched what operators read it as. var names = PublishedInstrumentNames(); Assert.Contains("scadabridge.site.stream.subscribers.active", names); Assert.DoesNotContain("scadabridge.site.connection.up", names); } private static string UniqueName() => $"conn-{Guid.NewGuid():N}"; /// Forces one observation of the named gauge and returns its value. /// Instrument name to observe. /// The observed value, or 0 if the instrument never reported. private static long ReadGauge(string instrumentName) { long observed = 0; using var listener = new MeterListener(); listener.InstrumentPublished = (instrument, l) => { if (instrument.Meter.Name == ScadaBridgeTelemetry.MeterName && instrument.Name == instrumentName) { l.EnableMeasurementEvents(instrument); } }; listener.SetMeasurementEventCallback((_, measurement, _, _) => observed = measurement); listener.Start(); listener.RecordObservableInstruments(); return observed; } /// Collects every instrument name published on the ScadaBridge meter. /// The published instrument names. private static IReadOnlyCollection PublishedInstrumentNames() { var names = new List(); using var listener = new MeterListener(); listener.InstrumentPublished = (instrument, _) => { if (instrument.Meter.Name == ScadaBridgeTelemetry.MeterName) { names.Add(instrument.Name); } }; listener.Start(); return names; } }