packages feed

aws-eventbridge-cron-0.1.0.0: test/AWS/EventBridge/MinutesSpec.hs

{-# LANGUAGE OverloadedStrings #-}

module AWS.EventBridge.MinutesSpec (tests) where

import AWS.EventBridge.Minutes
import Data.List (nub, sort)
import qualified Data.Text as T
import Test.Tasty (TestTree, testGroup)
import Test.Tasty.HUnit (testCase, (@?=))
import Test.Tasty.QuickCheck as QC
import TestSupport
  ( assertLeft
  , expectEvalWith
  , expectParseWith
  , strictlyAscending
  , withinBoundsInt
  )

tests :: TestTree
tests =
  testGroup "minutes"
    [ manualTests
    , propertyTests
    ]

manualTests :: TestTree
manualTests = testGroup "manual"
  [ testCase "parse *" $ parseMinutesText "*" @?= Right AllMinutes
  , testCase "parse literal minute" $ parseMinutesText "5" @?= Right (AtMinute 5)
  , testCase "parse range" $ parseMinutesText "10-12" @?= Right (RangeMinute 10 12)
  , testCase "parse step" $ parseMinutesText "5/15" @?= Right (StepMinute 5 15)
    , testCase "parse union" $ do
      expr <- expectParseWith "minutes" parseMinutesText "0,15,30"
      result <- expectEvalWith "minutes" evaluateMinuteT expr
      result @?= [0,15,30]
    , testCase "evaluate AllMinutes" $ do
      result <- expectEvalWith "minutes" evaluateMinuteT AllMinutes
      result @?= [0..59]
  , testCase "invalid step start fails" $
      assertLeft (evaluateMinuteT (StepMinute 75 5))
  , testCase "invalid step increment fails" $
      assertLeft (evaluateMinuteT (StepMinute 10 0))
  ]

propertyTests :: TestTree
propertyTests = testGroup "properties"
  [ QC.testProperty "AllMinutes covers 0..59" propAllMinutesRange
  , QC.testProperty "AtMinute returns singleton" propAtMinuteSingleton
  , QC.testProperty "Generated expressions evaluate within bounds" propExpressionsWithinBounds
  , QC.testProperty "Parse of comma list matches sorted unique" propParseCommaSeparated
  , QC.testProperty "Invalid literal minute rejected" propInvalidMinuteRejected
  , QC.testProperty "Step minute forms arithmetic progression" propStepMinuteProgression
  ]

propAllMinutesRange :: QC.Property
propAllMinutesRange =
  case evaluateMinuteT AllMinutes of
    Left err -> QC.counterexample err False
    Right xs -> xs QC.=== [0..59]

propAtMinuteSingleton :: QC.Property
propAtMinuteSingleton =
  QC.forAll (QC.chooseInt (0, 59)) $ \m ->
    case evaluateMinuteT (AtMinute m) of
      Left err -> QC.counterexample err False
      Right xs -> xs QC.=== [m]

propExpressionsWithinBounds :: QC.Property
propExpressionsWithinBounds =
  QC.forAll genMinuteExpr $ \expr ->
    case evaluateMinuteT expr of
      Left err -> QC.counterexample ("expr=" <> show expr <> ": " <> err) False
      Right xs ->
        let message = "expr=" <> show expr <> " xs=" <> show xs
        in QC.counterexample message (withinBoundsInt 0 59 xs && strictlyAscending xs)

propParseCommaSeparated :: QC.Property
propParseCommaSeparated =
  QC.forAll (QC.listOf1 (QC.chooseInt (0, 59))) $ \mins ->
    let txt = T.intercalate "," (map (T.pack . show) mins)
        expected = sort (nub mins)
    in case parseMinutesText txt of
        Left err -> QC.counterexample err False
        Right expr ->
          case evaluateMinuteT expr of
            Left evalErr -> QC.counterexample evalErr False
            Right xs -> xs QC.=== expected

propInvalidMinuteRejected :: QC.Property
propInvalidMinuteRejected =
  QC.forAll invalidMinute $ \m ->
    case evaluateMinuteT (AtMinute m) of
      Left _ -> QC.property True
      Right xs ->
        QC.counterexample ("unexpected success: " <> show xs) False

propStepMinuteProgression :: QC.Property
propStepMinuteProgression =
  QC.forAll (QC.chooseInt (0, 59)) $ \start ->
    QC.forAll (QC.chooseInt (1, 30)) $ \step ->
      case evaluateMinuteT (StepMinute start step) of
        Left err -> QC.counterexample err False
        Right xs ->
          QC.counterexample ("step output=" <> show xs)
            (withinBoundsInt 0 59 xs && isStepProgression start step xs)

genMinuteExpr :: QC.Gen MinutesExprT
genMinuteExpr = QC.sized go
  where
    go n
      | n <= 1 = QC.oneof base
      | otherwise = QC.frequency
          [ (3, QC.oneof base)
          , (2, genRange)
          , (2, genStep)
          , (2, genUnion (n - 1))
          ]
    base =
      [ pure AllMinutes
      , AtMinute <$> QC.chooseInt (0, 59)
      ]
    genRange = do
      start <- QC.chooseInt (0, 59)
      end <- QC.chooseInt (start, 59)
      pure (RangeMinute start end)
    genStep = do
      start <- QC.chooseInt (0, 59)
      step <- QC.chooseInt (1, 30)
      pure (StepMinute start step)
    genUnion depth = do
      let sub = max 0 depth
      left <- go sub
      right <- go sub
      pure (UnionMinute left right)

invalidMinute :: QC.Gen Int
invalidMinute = QC.oneof [QC.chooseInt (-30, -1), QC.chooseInt (60, 120)]

isStepProgression :: Int -> Int -> [Int] -> Bool
isStepProgression _ _ [] = True
isStepProgression start step (x0 : rest) =
  x0 == start && all (== step) (consecutiveDiffs x0 rest)
  where
    consecutiveDiffs _ [] = []
    consecutiveDiffs prev (y : ys) = (y - prev) : consecutiveDiffs y ys