atelier-core-0.1.0.0: test/Unit/Atelier/Effects/DebounceSpec.hs
module Unit.Atelier.Effects.DebounceSpec (spec_Debounce) where
import Data.Dynamic (fromDynamic, toDyn)
import Effectful (runEff)
import Effectful.Concurrent (runConcurrent)
import Test.Hspec
import Data.IORef qualified as IORef
import Effectful.Concurrent.STM qualified as STM
import StmContainers.Map qualified as Map
import Atelier.Effects.Conc (runConc)
import Atelier.Effects.Debounce
( Entry (..)
, debounced
, debouncedWith
, ensureCallback
, ensureEntry
, runDebounce
)
import Atelier.Effects.Delay (runDelay)
import Atelier.Time (Millisecond)
import Atelier.Effects.Conc qualified as Conc
import Atelier.Effects.Console qualified as Console
import Atelier.Effects.Delay qualified as Delay
spec_Debounce :: Spec
spec_Debounce = do
describe "runDebounce" testRunDebounce
describe "ensureEntry" testEnsureEntry
describe "ensureCallback" testEnsureCallback
testEnsureEntry :: Spec
testEnsureEntry = do
describe "new key" do
it "gets generation 0" do
state <- runSTM Map.new
entry <- runSTM $ mkEntry 1 42 state (+)
entry.generation `shouldBe` 0
it "stores value as arg" do
state <- runSTM Map.new
entry <- runSTM $ mkEntry 1 42 state (+)
(entry.arg >>= fromDynamic) `shouldBe` Just @Int 42
describe "existing key" do
it "increments generation" do
state <- runSTM Map.new
_ <- runSTM $ mkEntry 1 2 state (+)
entry <- runSTM $ mkEntry 1 3 state (+)
entry.generation `shouldBe` 1
it "applies merge function to old and new value" do
state <- runSTM Map.new
_ <- runSTM $ mkEntry 1 5 state (+)
entry <- runSTM $ mkEntry 1 10 state (+)
(entry.arg >>= fromDynamic) `shouldBe` Just @Int 15
describe "multiple updates" do
it "accumulates generation" do
state <- runSTM Map.new
_ <- runSTM $ mkEntry 1 1 state (+)
_ <- runSTM $ mkEntry 1 2 state (+)
entry <- runSTM $ mkEntry 1 3 state (+)
entry.generation `shouldBe` 2
it "applies merge cumulatively" do
state <- runSTM Map.new
_ <- runSTM $ mkEntry 1 1 state (+)
_ <- runSTM $ mkEntry 1 2 state (+)
entry <- runSTM $ mkEntry 1 3 state (+)
(entry.arg >>= fromDynamic) `shouldBe` Just @Int 6
describe "independent keys" do
it "do not interfere with each other" do
state <- runSTM Map.new
entryA <- runSTM $ mkEntry 1 10 state (+)
entryB <- runSTM $ mkEntry 2 20 state (+)
(entryA.arg >>= fromDynamic) `shouldBe` Just @Int 10
(entryB.arg >>= fromDynamic) `shouldBe` Just @Int 20
entryA.generation `shouldBe` 0
entryB.generation `shouldBe` 0
where
mkEntry = ensureEntry @Int @Int
runSTM action = runEff . runConcurrent $ STM.atomically action
testEnsureCallback :: Spec
testEnsureCallback = do
it "fires callback when entry's generation still matches" do
actual <- runCallbackTest do
state <- STM.atomically Map.new
STM.atomically $ Map.insert (entry 0) (1 :: Int) state
ref <- liftIO $ IORef.newIORef @Int 0
t <-
Conc.fork
$ ensureCallback @Int @Int 1 state 1 0
$ liftIO . IORef.writeIORef ref
Conc.await t
liftIO $ IORef.readIORef ref
actual `shouldBe` 10
it "skips callback when generation has been bumped" do
runCallbackTest do
state <- STM.atomically Map.new
-- A newer event has already bumped generation to 1
STM.atomically $ Map.insert (entry 1) (1 :: Int) state
-- This fork was scheduled for generation 0; it should skip
ensureCallback @Int @Int 1 state 1 0 \_ ->
liftIO $ False `shouldBe` True
it "skips callback when entry has been removed" do
runCallbackTest do
state <- STM.atomically Map.new
ensureCallback @Int @Int 1 state 1 0 \_ ->
liftIO $ False `shouldBe` True
where
runCallbackTest =
runEff
. runConcurrent
. Console.runConsole
. Delay.runDelay
. runConc
entry generation =
Entry
{ generation
, arg = Just $ toDyn @Int 10
}
-- These specs exercise real-timer debouncing across forked threads. Rather than
-- sleeping a fixed duration and hoping the settle fired (which races scheduling
-- under load), each callback signals a 'TMVar' and the test blocks until it
-- fires. Multi-burst tests sequence by waiting for each burst's fire, so bursts
-- cannot merge regardless of timing. A short grace after the fire guards against
-- a spurious second fire.
testRunDebounce :: Spec
testRunDebounce = do
describe "debounced" do
describe "when invoked once" $ it "fires once" do
n <- runTest do
(count, fired) <- newCounter
debounced 50 () (bump count fired)
awaitFire fired
Delay.wait @Millisecond 80
readCount count
n `shouldBe` 1
describe "when invoked multiple times" $ it "fires once" do
n <- runTest do
(count, fired) <- newCounter
debounced 50 () (bump count fired)
debounced 50 () (bump count fired)
debounced 50 () (bump count fired)
awaitFire fired
Delay.wait @Millisecond 80
readCount count
n `shouldBe` 1
describe "when invoked multiple times with a delay between" $ it "fires once per burst" do
n <- runTest do
(count, fired) <- newCounter
debounced 50 () (bump count fired)
debounced 50 () (bump count fired)
awaitFire fired -- burst 1 settled and cleared the entry
debounced 50 () (bump count fired)
debounced 50 () (bump count fired)
awaitFire fired -- burst 2 is therefore independent
readCount count
n `shouldBe` 2
describe "when invoked many times" $ it "should not crash" do
n <- runTest do
(count, fired) <- newCounter
replicateM_ 10_000 $ debounced 50 () (bump count fired)
awaitFire fired
Delay.wait @Millisecond 80
readCount count
n `shouldBe` 1
describe "debouncedWith" do
describe "when invoked once" $ it "fires callback with the provided arg" do
got <- runTest do
result <- STM.atomically STM.newEmptyTMVar
debouncedWith 50 (+) () (42 :: Int) (signal result)
awaitValue result
got `shouldBe` Just 42
describe "when invoked multiple times rapidly" do
it "fires once" do
n <- runTest do
(count, fired) <- newCounter
let cb _ = bump count fired
debouncedWith 50 (+) () (1 :: Int) cb
debouncedWith 50 (+) () (2 :: Int) cb
debouncedWith 50 (+) () (3 :: Int) cb
awaitFire fired
Delay.wait @Millisecond 80
readCount count
n `shouldBe` 1
it "fires callback with merged arg" do
got <- runTest do
result <- STM.atomically STM.newEmptyTMVar
let cb = signal result
debouncedWith 50 (+) () (1 :: Int) cb
debouncedWith 50 (+) () (2 :: Int) cb
debouncedWith 50 (+) () (3 :: Int) cb
awaitValue result
got `shouldBe` Just 6
describe "when invoked multiple times with a delay between" $ it "fires once per burst with merged arg" do
xs <- runTest do
fired <- STM.atomically STM.newEmptyTMVar
ref <- STM.atomically (STM.newTVar @[Int] [])
let cb v = STM.atomically (STM.modifyTVar' ref (<> [v]) >> void (STM.tryPutTMVar fired ()))
debouncedWith 50 (+) () (1 :: Int) cb
debouncedWith 50 (+) () (2 :: Int) cb
awaitFire fired -- burst 1 fired (1+2=3); entry cleared
debouncedWith 50 (+) () (10 :: Int) cb
debouncedWith 50 (+) () (20 :: Int) cb
awaitFire fired -- burst 2 fired (10+20=30)
STM.atomically (STM.readTVar ref)
xs `shouldBe` [3, 30]
where
runTest =
runEff
. runConcurrent
. runConc
. runDelay
. runDebounce
-- A fire counter paired with a TMVar signalling that a fire happened.
newCounter = do
count <- STM.atomically (STM.newTVar @Int 0)
fired <- STM.atomically STM.newEmptyTMVar
pure (count, fired)
bump count fired = STM.atomically do
STM.modifyTVar' count (+ 1)
void $ STM.tryPutTMVar fired ()
readCount count = STM.atomically (STM.readTVar count)
signal result v = STM.atomically (STM.putTMVar result v)
-- Block until a callback signals, bounded by a generous timeout so a real
-- regression fails loudly instead of hanging forever.
awaitFire fired =
awaitValue fired >>= \case
Just () -> pure ()
Nothing -> liftIO $ expectationFailure "debounce callback did not fire within 5s"
awaitValue tmv =
either (const Nothing) Just
<$> Conc.race (Delay.wait @Millisecond 5000) (STM.atomically (STM.takeTMVar tmv))