packages feed

keiki-0.2.0.0: test/Keiki/RoundTrip.hs

{-# LANGUAGE BlockArguments #-}
{-# LANGUAGE GADTs #-}

-- | Reusable decide/replay round-trip properties for keiki test fixtures.
module Keiki.RoundTrip
  ( RoundTripFixture (..),
    TamperExpectation (..),
    TamperCase (..),
    roundTripSpec,
    roundTripSpecUnchecked,
    teethSpec,
    genUTCTime,
    genShortText,
    genFromPool,
  )
where

import Data.Text (Text)
import Data.Text qualified as Text
import Data.Time (UTCTime)
import Data.Time.Clock.POSIX (posixSecondsToUTCTime)
import Keiki.Core
  ( HsPred,
    RegFile,
    ReplayFailure,
    SymTransducer (..),
    applyEventsEither,
    defaultValidationOptions,
    reconstituteEither,
    step,
    validateTransducer,
  )
import Test.Hspec (Spec, describe, it, shouldBe, shouldNotBe)
import Test.QuickCheck
  ( Gen,
    Property,
    checkCoverage,
    chooseInt,
    chooseInteger,
    counterexample,
    cover,
    elements,
    expectFailure,
    forAllShrinkShow,
    frequency,
    property,
    shrinkList,
    sized,
    vectorOf,
    (===),
  )

-- | What a fixture-specific log mutation promises.
data TamperExpectation = MustFailReplay | MustNotSilentlyMatch

-- | A semantically justified mutation of an event log.
data TamperCase co = TamperCase
  { tcName :: String,
    tcMutate :: [co] -> Maybe [co],
    tcExpect :: TamperExpectation
  }

-- | Everything the generic properties need for one aggregate.
data RoundTripFixture where
  RoundTripFixture ::
    (Bounded s, Enum s, Eq s, Ord s, Show s, Show ci, Eq co, Show co) =>
    { rtName :: String,
      rtTransducer :: SymTransducer (HsPred rs ci) rs s ci co,
      rtGenCommand :: s -> RegFile rs -> Gen ci,
      rtObserve :: s -> RegFile rs -> Text,
      rtTamperCases :: [TamperCase co]
    } ->
    RoundTripFixture

data StepMark = Accepted | Rejected | Epsilon

data TraceStep s ci co = TraceStep
  { tsCommand :: ci,
    tsMark :: StepMark,
    tsEvents :: [co],
    tsState :: s,
    tsObservation :: Text
  }

data ForwardRun s ci co = ForwardRun
  { frTrace :: [TraceStep s ci co],
    frEvents :: [co],
    frFinalState :: s,
    frFinalObservation :: Text
  }

roundTripSpec :: RoundTripFixture -> Spec
roundTripSpec (RoundTripFixture name transducer genCommand observe tamperCases) =
  describe name do
    it "passes validateTransducer with defaultValidationOptions" $
      validateTransducer defaultValidationOptions transducer `shouldBe` []
    it "P1: whole-log replay reproduces the forward state" $
      wholeLogProperty transducer genCommand observe
    it "P2: chunked replay agrees at every command boundary" $
      chunkedProperty transducer genCommand observe
    mapM_ (tamperSpec transducer genCommand observe) tamperCases

-- | Run the replay laws without forcing validation. This is reserved for a
-- fixture whose validation itself is the subject of a later correctness plan.
roundTripSpecUnchecked :: RoundTripFixture -> Spec
roundTripSpecUnchecked (RoundTripFixture name transducer genCommand observe tamperCases) =
  describe name do
    it "validation is explicitly deferred to the composition-alignment plans" $
      (pure () :: IO ())
    it "P1: whole-log replay reproduces the forward state" $
      wholeLogProperty transducer genCommand observe
    it "P2: chunked replay agrees at every command boundary" $
      chunkedProperty transducer genCommand observe
    mapM_ (tamperSpec transducer genCommand observe) tamperCases

teethSpec :: RoundTripFixture -> Spec
teethSpec (RoundTripFixture name transducer genCommand observe _tamperCases) =
  describe (name <> " (teeth)") do
    it "default validation rejects the fixture" $
      validateTransducer defaultValidationOptions transducer `shouldNotBe` []
    it "P1: whole-log replay detects the defect" $
      expectFailure (wholeLogProperty transducer genCommand observe)
    it "P2: chunked replay detects the defect" $
      expectFailure (chunkedProperty transducer genCommand observe)

wholeLogProperty ::
  (Eq s, Show s, Show ci, Eq co, Show co) =>
  SymTransducer (HsPred rs ci) rs s ci co ->
  (s -> RegFile rs -> Gen ci) ->
  (s -> RegFile rs -> Text) ->
  Property
wholeLogProperty transducer genCommand observe =
  forAllCommands transducer genCommand $ \commands ->
    let run = forwardRun transducer observe commands
        result = reconstituteEither transducer run.frEvents
        context = renderRun run <> "\nreplay: " <> renderReplay observe result
     in counterexample context case result of
          Left _ -> property False
          Right (replayState, replayRegs) ->
            (replayState, observe replayState replayRegs)
              === (run.frFinalState, run.frFinalObservation)

chunkedProperty ::
  (Eq s, Show s, Show ci, Eq co, Show co) =>
  SymTransducer (HsPred rs ci) rs s ci co ->
  (s -> RegFile rs -> Gen ci) ->
  (s -> RegFile rs -> Text) ->
  Property
chunkedProperty transducer genCommand observe =
  forAllCommands transducer genCommand $ \commands ->
    let run = forwardRun transducer observe commands
        result = replayChunks (transducer.initial, transducer.initialRegs) run.frTrace
        context = renderRun run <> "\nchunked replay: " <> renderChunkResult result
     in counterexample context case result of
          Left _ -> property False
          Right (replayState, replayRegs) ->
            (replayState, observe replayState replayRegs)
              === (run.frFinalState, run.frFinalObservation)
  where
    replayChunks seed [] = Right seed
    replayChunks seed (traceStep : rest) = case traceStep.tsMark of
      Rejected -> replayChunks seed rest
      Accepted -> advance seed traceStep
      Epsilon -> advance seed traceStep
      where
        advance current stepResult = do
          next@(actualState, actualRegs) <-
            case applyEventsEither transducer current stepResult.tsEvents of
              Left replayFailure -> Left ("replay failure: " <> show replayFailure)
              Right replayed -> Right replayed
          if (actualState, observe actualState actualRegs)
            == (stepResult.tsState, stepResult.tsObservation)
            then replayChunks next rest
            else
              Left
                ( "boundary mismatch after "
                    <> show stepResult.tsCommand
                    <> ": expected "
                    <> renderState stepResult.tsState stepResult.tsObservation
                    <> ", got "
                    <> renderState actualState (observe actualState actualRegs)
                )

tamperSpec ::
  (Eq s, Show s, Show ci, Eq co, Show co) =>
  SymTransducer (HsPred rs ci) rs s ci co ->
  (s -> RegFile rs -> Gen ci) ->
  (s -> RegFile rs -> Text) ->
  TamperCase co ->
  Spec
tamperSpec transducer genCommand observe tamperCase =
  it ("tamper: " <> tamperCase.tcName) $
    checkCoverage $
      forAllCommands transducer genCommand $ \commands ->
        let run = forwardRun transducer observe commands
         in case tamperCase.tcMutate run.frEvents of
              Nothing ->
                cover 30 False "mutation applies" (property True)
              Just mutatedEvents ->
                let replay = reconstituteEither transducer mutatedEvents
                    context =
                      renderRun run
                        <> "\nmutated log:\n  "
                        <> show mutatedEvents
                        <> "\nmutated replay: "
                        <> renderReplay observe replay
                    assertion = case tamperCase.tcExpect of
                      MustFailReplay -> case replay of
                        Left _ -> property True
                        Right _ -> property False
                      MustNotSilentlyMatch -> case replay of
                        Left _ -> property True
                        Right (replayState, replayRegs) ->
                          property $
                            (replayState, observe replayState replayRegs)
                              /= (run.frFinalState, run.frFinalObservation)
                 in cover 30 True "mutation applies" (counterexample context assertion)

forAllCommands ::
  (Show ci) =>
  SymTransducer (HsPred rs ci) rs s ci co ->
  (s -> RegFile rs -> Gen ci) ->
  ([ci] -> Property) ->
  Property
forAllCommands transducer genCommand =
  forAllShrinkShow
    (genCommands transducer genCommand)
    (shrinkList (const []))
    renderCommands

genCommands ::
  SymTransducer (HsPred rs ci) rs s ci co ->
  (s -> RegFile rs -> Gen ci) ->
  Gen [ci]
genCommands transducer genCommand = sized $ \size -> do
  count <- chooseInt (1, max 1 (min 15 size))
  go count transducer.initial transducer.initialRegs
  where
    go 0 _ _ = pure []
    go remaining state regs = do
      command <- genCommand state regs
      let next = case step transducer (state, regs) command of
            Nothing -> (state, regs)
            Just (state', regs', _) -> (state', regs')
      rest <- go (remaining - 1) (fst next) (snd next)
      pure (command : rest)

forwardRun ::
  SymTransducer (HsPred rs ci) rs s ci co ->
  (s -> RegFile rs -> Text) ->
  [ci] ->
  ForwardRun s ci co
forwardRun transducer observe = go transducer.initial transducer.initialRegs [] []
  where
    go state regs traceRev eventsRev [] =
      ForwardRun
        { frTrace = reverse traceRev,
          frEvents = concat (reverse eventsRev),
          frFinalState = state,
          frFinalObservation = observe state regs
        }
    go state regs traceRev eventsRev (command : commands) =
      case step transducer (state, regs) command of
        Nothing ->
          go
            state
            regs
            (TraceStep command Rejected [] state (observe state regs) : traceRev)
            eventsRev
            commands
        Just (nextState, nextRegs, events) ->
          let mark = if null events then Epsilon else Accepted
           in go
                nextState
                nextRegs
                (TraceStep command mark events nextState (observe nextState nextRegs) : traceRev)
                (events : eventsRev)
                commands

renderCommands :: (Show ci) => [ci] -> String
renderCommands commands =
  "commands:\n"
    <> unlines
      [ "  " <> show index <> ". " <> show command
      | (index, command) <- zip [(1 :: Int) ..] commands
      ]

renderRun :: (Show s, Show ci, Show co) => ForwardRun s ci co -> String
renderRun run =
  "commands (* accepted, - rejected, epsilon accepted-with-zero-output):\n"
    <> unlines
      [ "  " <> show index <> ". " <> renderMark traceStep.tsMark <> " " <> show traceStep.tsCommand
      | (index, traceStep) <- zip [(1 :: Int) ..] run.frTrace
      ]
    <> "event log:\n  "
    <> show run.frEvents
    <> "\nforward final: "
    <> renderState run.frFinalState run.frFinalObservation

renderMark :: StepMark -> String
renderMark Accepted = "*"
renderMark Rejected = "-"
renderMark Epsilon = "epsilon"

renderState :: (Show s) => s -> Text -> String
renderState state observation = show state <> " | " <> Text.unpack observation

renderReplay :: (Show s, Show co) => (s -> RegFile rs -> Text) -> Either (ReplayFailure s co) (s, RegFile rs) -> String
renderReplay observe = \case
  Left failure -> "Left " <> show failure
  Right (state, regs) -> "Right (" <> renderState state (observe state regs) <> ")"

renderChunkResult :: (Show s) => Either String (s, RegFile rs) -> String
renderChunkResult = \case
  Left message -> "Left (" <> message <> ")"
  Right (state, _) -> "Right (" <> show state <> ")"

-- | Whole-second timestamps keep generated values and counterexamples compact.
genUTCTime :: Gen UTCTime
genUTCTime =
  posixSecondsToUTCTime . fromInteger <$> chooseInteger (0, 2_000_000_000)

-- | A compact text generator suitable for domain identifiers and payloads.
genShortText :: Gen Text
genShortText = do
  length' <- chooseInt (1, 10)
  Text.pack <$> vectorOf length' (elements ['a' .. 'z'])

-- | Prefer a collision-friendly pool while retaining arbitrary short values.
genFromPool :: [Text] -> Gen Text
genFromPool [] = genShortText
genFromPool pool = frequency [(4, elements pool), (1, genShortText)]