keiki-0.2.0.0: test/Keiki/CompositionSpec.hs
{-# LANGUAGE TemplateHaskell #-}
-- | Acceptance tests for 'Keiki.Composition.compose' under EP-11 of
-- MasterPlan 4. The fixture is a tiny pipeline:
--
-- AlertSource ⨾ EmailDelivery
--
-- Aggregate 1 ('AlertSource', defined inline in this spec) is a
-- two-vertex transducer that consumes a 'TriggerAlert' command and
-- emits an 'EmailCmd' as its event. Its output type is exactly
-- 'EmailCmd' so that the composite's @mid@ alphabet aligns with
-- 'Keiki.Fixtures.EmailDelivery''s input alphabet without an
-- explicit lifting.
--
-- Aggregate 2 is the canonical 'Keiki.Fixtures.EmailDelivery'
-- transducer.
--
-- The pipeline shape — every transition produces a wire event —
-- means the composite's 'reconstitute' round-trip is well-defined
-- (the design note discusses the ε-edge restriction).
module Keiki.CompositionSpec
( spec,
-- Exported for re-use in 'Keiki.Render.MermaidSpec' (EP-31 M4).
-- See the Decision Log of
-- @docs/plans/31-mermaid-rendering-for-composite-symtransducers.md@
-- for why we re-export rather than duplicate the fixture.
alertSource,
AlertCmd (..),
AlertRegs,
AlertVertex (..),
sampleTrigger,
)
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 (..), compose)
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 AlertSource test fixture ------------------------------------------
-- | Command payload for the source aggregate. Carries the full set
-- of fields the composite ultimately writes into 'EmailEvent', so
-- the round-trip can verify each field arrives intact.
data TriggerAlertData = TriggerAlertData
{ recipient :: Text,
subject :: Text,
at :: UTCTime
}
deriving stock (Eq, Show, Generic)
-- | Command sum for the source aggregate.
data AlertCmd = TriggerAlert TriggerAlertData
deriving stock (Eq, Show, Generic)
-- | Register file for the source aggregate. Mirrors EmailRegs in
-- field shape but with distinct slot names so 'Append AlertRegs
-- EmailRegs' has no name collisions (the keiki RegFile is
-- positional, but distinct names also keep the SBV translation's
-- free-variable names unambiguous).
type AlertRegs =
'[ '("alertRecipient", Text),
'("alertSubject", Text),
'("alertAt", UTCTime)
]
data AlertVertex = AlertQuiescent | AlertEmitted
deriving stock (Eq, Show, Enum, Bounded)
emptyAlertRegs :: RegFile AlertRegs
emptyAlertRegs = emptyRegFile
-- TH-derived per-constructor projections + guards.
$( deriveAggregateCtors
''AlertCmd
''AlertRegs
[ ("TriggerAlert", "Trigger")
]
)
-- The output of AlertSource is EmailCmd — reuse the wire
-- constructor TH splice over EmailCmd by piggy-backing on
-- EmailDelivery's @SendEmail@ data ctor. We can't @deriveWireCtors@
-- against 'EmailCmd' here because that would conflict with
-- EmailDelivery's TH splice's binding of @wireSendEmail@. Build
-- the wire ctor manually using the same generic shape.
$( deriveWireCtors
''EmailCmd
[ ("SendEmail", "SendEmailEvent")
]
)
alertSource ::
SymTransducer
(HsPred AlertRegs AlertCmd)
AlertRegs
AlertVertex
AlertCmd
EmailCmd
alertSource =
SymTransducer
{ edgesOut = alertEdges,
initial = AlertQuiescent,
initialRegs = emptyAlertRegs,
isFinal = \case AlertEmitted -> True; _ -> False
}
alertEdges ::
AlertVertex ->
[Edge (HsPred AlertRegs AlertCmd) AlertRegs AlertCmd EmailCmd AlertVertex]
alertEdges = \case
AlertQuiescent ->
[ Edge
{ guard = isTrigger,
update =
USet
(#alertRecipient :: IndexN "alertRecipient" AlertRegs Text)
(inpTrigger #recipient)
`combine` USet
(#alertSubject :: IndexN "alertSubject" AlertRegs Text)
(inpTrigger #subject)
`combine` USet
(#alertAt :: IndexN "alertAt" AlertRegs UTCTime)
(inpTrigger #at),
-- The output is EmailCmd — built from the trigger's payload.
output =
[ pack
inCtorTrigger
wireSendEmailEvent
( OFCons
(inpTrigger #recipient)
( OFCons
(inpTrigger #subject)
(OFCons (inpTrigger #at) OFNil)
)
)
],
target = AlertEmitted
}
]
AlertEmitted -> []
-- * The composite ---------------------------------------------------------
-- | The composite pipeline: AlertSource ⨾ EmailDelivery.
--
-- Input: AlertCmd
-- Output: EmailEvent
-- Vertex: Composite AlertVertex EmailVertex
-- Regs: Append AlertRegs EmailRegs
pipeline ::
SymTransducer
(HsPred (Append AlertRegs EmailRegs) AlertCmd)
(Append AlertRegs EmailRegs)
(Composite AlertVertex EmailVertex)
AlertCmd
EmailEvent
pipeline = compose alertSource emailDelivery
-- * Test fixtures ---------------------------------------------------------
sampleAt :: UTCTime
sampleAt = UTCTime (fromGregorian 2026 5 2) (secondsToDiffTime 36000)
sampleTrigger :: AlertCmd
sampleTrigger =
TriggerAlert
( TriggerAlertData
{ recipient = "alice@example.com",
subject = "Hello",
at = sampleAt
}
)
sampleEmailEvent :: EmailEvent
sampleEmailEvent =
EmailSent
( EmailSentData
{ recipient = "alice@example.com",
subject = "Hello",
at = sampleAt
}
)
-- * Specs -----------------------------------------------------------------
spec :: Spec
spec = do
describe "compose alertSource emailDelivery" $ do
describe "step (one external command in, one wire event out)" $ do
it "produces EmailSent on TriggerAlert" $
case step pipeline (initial pipeline, initialRegs pipeline) sampleTrigger of
Just (Composite av ev, _, [co]) -> do
av `shouldBe` AlertEmitted
ev `shouldBe` EmailSentVertex
co `shouldBe` sampleEmailEvent
other ->
expectationFailure
( "expected Just (Composite AlertEmitted EmailSentVertex, _, Just EmailSent ...), got "
<> showStep other
)
it "rejects TriggerAlert at the terminal composite vertex" $
let terminalState = Composite AlertEmitted EmailSentVertex
in case step pipeline (terminalState, initialRegs pipeline) sampleTrigger of
Nothing -> pure ()
other -> expectationFailure ("expected Nothing, got " <> showStep other)
describe "checkHiddenInputs" $ do
it "reports no warnings on the composite" $
checkHiddenInputs pipeline `shouldBe` []
describe "isSingleValuedSym (symbolic)" $ do
it "the composite is single-valued" $
isSingleValuedSym (withSymPred pipeline) `shouldBe` True
describe "reconstitute (multi-aggregate event log replay)" $ do
it "lands at the expected final composite state" $
case reconstitute pipeline [sampleEmailEvent] of
Just (Composite av ev, _) -> do
av `shouldBe` AlertEmitted
ev `shouldBe` EmailSentVertex
Nothing ->
expectationFailure "reconstitute returned Nothing for the canonical event log"
describe "omega (the wire event for one external command)" $ do
it "produces sampleEmailEvent on TriggerAlert from initial state" $
omega pipeline (initial pipeline) (initialRegs pipeline) sampleTrigger
`shouldBe` [sampleEmailEvent]
where
showStep :: Maybe (Composite AlertVertex EmailVertex, x, [EmailEvent]) -> String
showStep Nothing = "Nothing"
showStep (Just (Composite a b, _, cos_)) =
"Just (Composite " <> show a <> " " <> show b <> ", _, " <> show cos_ <> ")"