packages feed

fractal-layer-0.1.0.0: test/Fractal/Layer/DiagnosticsSpec.hs

{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TypeApplications #-}

module Fractal.Layer.DiagnosticsSpec (spec) where

import Control.Category ((>>>))
import Control.Monad (void)
import Control.Monad.Reader.Class (ask)
import Data.Aeson (encode, decode, toJSON, toEncoding)
import Data.Aeson.Encoding (encodingToLazyByteString)
import qualified Data.ByteString.Lazy as BSL
import qualified Data.HashMap.Strict as HashMap
import qualified Data.Text as T
import Data.Typeable (Proxy(..), typeRep)
import Fractal.Layer
import Fractal.Layer.Diagnostics
import Fractal.Layer.Interceptor
import Test.Hspec
import UnliftIO
import Prelude hiding ((.), id)

-- Test data types
newtype Config = Config { configPort :: Int }
  deriving (Show, Eq)

newtype Database = Database { dbConnection :: String }
  deriving (Show, Eq)

newtype WebServer = WebServer { serverPort :: Int }
  deriving (Show, Eq)

newtype CacheService = CacheService { cacheSize :: Int }
  deriving (Show, Eq)

spec :: Spec
spec = do
  describe "LayerInterceptor" $ do
    it "nullInterceptor has no effect" $ do
      ref <- newIORef ([] :: [String])
      let layer = effect @IO @() @Config $ do
            modifyIORef ref (++ ["effect"])
            pure (Config 8080)
      result <- runLayer () layer
      configPort result `shouldBe` 8080
      logs <- readIORef ref
      logs `shouldBe` ["effect"]

    it "custom interceptor captures operations" $ do
      ref <- newIORef ([] :: [String])
      let customInterceptor = LayerInterceptor
            { onResourceAcquire = \ctx -> liftIO $ modifyIORef ref (++ ["resource-acquire:" <> T.unpack (operationName ctx)])
            , onResourceAcquireComplete = \name _ -> liftIO $ modifyIORef ref (++ ["resource-acquire-complete:" <> T.unpack name])
            , onResourceRelease = \name -> liftIO $ modifyIORef ref (++ ["resource-release:" <> T.unpack name])
            , onEffectRun = \ctx -> liftIO $ modifyIORef ref (++ ["effect-run:" <> T.unpack (operationName ctx)])
            , onEffectComplete = \name _ -> liftIO $ modifyIORef ref (++ ["effect-complete:" <> T.unpack name])
            , onServiceCreate = \ctx -> liftIO $ modifyIORef ref (++ ["service-create:" <> T.unpack (operationName ctx)])
            , onServiceReuse = \name _ -> liftIO $ modifyIORef ref (++ ["service-reuse:" <> T.unpack name])
            , onCompositionStart = \_ -> liftIO $ modifyIORef ref (++ ["composition-start"])
            , onCompositionEnd = \_ _ -> liftIO $ modifyIORef ref (++ ["composition-end"])
            }

      let layer = effect @IO @() @Config $ pure (Config 8080)
      void $ runLayerWithInterceptor customInterceptor () layer

      logs <- readIORef ref
      logs `shouldContain` ["effect-run:Config"]
      logs `shouldContain` ["effect-complete:Config"]

    it "combines multiple interceptors" $ do
      ref1 <- newIORef ([] :: [String])
      ref2 <- newIORef ([] :: [String])

      let interceptor1 = LayerInterceptor
            { onResourceAcquire = \_ -> liftIO $ modifyIORef ref1 (++ ["i1-resource"])
            , onResourceAcquireComplete = \_ _ -> pure ()
            , onResourceRelease = \_ -> liftIO $ modifyIORef ref1 (++ ["i1-release"])
            , onEffectRun = \_ -> liftIO $ modifyIORef ref1 (++ ["i1-effect"])
            , onEffectComplete = \_ _ -> pure ()
            , onServiceCreate = \_ -> pure ()
            , onServiceReuse = \_ _ -> pure ()
            , onCompositionStart = \_ -> pure ()
            , onCompositionEnd = \_ _ -> pure ()
            }

      let interceptor2 = LayerInterceptor
            { onResourceAcquire = \_ -> liftIO $ modifyIORef ref2 (++ ["i2-resource"])
            , onResourceAcquireComplete = \_ _ -> pure ()
            , onResourceRelease = \_ -> liftIO $ modifyIORef ref2 (++ ["i2-release"])
            , onEffectRun = \_ -> liftIO $ modifyIORef ref2 (++ ["i2-effect"])
            , onEffectComplete = \_ _ -> pure ()
            , onServiceCreate = \_ -> pure ()
            , onServiceReuse = \_ _ -> pure ()
            , onCompositionStart = \_ -> pure ()
            , onCompositionEnd = \_ _ -> pure ()
            }

      let combined = combineInterceptors [interceptor1, interceptor2]
      let layer = effect @IO @() @Config $ pure (Config 8080)
      void $ runLayerWithInterceptor combined () layer

      logs1 <- readIORef ref1
      logs2 <- readIORef ref2
      logs1 `shouldContain` ["i1-effect"]
      logs2 `shouldContain` ["i2-effect"]

  describe "Diagnostics Collection" $ do
    it "collects effect operations" $ do
      collector <- newDiagnosticsCollector
      let interceptor = createDiagnosticsInterceptor collector

      let layer = effect @IO @() @Config $ pure (Config 8080)
      result <- runLayerWithInterceptor interceptor () layer

      configPort result `shouldBe` 8080

      diags <- finalizeDiagnostics collector
      totalResources diags `shouldBe` 0
      length (children $ rootNode diags) `shouldSatisfy` (>= 0)

    it "collects resource operations" $ do
      collector <- newDiagnosticsCollector
      let interceptor = createDiagnosticsInterceptor collector

      let layer = resource @IO @() @Database
            (pure $ Database "connected")
            (\_ -> pure ())
      result <- runLayerWithInterceptor interceptor () layer

      dbConnection result `shouldBe` "connected"

      diags <- finalizeDiagnostics collector
      let root = rootNode diags
      nodeName root `shouldBe` "Root"

    it "tracks service creation and reuse" $ do
      collector <- newDiagnosticsCollector
      let interceptor = createDiagnosticsInterceptor collector

      let cacheLayer = effect @IO @() @CacheService $ pure (CacheService 100)
      let cacheService = mkService cacheLayer

      let useServiceTwice = do
            cache1 <- service cacheService
            cache2 <- service cacheService
            pure (cache1, cache2)

      (cache1, cache2) <- runLayerWithInterceptor interceptor () useServiceTwice

      cacheSize cache1 `shouldBe` 100
      cacheSize cache2 `shouldBe` 100

      diags <- finalizeDiagnostics collector
      totalResources diags `shouldSatisfy` (>= 1)
      sharedResources diags `shouldSatisfy` (>= 1)

    it "tracks multiple resources" $ do
      collector <- newDiagnosticsCollector
      let interceptor = createDiagnosticsInterceptor collector

      let layer = do
            _ <- resource @IO @() @Config (pure (Config 1)) (\_ -> pure ())
            _ <- resource @IO @() @Database (pure (Database "db")) (\_ -> pure ())
            _ <- resource @IO @() @WebServer (pure (WebServer 80)) (\_ -> pure ())
            pure ()

      void $ runLayerWithInterceptor interceptor () layer

      diags <- finalizeDiagnostics collector
      let root = rootNode diags
      length (children root) `shouldSatisfy` (>= 3)

  describe "Diagnostics Rendering" $ do
    it "renders a tree structure" $ do
      collector <- newDiagnosticsCollector
      let interceptor = createDiagnosticsInterceptor collector

      let layer = effect @IO @() @Config $ pure (Config 8080)
      void $ runLayerWithInterceptor interceptor () layer

      diags <- finalizeDiagnostics collector
      let rendered = renderLayerTree diags
      rendered `shouldContain` "Layer Initialization Tree"
      rendered `shouldContain` "Duration:"

    it "renders detailed tree with metadata" $ do
      collector <- newDiagnosticsCollector
      let interceptor = createDiagnosticsInterceptor collector

      let layer = effect @IO @() @Config $ pure (Config 8080)
      void $ runLayerWithInterceptor interceptor () layer

      diags <- finalizeDiagnostics collector
      let rendered = renderLayerTreeDetailed diags
      rendered `shouldContain` "Layer Initialization Tree (Detailed)"
      rendered `shouldContain` "Initialized"

    it "renders tree with children" $ do
      collector <- newDiagnosticsCollector
      let interceptor = createDiagnosticsInterceptor collector

      let layer = do
            _ <- resource @IO @() @Config (pure (Config 1)) (\_ -> pure ())
            _ <- effect @IO @() @Database $ pure (Database "db")
            pure ()

      void $ runLayerWithInterceptor interceptor () layer

      diags <- finalizeDiagnostics collector
      let rendered = renderLayerTree diags
      rendered `shouldContain` "Root"

    it "detailed rendering includes type information" $ do
      collector <- newDiagnosticsCollector
      let interceptor = createDiagnosticsInterceptor collector

      let svc = mkService $ resource @IO @() @Config (pure (Config 1)) (\_ -> pure ())
      let layer = do
            _ <- service svc
            _ <- service svc
            pure ()

      void $ runLayerWithInterceptor interceptor () layer

      diags <- finalizeDiagnostics collector
      let rendered = renderLayerTreeDetailed diags
      rendered `shouldContain` "Detailed"
      totalResources diags `shouldSatisfy` (>= 1)
      sharedResources diags `shouldSatisfy` (>= 1)

    it "exports to JSON" $ do
      collector <- newDiagnosticsCollector
      let interceptor = createDiagnosticsInterceptor collector

      let layer = effect @IO @() @Config $ pure (Config 8080)
      void $ runLayerWithInterceptor interceptor () layer

      diags <- finalizeDiagnostics collector
      let json = encode diags
      BSL.length json `shouldSatisfy` (> 0)

      let decoded = decode json :: Maybe LayerDiagnostics
      case decoded of
        Nothing -> expectationFailure "Failed to decode diagnostics JSON"
        Just diags' -> do
          totalDuration diags' `shouldBe` totalDuration diags
          totalResources diags' `shouldBe` totalResources diags

    it "diagnosticsToJSON produces valid JSON" $ do
      collector <- newDiagnosticsCollector
      let interceptor = createDiagnosticsInterceptor collector

      let layer = effect @IO @() @Int $ pure 42
      _ <- runLayerWithInterceptor interceptor () layer

      diags <- finalizeDiagnostics collector
      let json = diagnosticsToJSON diags
      let encoded = encode json
      BSL.length encoded `shouldSatisfy` (> 0)

  describe "LayerNodeType JSON roundtrip" $ do
    it "ResourceNode roundtrips" $ do
      let encoded = encode ResourceNode
      decode encoded `shouldBe` Just ResourceNode

    it "EffectNode roundtrips" $ do
      let encoded = encode EffectNode
      decode encoded `shouldBe` Just EffectNode

    it "ServiceNode roundtrips" $ do
      let encoded = encode ServiceNode
      decode encoded `shouldBe` Just ServiceNode

    it "ComposedNode roundtrips" $ do
      let encoded = encode ComposedNode
      decode encoded `shouldBe` Just ComposedNode

    it "ParallelNode roundtrips" $ do
      let encoded = encode ParallelNode
      decode encoded `shouldBe` Just ParallelNode

    it "SequentialNode roundtrips" $ do
      let encoded = encode SequentialNode
      decode encoded `shouldBe` Just SequentialNode

    it "unknown node type fails" $ do
      let decoded = decode "\"unknown\"" :: Maybe LayerNodeType
      decoded `shouldBe` Nothing

  describe "LayerNodeType equality" $ do
    it "same types are equal" $ do
      ResourceNode `shouldBe` ResourceNode
      EffectNode `shouldBe` EffectNode
      ServiceNode `shouldBe` ServiceNode
      ComposedNode `shouldBe` ComposedNode
      ParallelNode `shouldBe` ParallelNode
      SequentialNode `shouldBe` SequentialNode

    it "different types are not equal" $ do
      ResourceNode `shouldNotBe` EffectNode
      EffectNode `shouldNotBe` ServiceNode
      ComposedNode `shouldNotBe` ParallelNode

  describe "LayerNodeType show" $ do
    it "shows all types" $ do
      show ResourceNode `shouldBe` "ResourceNode"
      show EffectNode `shouldBe` "EffectNode"
      show ServiceNode `shouldBe` "ServiceNode"
      show ComposedNode `shouldBe` "ComposedNode"
      show ParallelNode `shouldBe` "ParallelNode"
      show SequentialNode `shouldBe` "SequentialNode"

  describe "ResourceStatus JSON roundtrip" $ do
    it "Initializing roundtrips" $ do
      let encoded = encode Initializing
      decode encoded `shouldBe` Just Initializing

    it "Initialized roundtrips" $ do
      let encoded = encode Initialized
      decode encoded `shouldBe` Just Initialized

    it "Failed roundtrips" $ do
      let encoded = encode (Failed "some error")
      decode encoded `shouldBe` Just (Failed "some error")

    it "SharedReference roundtrips" $ do
      let encoded = encode (SharedReference "node-42")
      decode encoded `shouldBe` Just (SharedReference "node-42")

    it "unknown status fails" $ do
      let decoded = decode "{\"status\":\"bogus\"}" :: Maybe ResourceStatus
      decoded `shouldBe` Nothing

  describe "ResourceStatus equality" $ do
    it "same statuses are equal" $ do
      Initializing `shouldBe` Initializing
      Initialized `shouldBe` Initialized
      Failed "x" `shouldBe` Failed "x"
      SharedReference "a" `shouldBe` SharedReference "a"

    it "different statuses are not equal" $ do
      Initializing `shouldNotBe` Initialized
      Failed "x" `shouldNotBe` Failed "y"
      SharedReference "a" `shouldNotBe` SharedReference "b"

  describe "ResourceStatus show" $ do
    it "shows all variants" $ do
      show Initializing `shouldContain` "Initializing"
      show Initialized `shouldContain` "Initialized"
      show (Failed "err") `shouldContain` "err"
      show (SharedReference "ref") `shouldContain` "ref"

  describe "LayerDiagnostics show" $ do
    it "shows the diagnostics" $ do
      collector <- newDiagnosticsCollector
      diags <- finalizeDiagnostics collector
      let s = show diags
      s `shouldContain` "LayerDiagnostics"

  describe "LayerNode show" $ do
    it "shows the node" $ do
      let node = LayerNode
            { nodeId = "test"
            , nodeName = "TestNode"
            , nodeType = EffectNode
            , resourceType = Nothing
            , status = Initialized
            , duration = Just 0.1
            , children = []
            , metadata = HashMap.empty
            }
      let s = show node
      s `shouldContain` "TestNode"
      s `shouldContain` "EffectNode"

  describe "LayerNode JSON roundtrip" $ do
    it "basic node roundtrips" $ do
      let node = LayerNode
            { nodeId = "n1"
            , nodeName = "TestNode"
            , nodeType = ResourceNode
            , resourceType = Nothing
            , status = Initialized
            , duration = Just 1.5
            , children = []
            , metadata = HashMap.empty
            }
      let encoded = encode node
      let decoded = decode encoded :: Maybe LayerNode
      case decoded of
        Nothing -> expectationFailure "Failed to decode LayerNode"
        Just n -> do
          nodeId n `shouldBe` "n1"
          nodeName n `shouldBe` "TestNode"
          nodeType n `shouldBe` ResourceNode
          status n `shouldBe` Initialized
          duration n `shouldBe` Just 1.5

    it "node with children roundtrips" $ do
      let child = LayerNode "c1" "Child" EffectNode Nothing Initialized (Just 0.5) [] HashMap.empty
      let parent = LayerNode "p1" "Parent" ComposedNode Nothing Initialized (Just 1.0) [child] HashMap.empty
      let encoded = encode parent
      let decoded = decode encoded :: Maybe LayerNode
      case decoded of
        Nothing -> expectationFailure "Failed to decode parent node"
        Just n -> do
          length (children n) `shouldBe` 1
          case children n of
            [c] -> nodeName c `shouldBe` "Child"
            _ -> expectationFailure "Expected exactly one child"

    it "node with metadata roundtrips" $ do
      let node = LayerNode "m1" "Meta" ServiceNode Nothing
                   (SharedReference "ref-1") Nothing []
                   (HashMap.fromList [("pool", "10"), ("timeout", "30")])
      let encoded = encode node
      let decoded = decode encoded :: Maybe LayerNode
      case decoded of
        Nothing -> expectationFailure "Failed to decode node with metadata"
        Just n -> do
          status n `shouldBe` SharedReference "ref-1"
          HashMap.lookup "pool" (metadata n) `shouldBe` Just "10"
          HashMap.lookup "timeout" (metadata n) `shouldBe` Just "30"

    it "node with Failed status roundtrips" $ do
      let node = LayerNode "f1" "Failing" ResourceNode Nothing (Failed "boom") Nothing [] HashMap.empty
      let encoded = encode node
      let decoded = decode encoded :: Maybe LayerNode
      case decoded of
        Nothing -> expectationFailure "Failed to decode failing node"
        Just n -> status n `shouldBe` Failed "boom"

  describe "LayerDiagnostics JSON roundtrip" $ do
    it "full diagnostics roundtrips" $ do
      let child1 = LayerNode "c1" "ConfigLayer" EffectNode Nothing Initialized (Just 0.05) [] HashMap.empty
      let child2 = LayerNode "c2" "DbLayer" ResourceNode Nothing Initialized (Just 0.2) [] HashMap.empty
      let root = LayerNode "root" "App" SequentialNode Nothing Initialized (Just 0.25)
                   [child1, child2] HashMap.empty
      let diags = LayerDiagnostics
            { rootNode = root
            , totalDuration = 0.25
            , totalResources = 2
            , sharedResources = 0
            }
      let encoded = encode diags
      let decoded = decode encoded :: Maybe LayerDiagnostics
      case decoded of
        Nothing -> expectationFailure "Failed to decode LayerDiagnostics"
        Just d -> do
          totalDuration d `shouldBe` 0.25
          totalResources d `shouldBe` 2
          sharedResources d `shouldBe` 0
          nodeName (rootNode d) `shouldBe` "App"
          length (children (rootNode d)) `shouldBe` 2

  describe "Complex Layer Compositions" $ do
    it "tracks composed layers" $ do
      collector <- newDiagnosticsCollector
      let interceptor = createDiagnosticsInterceptor collector

      let configLayer = effect @IO @() @Config $ pure (Config 8080)
      let dbLayer = do
            cfg <- ask
            effect @IO @Config @Database $ pure $ Database ("localhost:" <> show (configPort cfg))

      let composed = configLayer >>> dbLayer
      result <- runLayerWithInterceptor interceptor () composed

      dbConnection result `shouldBe` "localhost:8080"

      diags <- finalizeDiagnostics collector
      nodeName (rootNode diags) `shouldBe` "Root"

    it "tracks parallel composition" $ do
      collector <- newDiagnosticsCollector
      let interceptor = createDiagnosticsInterceptor collector

      let layer1 = effect @IO @() @Config $ pure (Config 8080)
      let layer2 = effect @IO @() @WebServer $ pure (WebServer 9090)

      let parallelLayers = liftA2 (,) layer1 layer2
      (cfg, ws) <- runLayerWithInterceptor interceptor () parallelLayers

      configPort cfg `shouldBe` 8080
      serverPort ws `shouldBe` 9090

      diags <- finalizeDiagnostics collector
      nodeName (rootNode diags) `shouldBe` "Root"

  describe "Interceptor Edge Cases" $ do
    it "combines interceptors with empty list" $ do
      let combined = combineInterceptors []
      ref <- newIORef ([] :: [String])
      let layer = effect @IO @() @String $ do
            modifyIORef ref (++ ["effect"])
            pure "test"
      result <- runLayerWithInterceptor combined () layer
      result `shouldBe` "test"
      logs <- readIORef ref
      logs `shouldBe` ["effect"]

    it "interceptor captures all operation types" $ do
      ref <- newIORef ([] :: [String])
      let loggingInterceptor = LayerInterceptor
            { onResourceAcquire = \_ -> liftIO $ modifyIORef ref (++ ["acquire"])
            , onResourceAcquireComplete = \_ _ -> pure ()
            , onResourceRelease = \_ -> liftIO $ modifyIORef ref (++ ["release"])
            , onEffectRun = \_ -> liftIO $ modifyIORef ref (++ ["effect"])
            , onEffectComplete = \_ _ -> liftIO $ modifyIORef ref (++ ["effect-done"])
            , onServiceCreate = \_ -> liftIO $ modifyIORef ref (++ ["service-create"])
            , onServiceReuse = \_ _ -> liftIO $ modifyIORef ref (++ ["service-reuse"])
            , onCompositionStart = \_ -> liftIO $ modifyIORef ref (++ ["comp-start"])
            , onCompositionEnd = \_ _ -> liftIO $ modifyIORef ref (++ ["comp-end"])
            }

      let resourceLayer = resource @IO @() @Int (pure 100) (\_ -> pure ())
      let effectLayer = effect @IO @() @String (pure "test")
      let serviceLayer = mkService resourceLayer
      let composed = do
            _ <- resourceLayer
            _ <- effectLayer
            _ <- service serviceLayer
            _ <- service serviceLayer
            pure ()

      void $ runLayerWithInterceptor loggingInterceptor () composed

      logs <- readIORef ref
      "acquire" `elem` logs `shouldBe` True
      "effect" `elem` logs `shouldBe` True
      "service-create" `elem` logs `shouldBe` True
      "service-reuse" `elem` logs `shouldBe` True

    it "helper functions create proper contexts" $ do
      let ctx1 = simpleContext "test"
      operationName ctx1 `shouldBe` "test"
      operationType ctx1 `shouldBe` Nothing

      let ctx2 = withType (typeRep (Proxy @Int)) ctx1
      operationType ctx2 `shouldSatisfy` (/= Nothing)

      let ctx3 = withMetadata [("key", "value")] ctx1
      operationMetadata ctx3 `shouldBe` [("key", "value")]

  describe "Snapshot Diagnostics" $ do
    it "snapshotDiagnostics doesn't finalize collector" $ do
      collector <- newDiagnosticsCollector
      let interceptor = createDiagnosticsInterceptor collector

      snap1 <- snapshotDiagnostics collector
      totalResources snap1 `shouldBe` 0

      let layer = effect @IO @() @Int (pure 42)
      void $ runLayerWithInterceptor interceptor () layer

      snap2 <- snapshotDiagnostics collector
      totalDuration snap2 `shouldSatisfy` (>= 0)

      final <- finalizeDiagnostics collector
      totalDuration final `shouldSatisfy` (>= 0)

    it "live diagnostics rendering with manual control" $ do
      collector <- newDiagnosticsCollector

      snap <- snapshotDiagnostics collector
      nodeName (rootNode snap) `shouldBe` "Root"

      let rendered = renderLayerTree snap
      rendered `shouldContain` "Layer Initialization Tree"

    it "snapshot reflects empty collector" $ do
      collector <- newDiagnosticsCollector
      snap <- snapshotDiagnostics collector
      totalResources snap `shouldBe` 0
      sharedResources snap `shouldBe` 0
      nodeName (rootNode snap) `shouldBe` "Root"
      nodeType (rootNode snap) `shouldBe` ComposedNode

  describe "Complex Diagnostics with withLayerDiagnostics" $ do
    it "withLayerDiagnostics provides both environment and diagnostics" $ do
      let testLayer = effect @IO @() @Int (pure 42)

      withLayerDiagnostics testLayer () $ \(env, diags) -> liftIO $ do
        env `shouldBe` 42
        totalDuration diags `shouldSatisfy` (>= 0)
        nodeName (rootNode diags) `shouldBe` "Root"

    it "buildLayerDiagnostics runs layer and returns diagnostics" $ do
      let testLayer = effect @IO @() @String (pure "test")

      diags <- buildLayerDiagnostics testLayer ()
      totalDuration diags `shouldSatisfy` (>= 0)
      nodeName (rootNode diags) `shouldBe` "Root"

    it "withLayerDiagnostics with resource layers" $ do
      let testLayer = resource @IO @() @String
            (pure "managed-resource")
            (\_ -> pure ())

      withLayerDiagnostics testLayer () $ \(env, diags) -> liftIO $ do
        env `shouldBe` ("managed-resource" :: String)
        totalDuration diags `shouldSatisfy` (>= 0)

    it "buildLayerDiagnostics with service tracking" $ do
      let svc = mkService $ effect @IO @() @Int (pure 99)
      let testLayer = do
            a <- service svc
            b <- service svc
            pure (a + b)

      diags <- buildLayerDiagnostics testLayer ()
      totalResources diags `shouldSatisfy` (>= 1)
      sharedResources diags `shouldSatisfy` (>= 1)

    it "buildLayerDiagnostics with composed layers exercises composition callbacks" $ do
      let configL = effect @IO @() @Config $ pure (Config 8080)
      let dbL = do
            cfg <- ask
            effect @IO @Config @Database $ pure (Database ("db:" <> show (configPort cfg)))
      let composed = configL >>> dbL
      diags <- buildLayerDiagnostics composed ()
      totalDuration diags `shouldSatisfy` (>= 0)
      let root = rootNode diags
      length (children root) `shouldSatisfy` (>= 1)

    it "buildLayerDiagnostics with service reuse through full pipeline" $ do
      let svc = mkService $ resource @IO @() @Config
            (pure (Config 42))
            (\_ -> pure ())
      let testLayer = do
            a <- service svc
            b <- service svc
            pure (configPort a + configPort b)
      diags <- buildLayerDiagnostics testLayer ()
      sharedResources diags `shouldSatisfy` (>= 1)
      totalResources diags `shouldSatisfy` (>= 1)

  describe "Diagnostics - endNode edge cases" $ do
    it "handles completing root node directly" $ do
      collector <- newDiagnosticsCollector
      let interceptor = createDiagnosticsInterceptor collector

      let layer = effect @IO @() @Int $ pure 42
      _ <- runLayerWithInterceptor interceptor () layer

      diags <- finalizeDiagnostics collector
      status (rootNode diags) `shouldBe` Initialized

    it "service without type still tracked" $ do
      collector <- newDiagnosticsCollector
      let interceptor = createDiagnosticsInterceptor collector

      let svc = mkService $ effect @IO @() @Int $ pure 42
      let layer = service svc
      _ <- runLayerWithInterceptor interceptor () layer

      diags <- finalizeDiagnostics collector
      totalResources diags `shouldSatisfy` (>= 1)

  describe "Diagnostics - rendering edge cases" $ do
    it "renders node with no duration" $ do
      let node = LayerNode "n1" "NoDuration" EffectNode Nothing Initializing Nothing [] HashMap.empty
      let diags = LayerDiagnostics
            { rootNode = node
            , totalDuration = 0
            , totalResources = 0
            , sharedResources = 0
            }
      let rendered = renderLayerTree diags
      rendered `shouldContain` "NoDuration"

    it "renders node with Failed status" $ do
      let node = LayerNode "n1" "FailedNode" ResourceNode Nothing (Failed "crash") (Just 0.1) [] HashMap.empty
      let diags = LayerDiagnostics
            { rootNode = node
            , totalDuration = 0.1
            , totalResources = 1
            , sharedResources = 0
            }
      let rendered = renderLayerTree diags
      rendered `shouldContain` "crash"

    it "renders node with SharedReference status" $ do
      let shared = LayerNode "s1" "SharedSvc" ServiceNode Nothing (SharedReference "svc-001") Nothing [] HashMap.empty
      let parent = LayerNode "p1" "Parent" ComposedNode Nothing Initialized (Just 0.5) [shared] HashMap.empty
      let diags = LayerDiagnostics
            { rootNode = parent
            , totalDuration = 0.5
            , totalResources = 1
            , sharedResources = 1
            }
      let rendered = renderLayerTree diags
      rendered `shouldContain` "svc-001"

    it "renders Initializing status" $ do
      let node = LayerNode "n1" "InProgress" EffectNode Nothing Initializing Nothing [] HashMap.empty
      let diags = LayerDiagnostics { rootNode = node, totalDuration = 0, totalResources = 0, sharedResources = 0 }
      let rendered = renderLayerTree diags
      rendered `shouldContain` "InProgress"

    it "renders deeply nested tree" $ do
      let leaf = LayerNode "l1" "Leaf" EffectNode Nothing Initialized (Just 0.01) [] HashMap.empty
      let mid = LayerNode "m1" "Mid" ResourceNode Nothing Initialized (Just 0.05) [leaf] HashMap.empty
      let root = LayerNode "r1" "Root" ComposedNode Nothing Initialized (Just 0.1) [mid] HashMap.empty
      let diags = LayerDiagnostics { rootNode = root, totalDuration = 0.1, totalResources = 2, sharedResources = 0 }
      let rendered = renderLayerTree diags
      rendered `shouldContain` "Leaf"
      rendered `shouldContain` "Mid"

    it "renders multiple children" $ do
      let c1 = LayerNode "c1" "Child1" EffectNode Nothing Initialized (Just 0.01) [] HashMap.empty
      let c2 = LayerNode "c2" "Child2" ResourceNode Nothing Initialized (Just 0.02) [] HashMap.empty
      let c3 = LayerNode "c3" "Child3" ServiceNode Nothing Initialized (Just 0.03) [] HashMap.empty
      let root = LayerNode "r" "Root" ParallelNode Nothing Initialized (Just 0.1) [c1, c2, c3] HashMap.empty
      let diags = LayerDiagnostics { rootNode = root, totalDuration = 0.1, totalResources = 3, sharedResources = 0 }
      let rendered = renderLayerTree diags
      rendered `shouldContain` "Child1"
      rendered `shouldContain` "Child2"
      rendered `shouldContain` "Child3"

    it "detailed rendering shows metadata" $ do
      let node = LayerNode "n1" "WithMeta" ResourceNode (Just (typeRep (Proxy @Int))) Initialized
                   (Just 0.5) [] (HashMap.fromList [("pool", "10")])
      let diags = LayerDiagnostics { rootNode = node, totalDuration = 0.5, totalResources = 1, sharedResources = 0 }
      let rendered = renderLayerTreeDetailed diags
      rendered `shouldContain` "pool"
      rendered `shouldContain` "10"
      rendered `shouldContain` "Int"

    it "detailed rendering shows all status types" $ do
      let mkNode s = LayerNode "n" "N" EffectNode Nothing s Nothing [] HashMap.empty
      let mkDiags n = LayerDiagnostics { rootNode = n, totalDuration = 0, totalResources = 0, sharedResources = 0 }

      let r1 = renderLayerTreeDetailed (mkDiags (mkNode Initializing))
      r1 `shouldContain` "Initializing"

      let r2 = renderLayerTreeDetailed (mkDiags (mkNode Initialized))
      r2 `shouldContain` "Initialized"

      let r3 = renderLayerTreeDetailed (mkDiags (mkNode (Failed "err")))
      r3 `shouldContain` "Failed"
      r3 `shouldContain` "err"

      let r4 = renderLayerTreeDetailed (mkDiags (mkNode (SharedReference "ref-1")))
      r4 `shouldContain` "Shared reference"
      r4 `shouldContain` "ref-1"

    it "detailed rendering with SequentialNode type symbol" $ do
      let node = LayerNode "n1" "Seq" SequentialNode Nothing Initialized Nothing [] HashMap.empty
      let diags = LayerDiagnostics { rootNode = node, totalDuration = 0, totalResources = 0, sharedResources = 0 }
      let rendered = renderLayerTreeDetailed diags
      rendered `shouldContain` "SequentialNode"

    it "detailed rendering with ParallelNode type symbol" $ do
      let node = LayerNode "n1" "Par" ParallelNode Nothing Initialized Nothing [] HashMap.empty
      let diags = LayerDiagnostics { rootNode = node, totalDuration = 0, totalResources = 0, sharedResources = 0 }
      let rendered = renderLayerTreeDetailed diags
      rendered `shouldContain` "ParallelNode"

    it "detailed rendering shows duration" $ do
      let node = LayerNode "n1" "Timed" EffectNode Nothing Initialized (Just 1.234) [] HashMap.empty
      let diags = LayerDiagnostics { rootNode = node, totalDuration = 1.234, totalResources = 0, sharedResources = 0 }
      let rendered = renderLayerTreeDetailed diags
      rendered `shouldContain` "1.234"

    it "detailed rendering with children" $ do
      let child = LayerNode "c1" "Kid" EffectNode Nothing Initialized (Just 0.1) [] HashMap.empty
      let parent = LayerNode "p1" "Dad" ComposedNode Nothing Initialized (Just 0.5) [child] HashMap.empty
      let diags = LayerDiagnostics { rootNode = parent, totalDuration = 0.5, totalResources = 1, sharedResources = 0 }
      let rendered = renderLayerTreeDetailed diags
      rendered `shouldContain` "Kid"
      rendered `shouldContain` "Dad"

  describe "Diagnostics - snapshot support" $ do
    it "snapshotDiagnostics computes totalDuration" $ do
      collector <- newDiagnosticsCollector
      snap <- snapshotDiagnostics collector
      totalDuration snap `shouldSatisfy` (>= 0)
      totalResources snap `shouldBe` 0

  describe "Diagnostics interceptor - composition callbacks" $ do
    it "onCompositionStart creates Sequential node" $ do
      collector <- newDiagnosticsCollector
      let interceptor = createDiagnosticsInterceptor collector
      onCompositionStart interceptor Sequential
      onCompositionEnd interceptor Sequential 0.1
      diags <- finalizeDiagnostics collector
      let root = rootNode diags
      length (children root) `shouldSatisfy` (>= 1)

    it "onCompositionStart creates Parallel node" $ do
      collector <- newDiagnosticsCollector
      let interceptor = createDiagnosticsInterceptor collector
      onCompositionStart interceptor Parallel
      onCompositionEnd interceptor Parallel 0.2
      diags <- finalizeDiagnostics collector
      length (children (rootNode diags)) `shouldSatisfy` (>= 1)

  describe "Diagnostics interceptor - service reuse shared node" $ do
    it "onServiceReuse creates shared reference when service was tracked" $ do
      collector <- newDiagnosticsCollector
      let interceptor = createDiagnosticsInterceptor collector
      let tr = typeRep (Proxy @Int)
      let ctx = OperationContext "IntService" (Just tr) []
      onServiceCreate interceptor ctx
      onServiceReuse interceptor "IntService" tr
      diags <- finalizeDiagnostics collector
      sharedResources diags `shouldBe` 1
      totalResources diags `shouldBe` 1

    it "onServiceReuse with untracked type is a no-op" $ do
      collector <- newDiagnosticsCollector
      let interceptor = createDiagnosticsInterceptor collector
      let tr = typeRep (Proxy @Bool)
      onServiceReuse interceptor "Unknown" tr
      diags <- finalizeDiagnostics collector
      sharedResources diags `shouldBe` 0

  describe "Diagnostics interceptor - endNode edge cases" $ do
    it "endNode on empty stack is a no-op" $ do
      collector <- newDiagnosticsCollector
      let interceptor = createDiagnosticsInterceptor collector
      onEffectComplete interceptor "test" 0.1
      onEffectComplete interceptor "test2" 0.2
      diags <- finalizeDiagnostics collector
      totalDuration diags `shouldSatisfy` (>= 0)

    it "endNode completing root directly" $ do
      collector <- newDiagnosticsCollector
      let interceptor = createDiagnosticsInterceptor collector
      onEffectRun interceptor (OperationContext "root-effect" Nothing [])
      onEffectComplete interceptor "root-effect" 0.5
      diags <- finalizeDiagnostics collector
      let root = rootNode diags
      length (children root) `shouldSatisfy` (>= 1)

  describe "Diagnostics - showList coverage" $ do
    it "showList for LayerNodeType" $ do
      let s = show [ResourceNode, EffectNode, ServiceNode]
      s `shouldContain` "ResourceNode"
      s `shouldContain` "EffectNode"

    it "showList for ResourceStatus" $ do
      let s = show [Initializing, Initialized, Failed "x"]
      s `shouldContain` "Initializing"

    it "showList for LayerDiagnostics" $ do
      let d = LayerDiagnostics
                (LayerNode "r" "R" ComposedNode Nothing Initialized Nothing [] HashMap.empty)
                0 0 0
      let s = show [d]
      s `shouldContain` "LayerDiagnostics"

    it "showList for LayerNode" $ do
      let n = LayerNode "n" "N" EffectNode Nothing Initialized Nothing [] HashMap.empty
      let s = show [n, n]
      s `shouldContain` "LayerNode"

  describe "Diagnostics - JSON list encoding" $ do
    it "toJSON list of LayerNodeType" $ do
      let val = toJSON [ResourceNode, EffectNode, ServiceNode]
      BSL.length (encode val) `shouldSatisfy` (> 0)

    it "toEncoding of LayerNodeType" $ do
      BSL.length (encodingToLazyByteString (toEncoding ResourceNode)) `shouldSatisfy` (> 0)
      BSL.length (encodingToLazyByteString (toEncoding EffectNode)) `shouldSatisfy` (> 0)
      BSL.length (encodingToLazyByteString (toEncoding ServiceNode)) `shouldSatisfy` (> 0)
      BSL.length (encodingToLazyByteString (toEncoding ComposedNode)) `shouldSatisfy` (> 0)
      BSL.length (encodingToLazyByteString (toEncoding ParallelNode)) `shouldSatisfy` (> 0)
      BSL.length (encodingToLazyByteString (toEncoding SequentialNode)) `shouldSatisfy` (> 0)

    it "toEncoding of ResourceStatus" $ do
      BSL.length (encodingToLazyByteString (toEncoding Initializing)) `shouldSatisfy` (> 0)
      BSL.length (encodingToLazyByteString (toEncoding Initialized)) `shouldSatisfy` (> 0)
      BSL.length (encodingToLazyByteString (toEncoding (Failed "e"))) `shouldSatisfy` (> 0)
      BSL.length (encodingToLazyByteString (toEncoding (SharedReference "r"))) `shouldSatisfy` (> 0)

    it "toEncoding of LayerNode" $ do
      let n = LayerNode "n" "N" EffectNode Nothing Initialized (Just 0.1) [] HashMap.empty
      BSL.length (encodingToLazyByteString (toEncoding n)) `shouldSatisfy` (> 0)

    it "toEncoding of LayerDiagnostics" $ do
      let d = LayerDiagnostics
                (LayerNode "r" "R" ComposedNode Nothing Initialized Nothing [] HashMap.empty)
                0.5 2 1
      BSL.length (encodingToLazyByteString (toEncoding d)) `shouldSatisfy` (> 0)

    it "decode JSON list of LayerNodeType" $ do
      let encoded = encode [ResourceNode, EffectNode]
      let decoded = decode encoded :: Maybe [LayerNodeType]
      decoded `shouldBe` Just [ResourceNode, EffectNode]

    it "decode JSON list of ResourceStatus" $ do
      let encoded = encode [Initializing, Initialized]
      let decoded = decode encoded :: Maybe [ResourceStatus]
      decoded `shouldBe` Just [Initializing, Initialized]

    it "decode JSON list of LayerNode" $ do
      let n = LayerNode "n" "N" EffectNode Nothing Initialized (Just 0.1) [] HashMap.empty
      let encoded = encode [n]
      let decoded = decode encoded :: Maybe [LayerNode]
      case decoded of
        Nothing -> expectationFailure "Failed to decode"
        Just ns -> length ns `shouldBe` 1

  describe "Diagnostics - DiagnosticsCollector field" $ do
    it "newDiagnosticsCollector returns usable collector" $ do
      collector <- newDiagnosticsCollector
      diags <- finalizeDiagnostics collector
      nodeName (rootNode diags) `shouldBe` "Root"

  describe "Diagnostics - full pipeline integration" $ do
    it "composed layers produce sequential composition nodes in diagnostics" $ do
      let l1 = effect @IO @() @Config $ pure (Config 1)
      let l2 = do
            c <- ask
            effect @IO @Config @Database $ pure (Database (show (configPort c)))
      diags <- buildLayerDiagnostics (l1 >>> l2) ()
      totalDuration diags `shouldSatisfy` (>= 0)

    it "service create+reuse through full pipeline" $ do
      let svc = mkService $ resource @IO @() @Database
            (pure (Database "svc"))
            (\_ -> pure ())
      let layer = do
            a <- service svc
            b <- service svc
            pure (dbConnection a, dbConnection b)
      diags <- buildLayerDiagnostics layer ()
      totalResources diags `shouldSatisfy` (>= 1)
      sharedResources diags `shouldSatisfy` (>= 1)

    it "resource layer produces resource node in diagnostics" $ do
      let layer = resource @IO @() @Config (pure (Config 1)) (\_ -> pure ())
      diags <- buildLayerDiagnostics layer ()
      let root = rootNode diags
      length (children root) `shouldSatisfy` (>= 1)

  describe "Diagnostics - concurrent safety" $ do
    it "does not lose events under concurrent zipLayer composition" $ do
      (barrier :: MVar ()) <- newEmptyMVar
      let layer1 = resource @IO @() @Config
            (takeMVar barrier >> pure (Config 1))
            (\_ -> pure ())
      let layer2 = resource @IO @() @Database
            (putMVar barrier () >> pure (Database "db"))
            (\_ -> pure ())
      let combined = zipLayer layer1 layer2
      collector <- newDiagnosticsCollector
      let interceptor = createDiagnosticsInterceptor collector
      _ <- runLayerWithInterceptor interceptor ((), ()) combined
      diags <- finalizeDiagnostics collector
      totalDuration diags `shouldSatisfy` (>= 0)

    it "tracks all resources under repeated concurrent composition" $ do
      let mkRes i = resource @IO @() @Int (pure i) (\_ -> pure ())
      let combined = zipLayer (zipLayer (mkRes 1) (mkRes 2)) (zipLayer (mkRes 3) (mkRes 4))
      collector <- newDiagnosticsCollector
      let interceptor = createDiagnosticsInterceptor collector
      _ <- runLayerWithInterceptor interceptor (((),()), ((),())) combined
      diags <- finalizeDiagnostics collector
      nodeName (rootNode diags) `shouldBe` "Root"