keiki-0.1.0.0: test/Keiki/BuilderSpike.hs
{-# LANGUAGE BlockArguments #-}
{-# LANGUAGE QualifiedDo #-}
{-# LANGUAGE TemplateHaskell #-}
-- | Spike for EP-15 M2: validate the builder against a tiny coffee-
-- dispenser two-vertex toy. After M3 promoted the builder to
-- 'Keiki.Builder', this module is the smallest end-to-end consumer
-- and stays in the test suite as a thin smoke test (the full unit
-- coverage is in 'Keiki.BuilderSpec' under M6).
module Keiki.BuilderSpike (spec) where
import Control.Exception (evaluate)
import Data.Time (UTCTime (..), fromGregorian, secondsToDiffTime)
import GHC.Generics (Generic)
import Keiki.Builder ((*:), (.=))
import Keiki.Builder qualified as B
import Keiki.Core
( Edge (..),
HsPred,
Index,
OutFields (..),
RegFile,
SymTransducer (..),
Update (..),
combine,
delta,
omega,
pack,
)
import Keiki.Core qualified as K
import Keiki.Generics (emptyRegFile)
import Keiki.Generics.TH (deriveAggregateCtors, deriveWireCtors)
import Keiki.Internal.Slots (IndexN)
import Test.Hspec
-- * Coffee-dispenser toy ---------------------------------------------------
-- | Money the dispenser tracks. Two slots: the price quoted at
-- insertion time (\"price\") and a sentinel timestamp for when the
-- brew started (\"brewStartedAt\").
type CoffeeRegs =
'[ '("price", Int),
'("brewStartedAt", UTCTime)
]
emptyCoffeeRegs :: RegFile CoffeeRegs
emptyCoffeeRegs = emptyRegFile
data CoffeeVertex = Idle | Brewing
deriving (Eq, Show, Enum, Bounded)
-- Two commands: insert money (with an amount and a timestamp); a
-- silent Continue tick that completes the brew.
data InsertData = InsertData
{ amount :: Int,
at :: UTCTime
}
deriving (Eq, Show, Generic)
data CoffeeCmd = Insert InsertData | Continue
deriving (Eq, Show, Generic)
-- One event: the dispenser brewed a coffee at amount C cents.
data BrewedData = BrewedData {paid :: Int}
deriving (Eq, Show, Generic)
data CoffeeEvent = Brewed BrewedData
deriving (Eq, Show, Generic)
-- TH: per-input-ctor projections + guards.
$( deriveAggregateCtors
''CoffeeCmd
''CoffeeRegs
[ ("Insert", "Insert"),
("Continue", "Continue")
]
)
-- TH: per-event-ctor wire ctors.
$( deriveWireCtors
''CoffeeEvent
[ ("Brewed", "Brewed")
]
)
-- * The toy transducer, twice -----------------------------------------------
-- AST form. Two edges:
-- Idle --[Insert]--> Brewing emits Brewed { paid = inp.amount }
-- writes price=inp.amount, brewStartedAt=inp.at
-- Brewing --[Continue]-> Idle epsilon (no event), no register changes
coffeeAST ::
SymTransducer
(HsPred CoffeeRegs CoffeeCmd)
CoffeeRegs
CoffeeVertex
CoffeeCmd
CoffeeEvent
coffeeAST =
SymTransducer
{ edgesOut = coffeeASTEdges,
initial = Idle,
initialRegs = emptyCoffeeRegs,
isFinal = const False
}
coffeeASTEdges ::
CoffeeVertex ->
[ Edge
(HsPred CoffeeRegs CoffeeCmd)
CoffeeRegs
CoffeeCmd
CoffeeEvent
CoffeeVertex
]
coffeeASTEdges = \case
Idle ->
[ Edge
{ guard = isInsert,
update =
USet
(#price :: IndexN "price" CoffeeRegs Int)
(inpInsert #amount)
`combine` USet
(#brewStartedAt :: IndexN "brewStartedAt" CoffeeRegs UTCTime)
(inpInsert #at),
output =
[ pack
inCtorInsert
wireBrewed
(OFCons (inpInsert #amount) OFNil)
],
target = Brewing
}
]
Brewing ->
[ Edge
{ guard = isContinue,
update = UKeep,
output = [],
target = Idle
}
]
-- Builder form. Reads as sequential commands.
--
-- The outer @do@ is plain (VertexBuilder is a regular Monad); the
-- per-vertex @do@ is also plain (EdgeListBuilder is a regular Monad);
-- only the per-edge body uses 'B.do' because it is the indexed-state
-- layer that threads the type-level slot-set.
coffeeBuilt ::
SymTransducer
(HsPred CoffeeRegs CoffeeCmd)
CoffeeRegs
CoffeeVertex
CoffeeCmd
CoffeeEvent
coffeeBuilt = B.buildTransducer Idle emptyCoffeeRegs (const False) do
B.from Idle do
B.onCmd inCtorInsert $ \d -> B.do
B.slot @"price" .= d.amount
B.slot @"brewStartedAt" .= d.at
B.emit wireBrewed (d.amount *: B.oNil)
B.goto Brewing
B.from Brewing do
B.onCmd inCtorContinue $ \_d -> B.do
B.goto Idle
-- * Misuse demonstrations --------------------------------------------------
-- A transducer with a missing-goto edge. Top-level binding evaluation
-- raises the finalize-time error; we wrap in an IO action and use
-- 'evaluate' to force the error in-spec.
coffeeMissingGoto ::
SymTransducer
(HsPred CoffeeRegs CoffeeCmd)
CoffeeRegs
CoffeeVertex
CoffeeCmd
CoffeeEvent
coffeeMissingGoto = B.buildTransducer Idle emptyCoffeeRegs (const False) do
B.from Idle do
B.onCmd inCtorInsert $ \_d -> B.do
-- intentional: no goto
B.noEmit
-- A transducer with two gotos in one body.
coffeeDoubleGoto ::
SymTransducer
(HsPred CoffeeRegs CoffeeCmd)
CoffeeRegs
CoffeeVertex
CoffeeCmd
CoffeeEvent
coffeeDoubleGoto = B.buildTransducer Idle emptyCoffeeRegs (const False) do
B.from Idle do
B.onCmd inCtorInsert $ \_d -> B.do
B.goto Brewing
B.goto Idle
-- * In-spec assertions -----------------------------------------------------
t0 :: UTCTime
t0 = UTCTime (fromGregorian 2026 5 2) (secondsToDiffTime 0)
t100 :: UTCTime
t100 = UTCTime (fromGregorian 2026 5 2) (secondsToDiffTime 100)
t200 :: UTCTime
t200 = UTCTime (fromGregorian 2026 5 2) (secondsToDiffTime 200)
spec :: Spec
spec = do
describe "EP-15 M2 spike: builder vs AST agreement" $ do
it "delta and omega agree on Idle + Insert" $ do
let cmd = Insert (InsertData 250 t0)
fmap fst (delta coffeeAST Idle emptyCoffeeRegs cmd)
`shouldBe` fmap fst (delta coffeeBuilt Idle emptyCoffeeRegs cmd)
omega coffeeAST Idle emptyCoffeeRegs cmd
`shouldBe` omega coffeeBuilt Idle emptyCoffeeRegs cmd
it "delta and omega agree on Brewing + Continue" $ do
let insertCmd = Insert (InsertData 250 t0)
Just (_, regs) <- pure (delta coffeeAST Idle emptyCoffeeRegs insertCmd)
fmap fst (delta coffeeAST Brewing regs Continue)
`shouldBe` fmap fst (delta coffeeBuilt Brewing regs Continue)
omega coffeeAST Brewing regs Continue
`shouldBe` omega coffeeBuilt Brewing regs Continue
it "delta is Nothing on Idle + Continue (guard mismatch)" $ do
fmap fst (delta coffeeAST Idle emptyCoffeeRegs Continue) `shouldBe` Nothing
fmap fst (delta coffeeBuilt Idle emptyCoffeeRegs Continue) `shouldBe` Nothing
it "omega round-trips event through the AST and builder forms" $ do
let cmd = Insert (InsertData 300 t100)
omega coffeeAST Idle emptyCoffeeRegs cmd
`shouldBe` [(Brewed (BrewedData 300))]
omega coffeeBuilt Idle emptyCoffeeRegs cmd
`shouldBe` [(Brewed (BrewedData 300))]
describe "EP-15 M2 spike: misuse error messages" $ do
it "missing goto fires at finalize time with the expected message" $
-- `head` forces the first element of the edges list, which is
-- the result of `finalizeEdge`. `length` would only walk the
-- spine and not trigger the error.
evaluate (head (edgesOut coffeeMissingGoto Idle))
`shouldThrow` errorCall
( "Keiki.Builder: edge #0 from Idle: goto missing. "
<> "Each onCmd/onEpsilon body must end with "
<> "exactly one goto V."
)
it "duplicated goto fires at finalize time with the expected message" $
evaluate (head (edgesOut coffeeDoubleGoto Idle))
`shouldThrow` errorCall
( "Keiki.Builder: edge #0 from Idle: goto called "
<> "more than once. Each onCmd/onEpsilon body must "
<> "end with exactly one goto V."
)
describe "EP-15 M2 spike: timestamp also threads correctly" $
it "second slot 'brewStartedAt' is written" $ do
let cmd = Insert (InsertData 200 t200)
Just (_, regs1) <- pure (delta coffeeAST Idle emptyCoffeeRegs cmd)
Just (_, regs2) <- pure (delta coffeeBuilt Idle emptyCoffeeRegs cmd)
(regs1 K.! (#brewStartedAt :: Index CoffeeRegs UTCTime))
`shouldBe` (regs2 K.! (#brewStartedAt :: Index CoffeeRegs UTCTime))