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.
This commit is contained in:
+71
-7
@@ -177,11 +177,73 @@ public class DataConnectionActorBatchTests : TestKit
|
||||
ExpectMsg<TagValueUpdate>(u => u.Quality == QualityCode.Bad, TimeSpan.FromSeconds(5));
|
||||
ExpectMsg<SubscribeTagsResponse>(TimeSpan.FromSeconds(5));
|
||||
|
||||
// This is a genuine real-time rate assertion (how many retry rounds a backoff
|
||||
// schedule produces inside a fixed wall-clock window), not a synchronization
|
||||
// primitive standing in for "wait until X happens" — proving the floor (>= 2
|
||||
// rounds) is unavoidably tied to actually waiting out the window. What the old
|
||||
// blind Thread.Sleep(2000) + single end-of-window sample could NOT catch is a
|
||||
// regression to full-rate (no-backoff) retrying: it would still land somewhere
|
||||
// in [2, 8] by luck if the window elapsed while under load, or it would blow
|
||||
// past 8 well before the 2s mark and nobody would know until the final sample.
|
||||
// AssertRoundsWithinWindow polls throughout and fails immediately the first time
|
||||
// the ceiling is breached, so a no-backoff regression is caught fast instead of
|
||||
// being masked by (or racing) the fixed window.
|
||||
var initialCalls = adapter.SubscribeBatches.Count;
|
||||
Thread.Sleep(2000);
|
||||
var rounds = adapter.SubscribeBatches.Count - initialCalls;
|
||||
AssertRoundsWithinWindow(TimeSpan.FromSeconds(2),
|
||||
() => adapter.SubscribeBatches.Count - initialCalls,
|
||||
minInclusive: 2, maxInclusive: 8,
|
||||
context: "dead-device probe backoff (floor 150ms, ceiling 600ms)");
|
||||
}
|
||||
|
||||
Assert.InRange(rounds, 2, 8);
|
||||
/// <summary>
|
||||
/// Row-40 house pattern adapted for a RATE assertion: proves a monotonically-increasing
|
||||
/// count stays within <c>[minInclusive, maxInclusive]</c> across the whole
|
||||
/// <paramref name="window"/>, rather than a single blind sleep-then-sample at the end.
|
||||
/// The upper bound is enforced continuously (fails fast the instant it is exceeded,
|
||||
/// well before the window elapses); the lower bound can only be confirmed once the
|
||||
/// full window has elapsed, since proving "at least N happened by wall-clock time T"
|
||||
/// requires actually waiting until T.
|
||||
/// </summary>
|
||||
private static void AssertRoundsWithinWindow(
|
||||
TimeSpan window, Func<int> currentRounds, int minInclusive, int maxInclusive, string context)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + window;
|
||||
while (true)
|
||||
{
|
||||
var rounds = currentRounds();
|
||||
Assert.True(rounds <= maxInclusive,
|
||||
$"{context}: {rounds} rounds already fired, exceeding the ceiling of {maxInclusive} " +
|
||||
"before the window even elapsed — backoff is not slowing retries down.");
|
||||
if (DateTime.UtcNow >= deadline)
|
||||
{
|
||||
Assert.True(rounds >= minInclusive,
|
||||
$"{context}: only {rounds} rounds fired in the window (need >= {minInclusive}).");
|
||||
return;
|
||||
}
|
||||
Thread.Sleep(20);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Row-40 house pattern for an ABSENCE assertion (mirrors the Medium-wave
|
||||
/// <c>SiteAlarmAggregatorActorTests.AssertHoldsFor</c> treatment): proves
|
||||
/// <paramref name="holds"/> keeps being true for the whole <paramref name="window"/> by
|
||||
/// polling, rather than a single blind <c>Thread.Sleep(window)</c> followed by one
|
||||
/// end-of-window sample. A bare sleep-then-assert cannot distinguish "the mechanism
|
||||
/// correctly stayed quiet" from "the mechanism just hadn't run yet when the one sample
|
||||
/// was taken", and it can't catch a value that flips wrong and then flips back before
|
||||
/// that single sample. This fails immediately the first time <paramref name="holds"/>
|
||||
/// goes false, and only returns once the full window has elapsed with it still true.
|
||||
/// </summary>
|
||||
private static void AssertHoldsFor(TimeSpan window, Func<bool> holds, string because)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + window;
|
||||
while (true)
|
||||
{
|
||||
Assert.True(holds(), because);
|
||||
if (DateTime.UtcNow >= deadline) return;
|
||||
Thread.Sleep(20);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -203,16 +265,18 @@ public class DataConnectionActorBatchTests : TestKit
|
||||
for (var i = 0; i < 20; i++)
|
||||
adapter.ValueCallback!("tag1", new TagValue(i, QualityCode.Good, DateTimeOffset.UtcNow));
|
||||
|
||||
Thread.Sleep(400);
|
||||
Assert.DoesNotContain(_health.ReceivedCalls(), c => c.GetMethodInfo().Name == "UpdateTagQuality");
|
||||
AssertHoldsFor(TimeSpan.FromMilliseconds(400),
|
||||
() => !_health.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "UpdateTagQuality"),
|
||||
"20 unchanged-quality repeats must never push UpdateTagQuality");
|
||||
|
||||
// One genuine transition → exactly one coalesced push.
|
||||
adapter.ValueCallback!("tag1", new TagValue(1, QualityCode.Bad, DateTimeOffset.UtcNow));
|
||||
AwaitCondition(
|
||||
() => _health.ReceivedCalls().Count(c => c.GetMethodInfo().Name == "UpdateTagQuality") == 1,
|
||||
TimeSpan.FromSeconds(3));
|
||||
Thread.Sleep(300);
|
||||
Assert.Single(_health.ReceivedCalls(), c => c.GetMethodInfo().Name == "UpdateTagQuality");
|
||||
AssertHoldsFor(TimeSpan.FromMilliseconds(300),
|
||||
() => _health.ReceivedCalls().Count(c => c.GetMethodInfo().Name == "UpdateTagQuality") == 1,
|
||||
"the coalesced push must not repeat");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
Reference in New Issue
Block a user