packages feed

fragr-0.1.0.0: test/Utils.hs

{-# LANGUAGE TypeFamilies #-}

-- | Test resource types and the mock backends the suites drive them through.
module Utils
  ( -- * Event-log resources
    Env (..)
  , newEnv
  , Event (..)
  , push
  , getEvents
  , ran
  , ranHere
  , Tex (..)
  , TexDesc (..)
  , tex
  , Buf (..)

    -- * Simulated device
  , Device (..)
  , newDevice
  , logQ
  , logHere
  , queueLog
  , Image (..)
  , ImgDesc (..)
  , Layout (..)
  , layoutName
  , img
  , runDevice
  , runHere

    -- * Driving a frame
  , mkBackend
  , runQueued
  , assertHandoffsDrained

    -- * Assertions
  , assertFatal
  , has
  , syncOf
  , tshow
  ) where

import Fragr

import Control.Exception (try)
import Control.Monad (unless, when)
import Control.Monad.IO.Class (liftIO)
import Data.Foldable (find, for_)
import Data.IORef (IORef, atomicModifyIORef', modifyIORef', newIORef, readIORef, writeIORef)
import Data.IntMap.Strict (IntMap)
import Data.IntMap.Strict qualified as IntMap
import Data.Map.Strict (Map)
import Data.Map.Strict qualified as Map
import Data.Maybe (fromJust)
import Data.Set (Set)
import Data.Set qualified as Set
import Data.Text (Text)
import Data.Text qualified as Text
import Data.Word (Word64)
import Test.Tasty.HUnit (assertBool, assertFailure)

import Fragr qualified as FG

-- * Test resource types

{- | Everything observable funnels into one event log; the allocator also
carries a creation counter (checklist: "observable via a creation
counter in the test resource type").
-}
data Env = Env
  { counter :: IORef Int
  , events :: IORef [Event]
  }

newEnv :: IO Env
newEnv = Env <$> newIORef 0 <*> newIORef []

data Event
  = ECreate Text Int
  | EDestroy Text
  | EPreRead Text Word64
  | EPreWrite Text Word64
  | EFlush Text
  | EPostFlush Text
  | ERun Text
  deriving stock (Eq, Show)

push :: Env -> Event -> IO ()
push env e = modifyIORef' env.events (e :)

getEvents :: Env -> IO [Event]
getEvents env = reverse <$> readIORef env.events

ran :: Env -> Text -> IO ()
ran env passName = push env (ERun passName)

-- | 'ran' from inside an execution callback.
ranHere :: Text -> FG.Exec Env Env ()
ranHere passName = do
  env <- FG.askCtx
  liftIO (ran env passName)

newtype Tex = Tex Int
  deriving stock (Eq, Show)

data TexDesc = TexDesc
  { tag :: Text
  , size :: Int
  }
  deriving stock (Eq, Show)

instance FG.Resource Tex where
  type Desc Tex = TexDesc
  type Alloc Tex = Env
  type Ctx Tex = Env
  type Flags Tex = Word64

  createResource desc env = do
    n <- atomicModifyIORef' env.counter \n -> (n + 1, n)
    push env (ECreate desc.tag n)
    pure (Tex n)

  destroyResource desc env _ = push env (EDestroy desc.tag)

  preRead _ desc w env _ = push env (EPreRead desc.tag w)

  preWrite _ desc w env _ = push env (EPreWrite desc.tag w)

  describeDesc desc = desc.tag

{- | A second resource type, for type-mismatch tests. No hooks: relies on
the default no-op 'preRead' / 'preWrite', empty 'describeDesc' and unit
'Flags'.
-}
data Buf = Buf
  deriving stock (Eq, Show)

instance FG.Resource Buf where
  type Desc Buf = Int
  type Alloc Buf = Env
  type Ctx Buf = Env
  createResource _ _ = pure Buf
  destroyResource _ _ _ = pure ()

tex :: Text -> TexDesc
tex t = TexDesc{tag = t, size = 4}

assertFatal :: IO a -> IO ()
assertFatal act =
  try @FragrError (act >> pure ()) >>= \case
    Left _ -> pure ()
    Right () -> assertFailure "expected a FragrError"

-- | Assert the rendered output contains the needle.
has :: Text -> Text -> IO ()
has out needle = assertBool (Text.unpack needle <> " in output") (needle `Text.isInfixOf` out)

-- * Simulated Vulkan-like backend (test-only, no GPU vocabulary in src/)

{- | A mock multi-queue device: per-queue command logs, per-queue timeline
counters, a set of signaled events, and the queue currently recording (so
the layout-tracking image hooks know where to log their barriers).
-}
data Device = Device
  { devLog :: IORef (IntMap [Text])
  -- ^ queue -> reversed command log
  , devTimeline :: IORef (IntMap Word64)
  -- ^ queue -> highest signaled (== reached, since we run synchronously)
  , devEvents :: IORef (Set Int)
  , devQueue :: IORef QueueId
  , devHandoffs :: IORef (Map (Text, Int) (Int, Word64))
  -- ^ (handle, dst queue) -> (src queue, its signal): released, not yet acquired
  , devWaited :: IORef (Map (Int, Int) Word64)
  -- ^ (queue, foreign queue) -> the highest value it has waited for so far
  }

newDevice :: IO Device
newDevice =
  Device
    <$> newIORef IntMap.empty
    <*> newIORef IntMap.empty
    <*> newIORef Set.empty
    <*> newIORef (QueueId 0)
    <*> newIORef Map.empty
    <*> newIORef Map.empty

logQ :: Device -> QueueId -> Text -> IO ()
logQ dev (QueueId q) msg = modifyIORef' dev.devLog (IntMap.insertWith (++) q [msg])

logHere :: Device -> Text -> IO ()
logHere dev msg = do
  q <- readIORef dev.devQueue
  logQ dev q msg

queueLog :: Device -> QueueId -> IO [Text]
queueLog dev (QueueId q) = reverse . IntMap.findWithDefault [] q <$> readIORef dev.devLog

{- | A simulated image whose layout state lives in an 'IORef'; the
'FG.preRead' / 'FG.preWrite' hooks diff requested-vs-current and record a
barrier command, exactly like a real backend would.
-}
data Image = Image (IORef Text) Bool

newtype ImgDesc = ImgDesc Text

-- | Image layout states: a real ADT reaching the hooks, no bit packing.
data Layout = ShaderRead | ColorAttachment | General
  deriving stock (Eq, Ord, Show)

layoutName :: Layout -> Text
layoutName = \case
  ShaderRead -> "shader-read"
  ColorAttachment -> "color-attachment"
  General -> "general"

instance FG.Resource Image where
  type Desc Image = ImgDesc
  type Alloc Image = Device
  type Ctx Image = Device
  type Flags Image = Layout

  createResource (ImgDesc n) dev = do
    logHere dev ("create " <> n)
    Image <$> newIORef "undefined" <*> pure False

  destroyResource (ImgDesc n) dev _ = logHere dev ("destroy " <> n)

  preRead _ d f dev im = barrier d f dev im
  preWrite _ d f dev im = barrier d f dev im

  isShared (Image _ sh) = sh

  preAcquire _ (ImgDesc n) f _peer dev _ = logHere dev ("acquire-hook " <> n <> " ->" <> layoutName f)
  preRelease _ (ImgDesc n) f _peer dev _ = logHere dev ("release-hook " <> n <> " ->" <> layoutName f)

barrier :: ImgDesc -> Layout -> Device -> Image -> IO ()
barrier (ImgDesc n) f dev (Image stateRef _) = do
  cur <- readIORef stateRef
  let target = layoutName f
  when (cur /= target) do
    logHere dev ("barrier " <> n <> " " <> cur <> "->" <> target)
    writeIORef stateRef target

img :: Text -> ImgDesc
img = ImgDesc

{- | A 'QueueBackend' that records the schedule onto the mock device.

Hand-offs are ground-truthed ('devHandoffs'): a release arms (handle, dst)
exactly once, the acquire consumes it exactly once from the right peer, and
only after a wait covering the releasing pass's signal — the invariants a
real QFOT pair needs to not deadlock or corrupt. 'assertHandoffsDrained'
closes the frame: every release found its acquire.
-}
mkBackend :: Device -> QueueBackend
mkBackend dev =
  QueueBackend
    { invoke = \_ body -> body
    , beforePass = \ps -> do
        writeIORef dev.devQueue ps.queue
        let QueueId our = ps.queue
        for_ ps.waits \w -> do
          let QueueId q = w.queue
          tl <- readIORef dev.devTimeline
          let reached = IntMap.findWithDefault 0 q tl
          when (reached < w.value) $
            assertFailure ("wait on q" <> show q <> " for " <> show w.value <> " but only " <> show reached <> " reached")
          modifyIORef' dev.devWaited (Map.insertWith max (our, q) w.value)
          logQ dev ps.queue ("wait q" <> tshow q <> ">=" <> tshow w.value)
        for_ ps.waitEvents \se -> do
          let EventId e = se.event
          evs <- readIORef dev.devEvents
          unless (Set.member e evs) $
            assertFailure ("waited on unsignaled event " <> show e)
          logQ dev ps.queue ("waitEvent " <> tshow e)
        waited <- readIORef dev.devWaited
        for_ ps.acquires \(Transfer h peer _flags) -> do
          let QueueId src = peer
          Map.lookup (tshow h, our) <$> readIORef dev.devHandoffs >>= \case
            Nothing ->
              assertFailure ("acquire of " <> show h <> " on q" <> show our <> " without a pending release")
            Just (src', sig) -> do
              when (src' /= src) $
                assertFailure ("acquire of " <> show h <> " names q" <> show src <> " but q" <> show src' <> " released it")
              let covered = Map.findWithDefault 0 (our, src) waited
              when (covered < sig) $
                assertFailure ("acquire of " <> show h <> " precedes a wait covering its release (waited " <> show covered <> ", released at " <> show sig <> ")")
              modifyIORef' dev.devHandoffs (Map.delete (tshow h, our))
          logQ dev ps.queue ("acquire " <> tshow h <> " from q" <> tshow src)
    , afterPass = \ps -> do
        let QueueId our = ps.queue
        for_ ps.releases \(Transfer h peer _flags) -> do
          let QueueId dst = peer
          pend <- readIORef dev.devHandoffs
          when (Map.member (tshow h, dst) pend) $
            assertFailure ("double release of " <> show h <> " to q" <> show dst)
          modifyIORef' dev.devHandoffs (Map.insert (tshow h, dst) (our, ps.signal))
          logQ dev ps.queue ("release " <> tshow h <> " to q" <> tshow dst)
        for_ ps.signalEvents \se -> do
          let EventId e = se.event
          modifyIORef' dev.devEvents (Set.insert e)
          logQ dev ps.queue ("signalEvent " <> tshow e)
        let QueueId q = ps.queue
        modifyIORef' dev.devTimeline (IntMap.insert q ps.signal)
        logQ dev ps.queue ("signal " <> tshow ps.signal)
    , completed = do
        tl <- readIORef dev.devTimeline
        pure do
          (q, v) <- IntMap.toList tl
          pure (QueueId q, v)
    }

-- | Every release found its acquire; call after 'FG.executeQueued'.
assertHandoffsDrained :: Device -> IO ()
assertHandoffsDrained dev = do
  pend <- readIORef dev.devHandoffs
  unless (Map.null pend) $
    assertFailure ("released but never acquired: " <> show (Map.keys pend))

-- | Execute on a fresh mock device and close the frame's hand-off ledger.
runQueued :: FrameGraph Device Device -> IO Device
runQueued g = do
  dev <- newDevice
  rq <- FG.newRecycleQueue
  FG.executeQueued g (mkBackend dev) (Just rq) dev dev
  assertHandoffsDrained dev
  pure dev

runDevice :: Device -> Text -> IO ()
runDevice dev n = logHere dev ("run " <> n)

-- | 'runDevice' from inside an execution callback.
runHere :: Text -> FG.Exec Device Device ()
runHere n = do
  dev <- FG.askCtx
  liftIO (runDevice dev n)

syncOf :: Snapshot -> Text -> PassSync
syncOf s n = fromJust do
  p <- find (\p -> p.name == n) s.passes
  p.sync

tshow :: (Show a) => a -> Text
tshow = Text.pack . show