packages feed

keiki-0.1.0.0: test/Keiki/CompositionAlternativeSpec.hs

{-# LANGUAGE TemplateHaskell #-}

-- | Acceptance tests for 'Keiki.Composition.alternative' under EP-25
-- of MasterPlan 8. The fixture composes 'Keiki.Fixtures.EmailDelivery'
-- with a small inline 'Pinger' aggregate via 'alternative':
--
--    Either EmailCmd PingCmd  →  Either EmailEvent PingEvent
--
-- The two aggregates have disjoint slot-name domains
-- (EmailDelivery: @emailRecipient@, @emailSubject@, @emailSentAt@;
-- Pinger: @pingNonce@), so the @Disjoint (Names rs1) (Names rs2)@
-- constraint on 'alternative' resolves automatically.
--
-- The composite vertex is 'Composite' EmailVertex PingVertex
-- (product, not sum): each input updates exactly one sub-aggregate
-- and leaves the other's state unchanged. See EP-25's Surprises &
-- Discoveries entry dated 2026-05-03 for the design discovery that
-- led to this shape.
module Keiki.CompositionAlternativeSpec
  ( spec,
    -- Re-exported for "Keiki.Render.MermaidSpec" (EP-33 M6). Following
    -- the test-fixture-re-export pattern EP-31's M4 established (see
    -- @docs/plans/33-shape-aware-mermaid-renderers-for-alternative-and-feedback1-composites.md@'s
    -- IP-5 reference).
    pinger,
    PingVertex (..),
    siblings,
  )
where

import Data.Text (Text)
import Data.Time (UTCTime)
import Data.Time.Calendar (fromGregorian)
import Data.Time.Clock (UTCTime (..), secondsToDiffTime)
import GHC.Generics (Generic)
import Keiki.Composition (Composite (..), alternative)
import Keiki.Core
import Keiki.Fixtures.EmailDelivery
import Keiki.Generics (Append, emptyRegFile)
import Keiki.Generics.TH (deriveAggregateCtors, deriveWireCtors)
import Keiki.Symbolic (isSingleValuedSym, withSymPred)
import Test.Hspec

-- * The Pinger fixture ----------------------------------------------------

-- | Single-field command payload: the nonce echoed back as the
-- event's nonce.
data PingData = PingData
  { nonce :: Text
  }
  deriving stock (Eq, Show, Generic)

data PingCmd = Ping PingData
  deriving stock (Eq, Show, Generic)

-- | Single-field event payload: same nonce, different wrapping.
data PongData = PongData
  { nonce :: Text
  }
  deriving stock (Eq, Show, Generic)

data PingEvent = Pong PongData
  deriving stock (Eq, Show, Generic)

-- | Two-vertex aggregate: idle → pinged on a 'Ping'.
data PingVertex = PingIdle | PingDone
  deriving stock (Eq, Show, Enum, Bounded)

-- | Disjoint slot name from EmailDelivery's @emailRecipient@ /
-- @emailSubject@ / @emailSentAt@ so 'alternative''s
-- @Disjoint (Names rs1) (Names rs2)@ constraint resolves.
type PingRegs =
  '[ '("pingNonce", Text)
   ]

emptyPingRegs :: RegFile PingRegs
emptyPingRegs = emptyRegFile

-- TH-derived per-constructor projections + guards. The third element
-- (the binding-suffix) is "Ping" so the bindings are @inCtorPing@,
-- @inpPing@, @isPing@.
$( deriveAggregateCtors
     ''PingCmd
     ''PingRegs
     [ ("Ping", "Ping")
     ]
 )

$( deriveWireCtors
     ''PingEvent
     [ ("Pong", "Pong")
     ]
 )

pinger ::
  SymTransducer
    (HsPred PingRegs PingCmd)
    PingRegs
    PingVertex
    PingCmd
    PingEvent
pinger =
  SymTransducer
    { edgesOut = pingerEdges,
      initial = PingIdle,
      initialRegs = emptyPingRegs,
      isFinal = \case PingDone -> True; _ -> False
    }

pingerEdges ::
  PingVertex ->
  [Edge (HsPred PingRegs PingCmd) PingRegs PingCmd PingEvent PingVertex]
pingerEdges = \case
  PingIdle ->
    [ Edge
        { guard = isPing,
          update =
            USet
              (#pingNonce :: IndexN "pingNonce" PingRegs Text)
              (inpPing #nonce),
          output =
            [ pack
                inCtorPing
                wirePong
                (OFCons (inpPing #nonce) OFNil)
            ],
          target = PingDone
        }
    ]
  PingDone -> []

-- * The composite --------------------------------------------------------

-- | The alternative composite: emailDelivery on the left arm, pinger
-- on the right arm. The composite's vertex is the product
-- 'Composite EmailVertex PingVertex' — each arm has its own state
-- that evolves independently as Left / Right inputs arrive.
--
-- Input:  Either EmailCmd PingCmd
-- Output: Either EmailEvent PingEvent
-- Vertex: Composite EmailVertex PingVertex
-- Regs:   Append EmailRegs PingRegs
siblings ::
  SymTransducer
    (HsPred (Append EmailRegs PingRegs) (Either EmailCmd PingCmd))
    (Append EmailRegs PingRegs)
    (Composite EmailVertex PingVertex)
    (Either EmailCmd PingCmd)
    (Either EmailEvent PingEvent)
siblings = alternative emailDelivery pinger

-- * Test fixtures --------------------------------------------------------

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

sampleSendEmail :: EmailCmd
sampleSendEmail =
  SendEmail
    ( SendEmailData
        { recipient = "alice@example.com",
          subject = "Hello",
          at = sampleAt
        }
    )

sampleEmailEvent :: EmailEvent
sampleEmailEvent =
  EmailSent
    ( EmailSentData
        { recipient = "alice@example.com",
          subject = "Hello",
          at = sampleAt
        }
    )

samplePing :: PingCmd
samplePing = Ping (PingData {nonce = "abc123"})

samplePingEvent :: PingEvent
samplePingEvent = Pong (PongData {nonce = "abc123"})

-- * Specs ----------------------------------------------------------------

spec :: Spec
spec = do
  describe "alternative emailDelivery pinger" $ do
    describe "step routing" $ do
      it "Left input advances the EmailDelivery arm and emits Left output" $
        case step
          siblings
          (initial siblings, initialRegs siblings)
          (Left sampleSendEmail) of
          Just (Composite ev pv, _, [Left co]) -> do
            ev `shouldBe` EmailSentVertex
            pv `shouldBe` PingIdle -- Pinger arm unchanged
            co `shouldBe` sampleEmailEvent
          other ->
            expectationFailure
              ( "expected Just (Composite EmailSentVertex PingIdle, _, Just (Left EmailSent ...)), got "
                  <> showStep other
              )

      it "Right input advances the Pinger arm and emits Right output" $
        case step
          siblings
          (initial siblings, initialRegs siblings)
          (Right samplePing) of
          Just (Composite ev pv, _, [Right co]) -> do
            ev `shouldBe` EmailPending -- EmailDelivery arm unchanged
            pv `shouldBe` PingDone
            co `shouldBe` samplePingEvent
          other ->
            expectationFailure
              ( "expected Just (Composite EmailPending PingDone, _, Just (Right Pong ...)), got "
                  <> showStep other
              )

      it "two-step interleave: Left then Right advances both arms independently" $
        case step
          siblings
          (initial siblings, initialRegs siblings)
          (Left sampleSendEmail) of
          Just (s1, regs1, _) ->
            case step siblings (s1, regs1) (Right samplePing) of
              Just (Composite ev pv, _, [Right co]) -> do
                ev `shouldBe` EmailSentVertex -- preserved from step 1
                pv `shouldBe` PingDone
                co `shouldBe` samplePingEvent
              other ->
                expectationFailure
                  ( "expected both arms advanced after Left+Right, got "
                      <> showStep other
                  )
          Nothing -> expectationFailure "first step (Left) returned Nothing"

    describe "checkHiddenInputs" $ do
      it "reports no warnings on the alternative composite" $
        checkHiddenInputs siblings `shouldBe` []

    describe "isSingleValuedSym (symbolic)" $ do
      it "the alternative composite is single-valued" $
        isSingleValuedSym (withSymPred siblings) `shouldBe` True

    describe "reconstitute (mixed-arm event log replay)" $ do
      it "lands at Composite EmailSentVertex PingDone on a Left+Right log" $
        case reconstitute siblings [Left sampleEmailEvent, Right samplePingEvent] of
          Just (Composite ev pv, _) -> do
            ev `shouldBe` EmailSentVertex
            pv `shouldBe` PingDone
          Nothing ->
            expectationFailure
              "reconstitute returned Nothing on the canonical mixed-arm log"

      it "preserves cross-arm state across reconstitute order (Right then Left)" $
        case reconstitute siblings [Right samplePingEvent, Left sampleEmailEvent] of
          Just (Composite ev pv, _) -> do
            ev `shouldBe` EmailSentVertex
            pv `shouldBe` PingDone
          Nothing ->
            expectationFailure
              "reconstitute returned Nothing on the reordered mixed-arm log"

    describe "omega (the wire event for one external command)" $ do
      it "produces Left sampleEmailEvent on Left sampleSendEmail" $
        omega
          siblings
          (initial siblings)
          (initialRegs siblings)
          (Left sampleSendEmail)
          `shouldBe` [(Left sampleEmailEvent)]

      it "produces Right samplePingEvent on Right samplePing" $
        omega
          siblings
          (initial siblings)
          (initialRegs siblings)
          (Right samplePing)
          `shouldBe` [(Right samplePingEvent)]
  where
    showStep ::
      Maybe
        ( Composite EmailVertex PingVertex,
          x,
          [Either EmailEvent PingEvent]
        ) ->
      String
    showStep Nothing = "Nothing"
    showStep (Just (cs, _, cos_)) =
      "Just (" <> show cs <> ", _, " <> show cos_ <> ")"