packages feed

reflex-vty-1.0.0.0: test/Reflex/Vty/HostSpec.hs

{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE RankNTypes #-}

-- |
-- Description: Host event-channel regression + backpressure tests.
module Reflex.Vty.HostSpec (spec) where

import Control.Concurrent (forkIO, threadDelay)
import Control.Monad (forever, replicateM_, void)
import Control.Monad.IO.Class (liftIO)
import Data.IORef (IORef, modifyIORef', newIORef, readIORef)
import qualified Graphics.Vty as V
import Graphics.Vty.Attributes.Color (ColorMode (..))
import Reflex
import System.Timeout (timeout)
import Test.Hspec

import Reflex.Vty.Host
  ( VtyApp
  , VtyResult (..)
  , defaultVtyAppConfig
  , runVtyAppWithHandle
  )

----------------------------------------------------------------------------
-- Constants
----------------------------------------------------------------------------

-- | Burst size for the regression test: large enough that the old
-- one-batch-per-frame host loop would not reach the shutdown request within
-- 'shutdownTimeout' (it would need one redraw per item).
burstN :: Int
burstN = 10000

-- | Burst size for the occurrence-preservation test. Deliberately far larger
-- than the default queue capacity (4096) so that backpressure provably engages
-- and the producer is throttled rather than able to dump everything at once.
overloadN :: Int
overloadN = 50000

-- | Time the host is allowed to take. The fixed host drains the whole burst in
-- a single frame, so this is ample; the old host (one batch per frame) would
-- need @burstN * redrawDelay@ microseconds and time out.
shutdownTimeout :: Int
shutdownTimeout = 6 * 1000000

-- | Generous timeout for the occurrence-preservation test: under backpressure
-- the producer is throttled to the host's processing rate, so @overloadN@
-- ticks take a little while to drain.
overloadTimeout :: Int
overloadTimeout = 30 * 1000000

-- | Per-redraw delay modelled by the mock vty. Simulates the cost of a real
-- 'V.update'.
redrawDelay :: Int
redrawDelay = 2000

spec :: Spec
spec = describe "Reflex.Vty.Host" $ do
  describe "runVtyAppWithHandle" $ do
    it "drains a burst of external triggers each frame and does not leak (regression)" $ do
      vty <- mockVty (\_ -> threadDelay redrawDelay)
      result <- timeout shutdownTimeout $ runVtyAppWithHandle defaultVtyAppConfig vty guest
      -- The host must shut down within the timeout. With the old loop it would
      -- stall behind @burstN@ backlogged batches (one redraw each) and time out.
      result `shouldBe` Just ()

    it "preserves every occurrence of an overloaded external trigger (backpressure)" $ do
      -- A producer firing faster than the host can process must be backpressured
      -- (no dropped occurrences) rather than allowed to exhaust memory.
      counter <- newIORef (0 :: Int)
      vty <- mockVty (\_ -> threadDelay redrawDelay)
      result <- timeout overloadTimeout $ runVtyAppWithHandle defaultVtyAppConfig vty (countingGuest counter)
      result `shouldBe` Just ()
      final <- readIORef counter
      -- Every one of the @overloadN@ ticks must be counted: backpressure throttles
      -- the producer but never drops an occurrence.
      final `shouldBe` overloadN

----------------------------------------------------------------------------
-- Guests
----------------------------------------------------------------------------

-- | Fires a burst of events into the FRP network (exactly the pattern that
-- backed up the host's event channel) and then requests shutdown. Both the
-- ticks and the shutdown request travel through the same 'TriggerEvent'
-- channel, so the only way the host reaches shutdown promptly is by draining
-- the whole channel each frame.
guest :: VtyApp t m
guest _region _input _sigs = do
  setupE <- getPostBuild
  (tickE, fireTick) <- newTriggerEvent
  (shutdownE, fireShutdown) <- newTriggerEvent
  -- Subscribe to the ticks so each occurrence does real work in the network.
  _d <- holdDyn (0 :: Int) tickE
  performEvent_ $
    ffor setupE $ \() ->
      liftIO $
        void $
          forkIO $ do
            replicateM_ burstN (fireTick (0 :: Int))
            fireShutdown ()
  return $
    VtyResult
      { _vtyResult_picture = pure (V.picForImage V.emptyImage)
      , _vtyResult_shutdown = shutdownE
      }

-- | Like 'guest' but increments an 'IORef' on every tick occurrence, so the
-- test can assert that no occurrences were dropped under backpressure.
countingGuest :: IORef Int -> (forall t m. VtyApp t m)
countingGuest counter _region _input _sigs = do
  setupE <- getPostBuild
  (tickE, fireTick) <- newTriggerEvent
  (shutdownE, fireShutdown) <- newTriggerEvent
  performEvent_ $ ffor tickE $ \_ -> liftIO $ modifyIORef' counter (+ 1)
  performEvent_ $
    ffor setupE $ \() ->
      liftIO $
        void $
          forkIO $ do
            replicateM_ overloadN (fireTick (0 :: Int))
            fireShutdown ()
  return $
    VtyResult
      { _vtyResult_picture = pure (V.picForImage V.emptyImage)
      , _vtyResult_shutdown = shutdownE
      }

----------------------------------------------------------------------------
-- Mock vty (no real terminal)
----------------------------------------------------------------------------

-- | A 'V.Vty' whose 'update' action runs the given @IO@ (to model redraw
-- cost) and whose 'nextEvent' blocks forever; the network is driven entirely
-- via 'TriggerEvent'. Only the fields the host actually touches are real;
-- the rest are never forced (the mock's @update@ never dereferences the
-- unused 'V.Output' fields).
mockVty :: (V.Picture -> IO ()) -> IO V.Vty
mockVty updateAction = do
  out <- mockOutput
  pure
    V.Vty
      { V.update = updateAction
      , V.nextEvent = forever (threadDelay 1000000)
      , V.nextEventNonblocking = pure Nothing
      , V.inputIface = mockInput
      , V.outputIface = out
      , V.refresh = pure ()
      , V.shutdown = pure ()
      , V.isShutdown = pure False
      }

mockInput :: V.Input
mockInput =
  V.Input
    { V.eventChannel = error "mockInput: eventChannel unused"
    , V.shutdownInput = pure ()
    , V.restoreInputState = pure ()
    , V.inputLogMsg = \_ -> pure ()
    }

mockOutput :: IO V.Output
mockOutput =
  pure
    V.Output
      { V.terminalID = "mock"
      , V.releaseTerminal = pure ()
      , V.reserveDisplay = pure ()
      , V.releaseDisplay = pure ()
      , V.setDisplayBounds = \_ -> pure ()
      , V.displayBounds = pure (80, 24)
      , V.outputByteBuffer = \_ -> pure ()
      , V.supportsCursorVisibility = True
      , V.supportsMode = const False
      , V.setMode = \_ _ -> pure ()
      , V.getModeStatus = \_ -> pure False
      , V.assumedStateRef = error "mockOutput: assumedStateRef unused"
      , V.mkDisplayContext = \_ _ -> error "mockOutput: mkDisplayContext unused"
      , V.ringTerminalBell = pure ()
      , V.supportsBell = pure True
      , V.supportsItalics = pure True
      , V.supportsStrikethrough = pure True
      , V.outputColorMode = FullColor
      , V.setOutputWindowTitle = \_ -> pure ()
      }