Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/ScadaBridgeTelemetryTests.cs
T
Joseph Doherty 428b8db024 test(dcl+communication+storeandforward+kpihistory+host): close register row-40-class sleeps + row-32 provider leak (arch-review 2026-08-19 P8-8 + P8-9)
P8-8: replace the remaining wall-clock sleep-then-assert instances outside
Communication.Tests' Medium-wave sweep with the same row-40 house pattern —
continuous polling (AssertHoldsFor) for ABSENCE assertions instead of a single
end-of-window sample, which cannot distinguish "stayed quiet throughout" from
"hadn't happened yet when the one sample landed" and cannot catch a value that
flips wrong and back before that sample.

- DataConnectionActorBatchTests: QualityCounters_ArePushedOnTransitionOnly_AndCoalesced
  (:206/:214 class) converted to AssertHoldsFor; ResolutionProbes_BackOff...
  (:181/:184, a genuine real-time backoff-RATE assertion, not a sync-primitive
  stand-in) converted to AssertRoundsWithinWindow, which enforces the ceiling
  continuously (fails fast on a no-backoff regression) while the floor still
  necessarily waits out the window.
- SiteAlarmLiveCacheServiceTests (:452-454, :470-472): converted to the
  project's own AssertHoldsFor (mirrors SiteAlarmAggregatorActorTests', same
  assembly).
- StoreAndForwardServiceTests (:891-892): async AssertHoldsForAsync variant
  (the class runs on the async test thread).
- KpiHistoryRecorderActorTests (:1043-1049): two skip-path FoldCount==0
  absence checks converted.

Each helper is a private per-file copy, matching the Medium-wave T17
precedent (SiteAlarmAggregatorActorTests.AssertHoldsFor) rather than a shared
TestSupport utility. Proof of the mechanism: a standalone (non-repo) harness
run 100 trials of a real background thread flipping a boolean true then back
to false inside a 300ms window — Thread.Sleep(300)+one sample missed the
violation 100/100 times (false pass), continuous 20ms polling caught it
100/100 times. Most of the actual conversions here guard monotonic counters
(their single-sample variant would eventually see the final value too), so
each converted test carries an inline honesty note on what specifically the
conversion buys for that assertion (fail-fast vs. true flip-back detection).

P8-9: ScadaBridgeTelemetryTests.EmitHelpers_DoNotThrow called
SetQueueDepthProvider(() => 5) against the process-global row-32 provider
slot with no clear and no [Collection] — a landmine for any future
same-process reader of scadabridge.store_and_forward.queue.depth. Fixed with
compare-and-clear in a finally via the existing (production, untouched)
ClearQueueDepthProvider. Searched this assembly and the whole test tree for a
second same-process reader that asserts on the value: none exists today
(MetricsEndpointTests scrapes /metrics but only asserts on the response
shape, and the only value-asserting test, QueueDepthGaugeTests, lives in
StoreAndForward.Tests — a separate test-host process the leak cannot reach),
so no [Collection] was added; the test's doc comment records the search and
flags it for re-check if that changes.

Test-only change, zero production edits. Full suites green: DCL.Tests
342/342, Communication.Tests 729/729, StoreAndForward.Tests 135/135,
KpiHistory.Tests 54/54, Host.Tests 522/522. Each converted test additionally
looped 20x in isolation with zero flakes.
2026-08-19 16:41:04 -04:00

75 lines
3.8 KiB
C#

using ZB.MOM.WW.ScadaBridge.Commons.Observability;
namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
/// <summary>
/// Infrastructure-free guards for <see cref="ScadaBridgeTelemetry"/>: the meter name is the
/// stable value registered with OTel, and the emit helpers are safe to call (they are no-op
/// until follow-on tasks wire real emit points and a listener attaches).
/// </summary>
public class ScadaBridgeTelemetryTests
{
[Fact]
public void MeterName_IsStableValue()
{
Assert.Equal("ZB.MOM.WW.ScadaBridge", ScadaBridgeTelemetry.MeterName);
}
/// <summary>
/// Register row 32 class: <c>SetQueueDepthProvider</c> writes the SAME process-global
/// slot <c>QueueDepthGaugeCollection</c> (StoreAndForward.Tests) exists to serialize
/// access to — this test was mutating it with no clear and no <c>[Collection]</c>,
/// leaving a stray <c>() =&gt; 5</c> provider registered in the process for the rest of
/// this assembly's run. Since the meter/instrument state is a single static field on
/// <c>ScadaBridgeTelemetry</c> (Commons), the leak is scoped to whatever process this
/// assembly's test host runs in — it cannot reach StoreAndForward.Tests (a separate
/// assembly with its own test-host process), but it IS visible within Host.Tests, whose
/// <c>MetricsEndpointTests</c> boots a real in-process host (<c>WebApplicationFactory</c>
/// shares this AppDomain, not a child process) and scrapes <c>/metrics</c>, which
/// enumerates every registered gauge including this one.
///
/// Honesty note on what was and wasn't proven: a search of this assembly (and the whole
/// test tree) found no test today that asserts on the VALUE of
/// <c>scadabridge.store_and_forward.queue.depth</c> from Host.Tests or any other
/// same-process reader — <c>MetricsEndpointTests</c> only asserts the endpoint returns
/// Prometheus-shaped text, not this series' content, and the only test that DOES assert
/// on the value (<c>QueueDepthGaugeTests</c>) lives in StoreAndForward.Tests, a distinct
/// test-host process the leak cannot reach. So there is no currently-reproducible flake
/// to demonstrate here (no fork of this test with the leak left in has an assertion to
/// break) — the defect is a landmine for a FUTURE same-process reader, not a live one.
/// The fix (compare-and-clear in <c>finally</c>) closes the landmine regardless; a
/// <c>[Collection]</c> is intentionally not added since no second same-process reader of
/// this slot exists to serialize against as of this writing — re-run this search before
/// removing the guard below if that changes.
/// </summary>
[Fact]
public void EmitHelpers_DoNotThrow()
{
Func<long> queueDepthProvider = () => 5;
var ex = Record.Exception(() =>
{
ScadaBridgeTelemetry.RecordDeploymentApplied();
ScadaBridgeTelemetry.RecordInboundApiRequest("X");
ScadaBridgeTelemetry.SiteStreamSubscriberOpened();
ScadaBridgeTelemetry.SiteStreamSubscriberClosed();
ScadaBridgeTelemetry.LiveAlarmAggregatorStarted();
ScadaBridgeTelemetry.LiveAlarmAggregatorStopped();
ScadaBridgeTelemetry.RecordLiveAlarmStreamReconnect();
ScadaBridgeTelemetry.SetQueueDepthProvider(queueDepthProvider);
});
try
{
Assert.Null(ex);
}
finally
{
// Compare-and-clear: only removes OUR delegate from the process-global slot, so
// a legitimate concurrent registrant (a real StoreAndForwardService started
// elsewhere in this process) is never stomped — mirrors
// ScadaBridgeTelemetry.ClearQueueDepthProvider's own doc'd contract.
ScadaBridgeTelemetry.ClearQueueDepthProvider(queueDepthProvider);
}
}
}