using ZB.MOM.WW.ScadaBridge.CentralUI.Services; namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Services; /// /// Unit tests for — the primitive behind the process-level /// KPI snapshot cache and the shared Alarm Summary fan-out (arch-review WP2.4). The two /// properties the whole optimisation rests on are tested here: single-flight (N /// concurrent circuits asking the same question produce ONE query) and TTL (a query /// answered inside the freshness window is served from memory, and one outside it is not). /// public class SingleFlightMemoTests { private static readonly DateTimeOffset T0 = new(2026, 8, 14, 9, 0, 0, TimeSpan.Zero); [Fact] public async Task ConcurrentCallers_ShareOneFlight() { var released = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var invocations = 0; var memo = new SingleFlightMemo(TimeSpan.FromSeconds(10), () => T0); // Ten circuits ask while the first flight is still in progress. var waiters = Enumerable.Range(0, 10) .Select(_ => memo.GetAsync(() => { Interlocked.Increment(ref invocations); return released.Task; })) .ToArray(); released.SetResult(42); var results = await Task.WhenAll(waiters); Assert.Equal(1, invocations); Assert.Equal(1, memo.FlightCount); Assert.All(results, r => Assert.Equal(42, r)); } [Fact] public async Task WithinTtl_ServesTheMemoizedValue_WithoutRequerying() { var now = T0; var invocations = 0; var memo = new SingleFlightMemo(TimeSpan.FromSeconds(8), () => now); var first = await memo.GetAsync(() => Task.FromResult(++invocations)); now = T0.AddSeconds(7); var second = await memo.GetAsync(() => Task.FromResult(++invocations)); Assert.Equal(1, first); Assert.Equal(1, second); Assert.Equal(1, memo.FlightCount); } [Fact] public async Task AfterTtl_RequeriesOnce() { var now = T0; var invocations = 0; var memo = new SingleFlightMemo(TimeSpan.FromSeconds(8), () => now); await memo.GetAsync(() => Task.FromResult(++invocations)); now = T0.AddSeconds(9); var refreshed = await memo.GetAsync(() => Task.FromResult(++invocations)); Assert.Equal(2, refreshed); Assert.Equal(2, memo.FlightCount); } [Fact] public async Task ForceRefresh_BypassesAFreshValue() { var now = T0; var invocations = 0; var memo = new SingleFlightMemo(TimeSpan.FromSeconds(60), () => now); await memo.GetAsync(() => Task.FromResult(++invocations)); var forced = await memo.GetAsync(() => Task.FromResult(++invocations), forceRefresh: true); Assert.Equal(2, forced); Assert.Equal(2, memo.FlightCount); } [Fact] public async Task ForceRefresh_StillJoinsAnInFlightRound() { var released = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var invocations = 0; var memo = new SingleFlightMemo(TimeSpan.FromSeconds(60), () => T0); var first = memo.GetAsync(() => { Interlocked.Increment(ref invocations); return released.Task; }); // A burst of operator "Refresh" clicks must not multiply the query. var forcedA = memo.GetAsync(() => Task.FromResult(-1), forceRefresh: true); var forcedB = memo.GetAsync(() => Task.FromResult(-1), forceRefresh: true); released.SetResult(7); Assert.Equal(7, await first); Assert.Equal(7, await forcedA); Assert.Equal(7, await forcedB); Assert.Equal(1, invocations); } [Fact] public async Task Failure_IsNotMemoized_AndTheNextCallerRetries() { var now = T0; var invocations = 0; var memo = new SingleFlightMemo(TimeSpan.FromSeconds(60), () => now); await Assert.ThrowsAsync(() => memo.GetAsync(() => { invocations++; return Task.FromException(new InvalidOperationException("KPI query failed")); })); // Same instant, well inside the TTL — a cached fault would starve the tiles for a // whole window, so the next caller must produce a new flight. var recovered = await memo.GetAsync(() => Task.FromResult(++invocations)); Assert.Equal(2, recovered); Assert.Equal(2, memo.FlightCount); } [Fact] public async Task TtlOverride_AppliesToThatFlight() { var now = T0; var invocations = 0; var memo = new SingleFlightMemo(TimeSpan.FromSeconds(5), () => now); // Produced under a 60s override — still fresh 30s later despite the 5s default. await memo.GetAsync(() => Task.FromResult(++invocations), ttlOverride: TimeSpan.FromSeconds(60)); now = T0.AddSeconds(30); var second = await memo.GetAsync(() => Task.FromResult(++invocations)); Assert.Equal(1, second); Assert.Equal(1, memo.FlightCount); } [Fact] public async Task CallerCancellation_DoesNotCancelTheSharedFlight() { var released = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var memo = new SingleFlightMemo(TimeSpan.FromSeconds(60), () => T0); using var giveUp = new CancellationTokenSource(); var impatient = memo.GetAsync(() => released.Task, cancellationToken: giveUp.Token); var patient = memo.GetAsync(() => Task.FromResult(-1)); giveUp.Cancel(); await Assert.ThrowsAnyAsync(() => impatient); // The abandoned wait must not have taken the round down with it. released.SetResult(99); Assert.Equal(99, await patient); } }