packages feed

keiki-0.1.0.0: test/Keiki/DeciderSpec.hs

module Keiki.DeciderSpec (spec) where

import Data.Time (UTCTime (..), fromGregorian, secondsToDiffTime)
import Keiki.Core
import Keiki.Decider
import Keiki.Fixtures.UserRegistration
import Test.Hspec

-- | Same time fixture as 'Keiki.Fixtures.UserRegistrationSpec' so the
-- decider round-trip lands on the snapshot 'reconstitute' produces.
t :: Integer -> UTCTime
t s = UTCTime (fromGregorian 2026 5 1) (secondsToDiffTime s)

type Snapshot = (Email, ConfirmationCode, UTCTime, UTCTime, UTCTime)

snapshot :: RegFile UserRegRegs -> Snapshot
snapshot regs =
  ( regs ! #email,
    regs ! #confirmCode,
    regs ! #registeredAt,
    regs ! #confirmedAt,
    regs ! #deletedAt
  )

-- | The forward command sequence whose 'omega' trace matches
-- 'Keiki.Fixtures.UserRegistrationSpec.canonicalLog'. The reconstitute
-- spec fixes the events; this fixture records the inputs that produce
-- them on the User Registration edge graph.
-- EP-19 M7: the entrance now drives PotentialCustomer →
-- RequiresConfirmation in one transition emitting two events, so
-- there is no separate Continue command in the sequence.
canonicalCmds :: [UserCmd]
canonicalCmds =
  [ StartRegistration (StartRegistrationData "alice@x" "Z9F4" (t 0)),
    ResendConfirmation (ResendConfirmationData "K2P7" (t 100)),
    ConfirmAccount (ConfirmAccountData "K2P7" (t 200)),
    FulfillGDPRRequest (FulfillGDPRRequestData (t 300))
  ]

-- | Hand-computed snapshot at the end of replay. Same values as
-- 'Keiki.Fixtures.UserRegistrationSpec.expectedSnapshot' so the two
-- specs validate the same end-state from opposite directions.
expectedSnapshot :: Snapshot
expectedSnapshot =
  ( "alice@x",
    "K2P7",
    t 100, -- registeredAt rotated by ResendConfirmation
    t 200, -- confirmedAt
    t 300 -- deletedAt
  )

-- | Run one decide/evolve round on the (s, regs) pair: the façade
-- emits zero or one event (on the state-refinement form), and
-- 'evolve' folds the (zero or one) event back into the state.
runRound ::
  Decider
    UserCmd
    UserEvent
    (Vertex, RegFile UserRegRegs)
    (InFlight Vertex UserEvent, RegFile UserRegRegs) ->
  (Vertex, RegFile UserRegRegs) ->
  UserCmd ->
  (Vertex, RegFile UserRegRegs)
runRound d acc cmd = foldl (evolve d) acc (decide d cmd acc)

spec :: Spec
spec = do
  describe "toDecider userReg" $ do
    it "round-trips the canonical command sequence to (Deleted, expectedSnapshot)" $ do
      let d = toDecider userReg
          (sFinal, regsFinal) = foldl (runRound d) (initialState d) canonicalCmds
      (sFinal, snapshot regsFinal) `shouldBe` (Deleted, expectedSnapshot)

    it "isTerminal d reports True after the canonical sequence" $ do
      let d = toDecider userReg
          end = foldl (runRound d) (initialState d) canonicalCmds
      isTerminal d end `shouldBe` True

    it "decide on the very first command emits the multi-event chain [RegistrationStarted, ConfirmationEmailSent]" $ do
      -- EP-19 M7: the entrance is now a length-2 multi-event edge,
      -- so decide returns both events from StartRegistration in
      -- declaration order.
      let d = toDecider userReg
          evs = decide d (head canonicalCmds) (initialState d)
      length evs `shouldBe` 2
      case evs of
        [RegistrationStarted _, ConfirmationEmailSent _] -> pure ()
        _ -> expectationFailure ("unexpected event sequence: " <> show evs)

    it "ε-edge limitation: GDPR from RequiresConfirmation yields [] from decide" $ do
      -- Drive the aggregate to RequiresConfirmation by chunk-replaying
      -- StartRegistration's two-event chain, then attempt the silent
      -- ε-edge (FulfillGDPRRequest before the user has confirmed).
      let d = toDecider userReg
          startCmd = StartRegistration (StartRegistrationData "bob@x" "S0E1" (t 0))
          startEvs = decide d startCmd (initialState d)
          preGdpr = case applyEvents userReg (initialState d) startEvs of
            Just sR -> sR
            Nothing -> error "applyEvents on the 2-event chain failed"
          gdprCmd = FulfillGDPRRequest (FulfillGDPRRequestData (t 999))
          evs = decide d gdprCmd preGdpr
          afterGdpr = foldl (evolve d) preGdpr evs
      length startEvs `shouldBe` 2
      fst preGdpr `shouldBe` RequiresConfirmation
      evs `shouldBe` []
      -- The ε-edge limitation: with no event, evolve is a no-op, so
      -- the façade leaves the state at RequiresConfirmation even
      -- though the keiki delta would transition to Deleted.
      fst afterGdpr `shouldBe` RequiresConfirmation

    it "ε-edge cross-check: delta does transition the same input to Deleted" $ do
      -- Companion to the previous case: confirm that the keiki
      -- transducer itself can drive the ε-edge via 'delta'. The point
      -- is that the limitation lives at the façade boundary, not in
      -- the underlying transducer.
      let d = toDecider userReg
          startCmd = StartRegistration (StartRegistrationData "carol@x" "T1V2" (t 0))
          startEvs = decide d startCmd (initialState d)
          preGdpr = case applyEvents userReg (initialState d) startEvs of
            Just sR -> sR
            Nothing -> error "applyEvents on the 2-event chain failed"
          (vAtRC, regsAtRC) = preGdpr
          gdprCmd = FulfillGDPRRequest (FulfillGDPRRequestData (t 999))
      vAtRC `shouldBe` RequiresConfirmation
      case delta userReg vAtRC regsAtRC gdprCmd of
        Just (vNext, _) -> vNext `shouldBe` Deleted
        Nothing -> expectationFailure "delta returned Nothing"

  describe "evolveStreaming (EP-19 M5)" $ do
    it "Settled PotentialCustomer ⊢ RegistrationStarted → InFlight RequiresConfirmation [ConfirmationEmailSent]" $ do
      -- After EP-19 M7 collapsed the entrance into a length-2
      -- multi-event edge, streaming replay through its head event
      -- transitions to the mid-chain wrapper carrying the expected
      -- tail event. The second event then unwraps to Settled.
      let d = toDecider userReg
          (_, regs0) = initialState d
          ev =
            RegistrationStarted
              (RegistrationStartedData "dave@x" "U2V3" (t 0))
      case evolveStreaming d (Settled PotentialCustomer, regs0) ev of
        Just (InFlight RequiresConfirmation [ConfirmationEmailSent _], _) -> pure ()
        Just (other, _) ->
          expectationFailure ("expected InFlight RequiresConfirmation [...], got " <> show other)
        Nothing -> expectationFailure "evolveStreaming returned Nothing"

    it "Settled Confirmed ⊢ AccountDeleted → Settled Deleted" $ do
      let d = toDecider userReg
          (_, regs0) = initialState d
          ev = AccountDeleted (AccountDeletedData "x@y" (t 200))
      case evolveStreaming d (Settled Confirmed, regs0) ev of
        Just (Settled Deleted, _) -> pure ()
        other -> expectationFailure ("unexpected: " <> show (fmap fst other))