keiki-0.2.0.0: test/Keiki/CompositionAlternativeSpec.hs
{-# LANGUAGE BlockArguments #-}
{-# LANGUAGE QualifiedDo #-}
{-# 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.Builder qualified as B
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
epsilonRight :: SymTransducer (HsPred '[] PingCmd) '[] Bool PingCmd PingEvent
epsilonRight =
B.buildTransducer False RNil id do
B.from False do
B.onEpsilon B.do
B.noEmit
B.goto True
epsilonSiblings ::
SymTransducer
(HsPred EmailRegs (Either EmailCmd PingCmd))
EmailRegs
(Composite EmailVertex Bool)
(Either EmailCmd PingCmd)
(Either EmailEvent PingEvent)
epsilonSiblings = alternative emailDelivery epsilonRight
-- * 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 "arm-restricts an onEpsilon-authored right edge on Left input" $
case step
epsilonSiblings
(initial epsilonSiblings, initialRegs epsilonSiblings)
(Left sampleSendEmail) of
Just (Composite ev rightVertex, _, [Left co]) -> do
ev `shouldBe` EmailSentVertex
rightVertex `shouldBe` False
co `shouldBe` sampleEmailEvent
other ->
expectationFailure
("expected only the Left edge to fire, got " <> showStep other)
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
it "keeps an alternative with a PTop right edge single-valued" $
isSingleValuedSym (withSymPred epsilonSiblings) `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 ::
(Show pv) =>
Maybe
( Composite EmailVertex pv,
x,
[Either EmailEvent PingEvent]
) ->
String
showStep Nothing = "Nothing"
showStep (Just (cs, _, cos_)) =
"Just (" <> show cs <> ", _, " <> show cos_ <> ")"