packages feed

keiki-0.2.0.0: test/Keiki/CategorySpec.hs

{-# LANGUAGE BlockArguments #-}

-- | Acceptance tests for the 'Control.Category.Category' instance on
-- 'SomeSymTransducer' (EP-28 of MasterPlan 9).
--
-- The fixture is the existing 'Keiki.Fixtures.EmailDelivery'
-- aggregate and the identity transducer shipped from
-- 'Keiki.Profunctor'. Tests assert the three Category laws (left
-- identity, right identity, associativity) up to state-isomorphism
-- via forward output equality, plus the runtime
-- 'CategoryOverlapError' path and survival of 'isSingleValuedSym'
-- across @id . t@.
module Keiki.CategorySpec (spec) where

import Control.Category (id, (.))
import Control.Exception (evaluate)
import Data.Either (isRight)
import Data.Profunctor (lmap, rmap)
import Data.Time.Calendar (fromGregorian)
import Data.Time.Clock (UTCTime (..), secondsToDiffTime)
import Keiki.Composition
import Keiki.Core
import Keiki.Fixtures.CounterPipeline
import Keiki.Fixtures.EmailDelivery
import Keiki.Generics (Append)
import Keiki.LawHelpers (emittedLog)
import Keiki.Profunctor
import Keiki.Symbolic (isSingleValuedSym, withSymPred)
import Test.Hspec
import Prelude hiding (id, (.))

-- * Fixtures ----------------------------------------------------------------

sampleAt :: UTCTime
sampleAt = UTCTime (fromGregorian 2026 5 9) (secondsToDiffTime 0)

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
      }

-- | @emailDelivery@ wrapped in the existential.
someEmail :: SomeSymTransducer EmailCmd EmailEvent
someEmail = someSymTransducer emailDelivery

-- | An "adapted" copy of @emailDelivery@ whose input alphabet is
-- bridged from 'EmailEvent' back to 'EmailCmd' via 'lmapCi'. Used
-- by the overlap test: @adaptedEmail Cat.. someEmail@ has middle
-- alphabet 'EmailEvent' and forces both halves to share
-- @emailDelivery@'s @EmailRegs@ slots, triggering
-- 'CategoryOverlapError'.
--
-- The 'lmapCi' step poisons @icBuild@ on the rewritten transducer
-- ('Keiki.Profunctor.lmapCi''s documented variance caveat) but the
-- forward-only overlap assertion below never invokes
-- 'Keiki.Core.solveOutput', so the poisoned 'icBuild' is harmless
-- here.
adaptedEmail :: SomeSymTransducer EmailEvent EmailEvent
adaptedEmail = someSymTransducer (lmapCi eventToCmd emailDelivery)
  where
    eventToCmd :: EmailEvent -> EmailCmd
    eventToCmd (EmailSent d) =
      SendEmail
        SendEmailData
          { recipient = d.recipient,
            subject = d.subject,
            at = d.at
          }

-- * Behavioural-equality helpers --------------------------------------------

-- | Run @omega@ on the inner transducer of a 'SomeSymTransducer'
-- starting from its initial state and return the wire output.
-- Behavioural equality between @t1@ and @t2@ on input @ci@ is
-- defined as @runOmega t1 ci == runOmega t2 ci@.
--
-- The 'SomeSymIdentity' sentinel returns its input verbatim — by
-- definition, that is what 'Cat.id' means.
runOmega :: SomeSymTransducer ci co -> ci -> [co]
runOmega (SomeSymTransducer t) ci =
  omega t (initial t) (initialRegs t) ci
runOmega SomeSymIdentity ci = [ci]

-- | Fold 'step' over an input sequence from the initial state,
-- collecting each step's emissions. 'Nothing' means a step rejected.
runSteps :: SomeSymTransducer ci co -> [ci] -> Maybe [[co]]
runSteps (SomeSymTransducer t) inputs = go (initial t, initialRegs t) inputs
  where
    go _ [] = Just []
    go st (ci : rest) = case step t st ci of
      Nothing -> Nothing
      Just (s', regs', cos_) -> (cos_ :) <$> go (s', regs') rest
runSteps SomeSymIdentity inputs = Just (map (: []) inputs)

replays :: (Eq co) => SomeSymTransducer ci co -> [ci] -> Bool
replays (SomeSymTransducer transducer) inputs =
  isRight (reconstituteEither transducer (emittedLog transducer inputs))
replays SomeSymIdentity _ = True

-- | The slot names the wrapper's hidden register file reports.
wrapperSlotNames :: SomeSymTransducer ci co -> [String]
wrapperSlotNames someT = case someT of
  SomeSymTransducer (_ :: SymTransducer (HsPred rs ci) rs s ci co) ->
    slotNames @rs
  SomeSymIdentity -> []

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

spec :: Spec
spec = do
  describe "Cat.id" $ do
    it "lifts identityTransducer at any alphabet" $ do
      let identityAtCmd :: SomeSymTransducer EmailCmd EmailCmd
          identityAtCmd = id
      runOmega identityAtCmd sampleSendEmail
        `shouldBe` [sampleSendEmail]

    it "round-trips an EmailEvent through its EmailEvent identity" $ do
      let identityAtEvent :: SomeSymTransducer EmailEvent EmailEvent
          identityAtEvent = id
      runOmega identityAtEvent sampleEmailEvent
        `shouldBe` [sampleEmailEvent]

  describe "Category laws (behavioural, up to state-isomorphism)" $ do
    it "L1 left identity: id . t behaves like t on a representative input" $
      runOmega (id . someEmail) sampleSendEmail
        `shouldBe` runOmega someEmail sampleSendEmail

    it "L2 right identity: t . id behaves like t on a representative input" $
      runOmega (someEmail . id) sampleSendEmail
        `shouldBe` runOmega someEmail sampleSendEmail

    it "L3 associativity: three stateful stages agree under both associations" $ do
      let wa = someSymTransducer stageA
          wb = someSymTransducer stageB
          wc = someSymTransducer stageC
          inputs = [MsgA 1, MsgA 5, MsgA 2, MsgA 3]
          expected = Just [[MsgD 3], [MsgD 14], [MsgD 19], [MsgD 26]]
          left = runSteps ((wc . wb) . wa) inputs
          right = runSteps (wc . (wb . wa)) inputs
      left `shouldBe` right
      left `shouldBe` expected
      right `shouldBe` expected

      -- stageA's derived-only MsgB output cannot reconstruct MsgA, so neither
      -- association repairs the fixture's pre-existing replay defect. Keep
      -- this as an explicit inversion counterexample alongside the forward
      -- associativity observation above.
      replays ((wc . wb) . wa) inputs `shouldBe` False
      replays (wc . (wb . wa)) inputs `shouldBe` False

    it "L1 with concrete output: id . someEmail still emits the wire EmailEvent" $
      runOmega (id . someEmail) sampleSendEmail
        `shouldBe` [sampleEmailEvent]

  describe "CategoryOverlapError on slot-name collision" $ do
    it "raises when both halves share register slots" $ do
      -- adaptedEmail's slot list is the same as emailDelivery's
      -- (EmailRegs); composing them on the EmailEvent boundary
      -- forces the runtime check to fail on all three EmailRegs
      -- slots.
      let composed = adaptedEmail . someEmail
      evaluate composed
        `shouldThrow` ( \e ->
                          let slots = coeSlots e
                           in "emailRecipient" `elem` slots
                                && "emailSubject" `elem` slots
                                && "emailSentAt" `elem` slots
                      )

    it "does NOT raise when one half is the empty-slot identity" $ do
      -- id has rs = '[], so Disjoint reduces statically; the
      -- runtime check finds no overlap.
      let composedL = id . someEmail
          composedR = someEmail . id
      runOmega composedL sampleSendEmail `shouldBe` [sampleEmailEvent]
      runOmega composedR sampleSendEmail `shouldBe` [sampleEmailEvent]

  describe "PoisonedCompositionError on mapped boundaries" $ do
    it "rejects an upstream rmap boundary" $ do
      let wa = someSymTransducer stageA
          wb = someSymTransducer stageB
          mapped = rmap (\(MsgB n) -> MsgB (n + 100)) wa
      evaluate (wb . mapped)
        `shouldThrow` (\e -> pceSide e == "upstream output")

    it "rejects a downstream lmap boundary" $ do
      let wa = someSymTransducer stageA
          wb = someSymTransducer stageB
          mapped = lmap (\(MsgB n) -> MsgB (n + 100)) wb
      evaluate (mapped . wa)
        `shouldThrow` (\e -> pceSide e == "downstream input")

    it "allows an output map outside composition and applies it" $ do
      let wa = someSymTransducer stageA
          wb = someSymTransducer stageB
          mapped = rmap (\(MsgC n) -> MsgC (n + 100)) (wb . wa)
      runOmega mapped (MsgA 2) `shouldBe` [MsgC 105]

    it "carries an outer output poison into the next boundary" $ do
      let wa = someSymTransducer stageA
          wb = someSymTransducer stageB
          wc = someSymTransducer stageC
          mapped = rmap (\(MsgC n) -> MsgC (n + 100)) (wb . wa)
      evaluate (wc . mapped)
        `shouldThrow` (\e -> pceSide e == "upstream output")

    it "stamped concrete composition is dead instead of silently bypassing rmap" $ do
      let concrete = compose (rmapCo id stageA) stageB
      case step concrete (initial concrete, initialRegs concrete) (MsgA 2) of
        Nothing -> pure ()
        Just _ -> expectationFailure "stamped boundary unexpectedly fired"

  describe "nested stateful composition regressions" $ do
    it "touches the final stage's slots after a nested upstream composite" $ do
      let wa = someSymTransducer stageA
          wb = someSymTransducer stageB
          wc = someSymTransducer stageC
      runSteps (wc . (wb . wa)) [MsgA 1, MsgA 5, MsgA 2]
        `shouldBe` Just [[MsgD 3], [MsgD 14], [MsgD 19]]

    it "reports the real concatenated slot names" $ do
      let wa = someSymTransducer stageA
          wb = someSymTransducer stageB
          wc = someSymTransducer stageC
      wrapperSlotNames (wc . (wb . wa))
        `shouldBe` ["regA", "regB", "regC"]

    it "detects slot overlap against a nested composite" $ do
      let wa = someSymTransducer stageA
          wb = someSymTransducer stageB
          wc = someSymTransducer stageC
          conflict = someSymTransducer stageConflict
          composed = conflict . (wc . (wb . wa))
      evaluate composed
        `shouldThrow` (\e -> "regA" `elem` coeSlots e)

  describe "slot-list witness toolkit" $ do
    it "reports names for a concrete witness" $
      witnessNames (slotWitness @ARegs) `shouldBe` ["regA"]

    it "appends concrete witnesses in register order" $
      witnessNames (appendWitness (slotWitness @ARegs) (slotWitness @BRegs))
        `shouldBe` ["regA", "regB"]

    it "derives KnownSlots for an appended witness" $
      withKnownSlots
        (appendWitness (slotWitness @ARegs) (slotWitness @BRegs))
        (slotNames @(Append ARegs BRegs))
        `shouldBe` ["regA", "regB"]

  describe "isSingleValuedSym survives id . t" $ do
    it "single-valuedness is preserved across left identity" $
      case id . someEmail of
        SomeSymTransducer t ->
          isSingleValuedSym (withSymPred t) `shouldBe` True

    it "single-valuedness is preserved across right identity" $
      case someEmail . id of
        SomeSymTransducer t ->
          isSingleValuedSym (withSymPred t) `shouldBe` True