using ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs;
namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard;
///
/// Verifies , the seam
/// reads before building a snapshot.
/// An over-count leaves the publisher ticking for nobody; an under-count is
/// worse — it idle-gates a dashboard that is actually open, so the page silently
/// stops updating. The counter is exercised directly rather than through the hub,
/// mirroring the precedent: a SignalR
/// Hub instance needs a caller-clients and connection context fake to
/// invoke OnConnectedAsync, and the hub methods themselves are two lines
/// of delegation to this type.
///
public sealed class DashboardSnapshotHubConnectionCounterTests
{
/// A fresh counter reports no viewers, so the publisher starts idle.
[Fact]
public void Count_WhenNothingConnected_IsZero()
{
DashboardSnapshotHubConnectionCounter counter = new();
Assert.Equal(0, counter.Count);
}
/// Connect/disconnect pairs move the count and return the post-operation value.
[Fact]
public void IncrementThenDecrement_TracksLiveConnections()
{
DashboardSnapshotHubConnectionCounter counter = new();
Assert.Equal(1, counter.Increment());
Assert.Equal(2, counter.Increment());
Assert.Equal(2, counter.Count);
Assert.Equal(1, counter.Decrement());
Assert.Equal(0, counter.Decrement());
Assert.Equal(0, counter.Count);
}
///
/// SignalR calls OnDisconnectedAsync for a connection whose
/// OnConnectedAsync faulted, so unmatched decrements happen. They must
/// hold the floor at zero rather than driving the count negative.
///
[Fact]
public void Decrement_WithoutMatchingIncrement_HoldsAtZero()
{
DashboardSnapshotHubConnectionCounter counter = new();
Assert.Equal(0, counter.Decrement());
Assert.Equal(0, counter.Decrement());
Assert.Equal(0, counter.Count);
// A genuine connection after unmatched disconnects still registers as one.
Assert.Equal(1, counter.Increment());
}
///
/// Many concurrent unmatched decrements must not leave the count below zero:
/// a negative floor would swallow the next real connection's increment and
/// keep the publisher idle-gated while a viewer waits. The clamp lives inside
/// the compare-and-swap, so the floor holds however the calls interleave.
///
[Fact]
public void Decrement_UnderConcurrencyFromZero_NeverGoesNegative()
{
DashboardSnapshotHubConnectionCounter counter = new();
Parallel.For(0, 256, _ => counter.Decrement());
Assert.Equal(0, counter.Count);
Assert.Equal(1, counter.Increment());
Assert.Equal(1, counter.Count);
}
///
/// Stress check on the invariant the idle gate depends on: real connects
/// interleaved with unmatched disconnects leave the count in [0, connects], and a
/// connect after the storm is always visible to the publisher. The failure this
/// guards is the decrement-then-repair race the CAS retry loop replaced — an early
/// decrementer's stale repair either erases a live connection's increment or leaves
/// a negative value behind, and either way an open dashboard freezes behind the
/// gate.
///
///
/// This does not deterministically reproduce that race, and it is not claimed to:
/// the bad interleaving needs a specific few-instruction overlap that cannot be
/// forced through the public API, and a merely low count is a legitimate outcome
/// here (a decrement that runs while the count is positive consumes a real
/// connection). Verified by experiment: the previous implementation passes this
/// test. What is asserted are the observable consequences — never negative, the
/// floor holds, a later connect still registers — with the correctness argument
/// resting on the clamped CAS retry loop itself.
///
[Fact]
public void IncrementAndDecrement_InterleavedUnderConcurrency_StayWithinTheRealConnectionCount()
{
const int LiveConnections = 8;
const int UnmatchedDisconnects = 128;
for (int round = 0; round < 50; round++)
{
DashboardSnapshotHubConnectionCounter counter = new();
// A few workers are real connects that must survive; the rest are
// unmatched disconnects hammering the zero floor around them.
Parallel.For(0, LiveConnections + UnmatchedDisconnects, index =>
{
if (index % 16 == 0 && index / 16 < LiveConnections)
{
counter.Increment();
return;
}
counter.Decrement();
});
Assert.InRange(counter.Count, 0, LiveConnections);
// Every unmatched decrement has completed, so the surviving connections
// disconnect cleanly and the counter must land exactly on zero — never
// below it, and a subsequent connect must be visible to the publisher.
int remaining = counter.Count;
for (int i = 0; i < remaining; i++)
{
counter.Decrement();
}
Assert.Equal(0, counter.Count);
Assert.Equal(1, counter.Increment());
}
}
/// Matched connect/disconnect pairs under concurrency settle back at zero.
[Fact]
public void IncrementAndDecrement_MatchedPairsUnderConcurrency_SettleAtZero()
{
DashboardSnapshotHubConnectionCounter counter = new();
Parallel.For(0, 64, _ =>
{
for (int pass = 0; pass < 50; pass++)
{
counter.Increment();
counter.Decrement();
}
});
Assert.Equal(0, counter.Count);
}
}