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); } }