packages feed

keiki-0.2.0.0: test/Keiki/ProfunctorSpec.hs

-- | Acceptance tests for 'Keiki.Profunctor' (EP-27 of MasterPlan 9).
--
-- The fixture is the existing 'Keiki.Fixtures.EmailDelivery'
-- aggregate. Each combinator is applied and forward processing /
-- inversion / single-valuedness / hidden-input behaviour is asserted
-- against the documented contract.
module Keiki.ProfunctorSpec (spec) where

import Control.Exception (ErrorCall (..), evaluate)
import Data.Profunctor (Profunctor (..), dimap)
import Data.Time.Calendar (fromGregorian)
import Data.Time.Clock (UTCTime (..), secondsToDiffTime)
import GHC.Generics (Generic)
import Keiki.Core
import Keiki.Fixtures.CounterPipeline
import Keiki.Fixtures.EmailDelivery
import Keiki.LawHelpers (emittedLog, runScript)
import Keiki.Profunctor
import Keiki.Symbolic (isSingleValuedSym, withSymPred)
import Test.Hspec

-- | A wrapper command type used to exercise 'lmapCi'.
newtype WrappedCmd = WrappedCmd {unwrapCmd :: EmailCmd}
  deriving stock (Eq, Show, Generic)

-- | A wrapper event type used to exercise 'rmapCo'.
newtype WrappedEvent = WrappedEvent {unwrapEvent :: EmailEvent}
  deriving stock (Eq, Show, Generic)

-- | A sum command type used to exercise 'lmapMaybeCi' as a router.
data RouterCmd
  = ToEmail EmailCmd
  | OtherCmd
  deriving stock (Eq, Show, Generic)

-- | Routing function: project the EmailCmd arm of 'RouterCmd'.
router :: RouterCmd -> Maybe EmailCmd
router (ToEmail c) = Just c
router OtherCmd = Nothing

-- | A representative input we will fire through transducers.
sampleEmailCmd :: EmailCmd
sampleEmailCmd =
  SendEmail
    SendEmailData
      { recipient = "alice@example.com",
        subject = "hello",
        at = sampleAt
      }

sampleAt :: UTCTime
sampleAt = UTCTime (fromGregorian 2026 5 3) (secondsToDiffTime 0)

-- | Fire a single command through a transducer's initial state and
-- collect '(target, output)' pairs from every active (guard-true)
-- edge. This is sufficient to assert the rewriters preserve forward
-- behaviour without going through 'delta'/'omega''s
-- single-valuedness gate.
fireFromInitial ::
  SymTransducer (HsPred rs ci) rs s ci co ->
  ci ->
  [(s, [co])]
fireFromInitial t ci =
  [ (target e, map (\o -> evalOut o (initialRegs t) ci) (output e))
  | e <- edgesOut t (initial t),
    evalPred (guard e) (initialRegs t) ci
  ]

-- | Pull a single edge's structural OPack output list for
-- assertions about the rewriter's effect on the AST itself.
firstEdgeOutput ::
  SymTransducer (HsPred rs ci) rs s ci co ->
  [OutTerm rs ci co]
firstEdgeOutput t = case edgesOut t (initial t) of
  [] -> []
  (e : _) -> output e

-- | 'fireFromInitial' but drops the (existential) vertex type so the
-- result can escape an existential pattern. Returns the per-edge
-- output (or Nothing for ε-edges) in the same order edges were
-- fired.
fireOutputsOnly ::
  SymTransducer (HsPred rs ci) rs s ci co ->
  ci ->
  [[co]]
fireOutputsOnly t ci = map snd (fireFromInitial t ci)

spec :: Spec
spec = do
  describe "lmapCi" $ do
    it "preserves forward processing through the rewritten transducer" $ do
      let lmapped = lmapCi unwrapCmd emailDelivery
          original = emailDelivery
          fromOriginal = fireFromInitial original sampleEmailCmd
          fromLmapped = fireFromInitial lmapped (WrappedCmd sampleEmailCmd)
      length fromOriginal `shouldBe` length fromLmapped
      map fst fromOriginal `shouldBe` map fst fromLmapped
      map snd fromOriginal `shouldBe` map snd fromLmapped

    it "raises a poisoned-icBuild error when solveOutput is forced on lmapped edges" $ do
      let lmapped = lmapCi unwrapCmd emailDelivery
      case firstEdgeOutput lmapped of
        [] -> expectationFailure "lmapped EmailDelivery should have a non-eps edge output"
        (o : _) -> do
          -- The structural inverse returns 'Just (icBuild …)' — the
          -- 'Just' is in WHNF but the inner thunk only fires when
          -- forced. lmapCi's contract: if you try to recover a 'ci'
          -- from a wire event, the poisoned icBuild raises a clear
          -- error. We force the inner value and assert the throw.
          let event =
                EmailSent
                  EmailSentData
                    { recipient = "alice@example.com",
                      subject = "hello",
                      at = sampleAt
                    }
              recovered :: Maybe WrappedCmd
              recovered = solveOutput o (initialRegs lmapped) event
          case recovered of
            Nothing ->
              expectationFailure
                "lmapped solveOutput unexpectedly returned Nothing — \
                \the contract is 'Just (poison)' not 'Nothing'"
            Just c ->
              evaluate c
                `shouldThrow` errorCall' "icBuild on a contramapped InCtor"

    it "preserves isSingleValuedSym" $ do
      isSingleValuedSym (withSymPred (lmapCi unwrapCmd emailDelivery))
        `shouldBe` True

  describe "rmapCo" $ do
    it "post-composes the output through the supplied function" $ do
      let rmapped = rmapCo WrappedEvent emailDelivery
          fromRmapped = fireFromInitial rmapped sampleEmailCmd
          expected =
            [ ( EmailSentVertex,
                [ WrappedEvent
                    ( EmailSent
                        EmailSentData
                          { recipient = "alice@example.com",
                            subject = "hello",
                            at = sampleAt
                          }
                    )
                ]
              )
            ]
      fromRmapped `shouldBe` expected

    it "returns Nothing from solveOutput on rmapped edges" $ do
      let rmapped = rmapCo WrappedEvent emailDelivery
      case firstEdgeOutput rmapped of
        [] -> expectationFailure "rmapped EmailDelivery should have a non-eps edge output"
        (o : _) -> do
          let wrappedEvent =
                WrappedEvent
                  ( EmailSent
                      EmailSentData
                        { recipient = "alice@example.com",
                          subject = "hello",
                          at = sampleAt
                        }
                  )
          solveOutput o (initialRegs rmapped) wrappedEvent
            `shouldBe` (Nothing :: Maybe EmailCmd)

    it "preserves isSingleValuedSym" $ do
      isSingleValuedSym (withSymPred (rmapCo WrappedEvent emailDelivery))
        `shouldBe` True

  describe "dimapTransducer" $ do
    it "agrees with rmapCo . lmapCi on forward output" $ do
      let viaDimap = dimapTransducer unwrapCmd WrappedEvent emailDelivery
          viaSplit = rmapCo WrappedEvent (lmapCi unwrapCmd emailDelivery)
          input = WrappedCmd sampleEmailCmd
      fireFromInitial viaDimap input
        `shouldBe` fireFromInitial viaSplit input

  describe "lmapMaybeCi" $ do
    it "filters non-routed inputs (no edges fire)" $ do
      let routed = lmapMaybeCi router emailDelivery
      fireFromInitial routed OtherCmd `shouldBe` []

    it "passes routed inputs through" $ do
      let routed = lmapMaybeCi router emailDelivery
          fired = fireFromInitial routed (ToEmail sampleEmailCmd)
      length fired `shouldBe` 1
      map fst fired `shouldBe` [EmailSentVertex]

  describe "Profunctor SomeSymTransducer" $ do
    it "dimap on the wrapper agrees with dimapTransducer on the inner" $ do
      let viaWrapper =
            dimap unwrapCmd WrappedEvent (someSymTransducer emailDelivery)
          viaConcrete =
            dimapTransducer unwrapCmd WrappedEvent emailDelivery
          input = WrappedCmd sampleEmailCmd
          fromConcrete = fireOutputsOnly viaConcrete input
      case viaWrapper of
        SomeSymTransducer t -> fireOutputsOnly t input `shouldBe` fromConcrete

    it "fmap on the wrapper post-composes the output" $ do
      let mapped = fmap WrappedEvent (someSymTransducer emailDelivery)
          expected =
            [ [ WrappedEvent
                  ( EmailSent
                      EmailSentData
                        { recipient = "alice@example.com",
                          subject = "hello",
                          at = sampleAt
                        }
                  )
              ]
            ]
      case mapped of
        SomeSymTransducer t ->
          fireOutputsOnly t sampleEmailCmd `shouldBe` expected

  describe "forward and inversion observations" $ do
    it "dimap id id preserves a four-command stateful forward trace" $ do
      let mapped = dimapTransducer id id stageA
          script = map MsgA [1, 5, 2, 3]
      runScript mapped script `shouldBe` runScript stageA script

    it "dimap composition preserves a four-command stateful forward trace" $ do
      let f (MsgA n) = MsgA (n + 1)
          f' (MsgA n) = MsgA (n * 2)
          g (MsgB n) = MsgB (n + 3)
          g' (MsgB n) = MsgB (n * 4)
          direct = dimapTransducer (f' . f) (g . g') stageA
          nested = dimapTransducer f g (dimapTransducer f' g' stageA)
          script = map MsgA [1, 5, 2, 3]
      runScript direct script `shouldBe` runScript nested script

    it "dimap id id is not replay-equivalent" $ do
      let mapped = dimapTransducer id id stageA
          script = map MsgA [1, 5, 2, 3]
      case reconstituteEither mapped (emittedLog mapped script) of
        Left _ -> pure ()
        Right _ -> expectationFailure "dimap id id unexpectedly replayed"

-- * Hspec helpers ----------------------------------------------------------

-- | A loose 'errorCall' matcher: asserts the thrown 'ErrorCall'
-- contains the given substring. Hspec ships an 'errorCall' matcher
-- for exact match; the substring variant is useful when the message
-- is partially a runtime-formatted string.
errorCall' :: String -> Selector ErrorCall
errorCall' needle (ErrorCall msg) = needle `isInfixOf'` msg

isInfixOf' :: String -> String -> Bool
isInfixOf' needle haystack
  | length needle > length haystack = False
  | take (length needle) haystack == needle = True
  | otherwise = isInfixOf' needle (drop 1 haystack)