test(commons): realign enum-count guards (AuditKind/AuditChannel/DataType) + derace StaleTagMonitor timer tests (#228, pre-existing)

This commit is contained in:
Joseph Doherty
2026-06-19 00:33:39 -04:00
parent 7aeb7a43b0
commit ddafc5c811
5 changed files with 66 additions and 16 deletions
@@ -5,7 +5,7 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types;
public class EnumTests
{
[Theory]
[InlineData(typeof(DataType), new[] { "Boolean", "Int32", "Float", "Double", "String", "DateTime", "Binary" })]
[InlineData(typeof(DataType), new[] { "Boolean", "Int32", "Float", "Double", "String", "DateTime", "Binary", "List" })]
[InlineData(typeof(InstanceState), new[] { "NotDeployed", "Enabled", "Disabled" })]
[InlineData(typeof(DeploymentStatus), new[] { "Pending", "InProgress", "Success", "Failed" })]
[InlineData(typeof(AlarmState), new[] { "Active", "Normal" })]
@@ -12,7 +12,7 @@ public class AuditEnumTests
[Fact]
public void AuditChannel_HasExactlyExpectedMembers()
{
var expected = new[] { "ApiOutbound", "DbOutbound", "Notification", "ApiInbound" };
var expected = new[] { "ApiOutbound", "DbOutbound", "Notification", "ApiInbound", "SecuredWrite" };
var actual = Enum.GetValues(typeof(AuditChannel))
.Cast<AuditChannel>()
.Select(x => x.ToString())
@@ -23,20 +23,21 @@ public class AuditEnumTests
}
[Fact]
public void AuditKind_HasExactlyTenExpectedMembers()
public void AuditKind_HasExactlyFourteenExpectedMembers()
{
var expected = new[]
{
"ApiCall", "ApiCallCached", "DbWrite", "DbWriteCached",
"NotifySend", "NotifyDeliver", "InboundRequest", "InboundAuthFailure",
"CachedSubmit", "CachedResolve",
"SecuredWriteSubmit", "SecuredWriteApprove", "SecuredWriteReject", "SecuredWriteExecute",
};
var actual = Enum.GetValues(typeof(AuditKind))
.Cast<AuditKind>()
.Select(x => x.ToString())
.ToArray();
Assert.Equal(10, actual.Length);
Assert.Equal(14, actual.Length);
Assert.Equal(expected, actual);
}
@@ -0,0 +1,10 @@
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types;
/// <summary>
/// Marks both StaleTagMonitor test classes as non-parallel with each other and with the
/// rest of the test suite. This prevents CPU-contention-induced timing flakiness in tests
/// that rely on wall-clock timer deadlines (MaxSilence values in the 30200ms range).
/// xUnit runs all tests in the same [Collection] sequentially.
/// </summary>
[CollectionDefinition("StaleTagMonitor", DisableParallelization = true)]
public class StaleTagMonitorCollection { }
@@ -5,6 +5,8 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types;
/// <summary>
/// Regression tests for Commons-001: the check-then-act race between the timer
/// callback (<c>OnTimerElapsed</c>) and <c>OnValueReceived</c> / <c>Stop</c> / <c>Start</c>.
/// Placed in the same non-parallel collection as <see cref="StaleTagMonitorTests"/> to
/// avoid CPU contention from other timer-based tests running concurrently.
///
/// The original implementation guarded firing with a single <c>volatile bool</c> that
/// was both read by the callback and reset by the caller threads. Because the
@@ -16,6 +18,7 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types;
/// These tests use the internal <c>CallbackEnteredHook</c> seam to deterministically
/// interleave a caller-thread operation with an in-flight callback.
/// </summary>
[Collection("StaleTagMonitor")]
public class StaleTagMonitorRaceTests
{
/// <summary>
@@ -2,8 +2,33 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types;
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types;
/// <summary>
/// Both StaleTagMonitor test classes are placed in the same non-parallel collection
/// to prevent wall-clock contention when the full suite runs under load.
/// Within each test, fixed-delay assertions are replaced with poll-until-condition
/// loops that tolerate CPU jitter on a busy machine.
/// </summary>
[Collection("StaleTagMonitor")]
public class StaleTagMonitorTests
{
// Generous poll timeout: tests use MaxSilence values of 50200ms; we allow up to
// 5 seconds of total wait before declaring failure. This absorbs any scheduler lag
// that would previously cause a tight Task.Delay window to expire before the timer
// had a chance to fire.
private static readonly TimeSpan PollTimeout = TimeSpan.FromSeconds(5);
private static readonly TimeSpan PollInterval = TimeSpan.FromMilliseconds(10);
private static async Task<bool> PollUntilAsync(Func<bool> condition, TimeSpan timeout)
{
var deadline = DateTime.UtcNow + timeout;
while (DateTime.UtcNow < deadline)
{
if (condition()) return true;
await Task.Delay(PollInterval);
}
return condition(); // final check
}
[Fact]
public void Constructor_ZeroTimeSpan_Throws()
{
@@ -24,7 +49,8 @@ public class StaleTagMonitorTests
monitor.Stale += () => Interlocked.Increment(ref staleCount);
monitor.Start();
await Task.Delay(300);
var fired = await PollUntilAsync(() => staleCount >= 1, PollTimeout);
Assert.True(fired, "Stale event did not fire within the generous timeout.");
Assert.Equal(1, staleCount);
}
@@ -36,7 +62,13 @@ public class StaleTagMonitorTests
monitor.Stale += () => Interlocked.Increment(ref staleCount);
monitor.Start();
await Task.Delay(300);
// Wait for the first (and only) fire, then hold for a bit to confirm no second fire.
var fired = await PollUntilAsync(() => staleCount >= 1, PollTimeout);
Assert.True(fired, "Stale event did not fire within the generous timeout.");
// Hold long enough to confirm no second fire — the one-shot timer design means
// a second fire can only happen if OnValueReceived is called; we do not call it.
await Task.Delay(200);
Assert.Equal(1, staleCount);
}
@@ -48,14 +80,16 @@ public class StaleTagMonitorTests
monitor.Stale += () => Interlocked.Increment(ref staleCount);
monitor.Start();
// Keep resetting before the 200ms deadline
// Keep resetting before the 200ms deadline — use the monitor's own MaxSilence
// divided by 2 as the reset cadence so timing margin is relative, not absolute.
var resetInterval = (int)(monitor.MaxSilence.TotalMilliseconds / 2);
for (int i = 0; i < 5; i++)
{
await Task.Delay(100);
await Task.Delay(resetInterval);
monitor.OnValueReceived();
}
// Should not have gone stale
// Should not have gone stale during the reset loop.
Assert.Equal(0, staleCount);
}
@@ -67,12 +101,12 @@ public class StaleTagMonitorTests
monitor.Stale += () => Interlocked.Increment(ref staleCount);
monitor.Start();
// Reset once
// Reset once before stale fires, then go silent and poll for the eventual fire.
await Task.Delay(50);
monitor.OnValueReceived();
// Then go silent
await Task.Delay(250);
var fired = await PollUntilAsync(() => staleCount >= 1, PollTimeout);
Assert.True(fired, "Stale event did not fire after silence following a reset.");
Assert.Equal(1, staleCount);
}
@@ -84,13 +118,15 @@ public class StaleTagMonitorTests
monitor.Stale += () => Interlocked.Increment(ref staleCount);
monitor.Start();
// Wait for first stale
await Task.Delay(250);
// Wait for first stale via poll.
var firstFired = await PollUntilAsync(() => staleCount >= 1, PollTimeout);
Assert.True(firstFired, "First Stale event did not fire.");
Assert.Equal(1, staleCount);
// Reset — should allow second stale fire
// Reset — should allow second stale fire.
monitor.OnValueReceived();
await Task.Delay(250);
var secondFired = await PollUntilAsync(() => staleCount >= 2, PollTimeout);
Assert.True(secondFired, "Second Stale event did not fire after reset.");
Assert.Equal(2, staleCount);
}