packages feed

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

{-# LANGUAGE OverloadedStrings #-}

module AWS.EventBridge.HoursSpec (tests) where

import AWS.EventBridge.Hours
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 "hours"
    [ manualTests
    , propertyTests
    ]

manualTests :: TestTree
manualTests = testGroup "manual"
  [ testCase "parse *" $ parseHoursText "*" @?= Right AllHours
  , testCase "parse literal hour" $ parseHoursText "7" @?= Right (AtHour 7)
  , testCase "parse range" $ parseHoursText "8-12" @?= Right (RangeHour 8 12)
  , testCase "parse step" $ parseHoursText "3/4" @?= Right (StepHour 3 4)
  , testCase "parse union" $ do
      expr <- expectParseWith "hours" parseHoursText "1,12,18"
      result <- expectEvalWith "hours" evaluateHourT expr
      result @?= [1,12,18]
  , testCase "evaluate AllHours" $ do
      result <- expectEvalWith "hours" evaluateHourT AllHours
      result @?= [0..23]
  , testCase "invalid step start fails" $
      assertLeft (evaluateHourT (StepHour 25 2))
  , testCase "invalid step increment fails" $
      assertLeft (evaluateHourT (StepHour 3 0))
  ]

propertyTests :: TestTree
propertyTests = testGroup "properties"
  [ QC.testProperty "AllHours covers 0..23" propAllHoursRange
  , QC.testProperty "AtHour returns singleton" propAtHourSingleton
  , QC.testProperty "Generated expressions evaluate within bounds" propExpressionsWithinBounds
  , QC.testProperty "Parse of comma list matches sorted unique" propParseCommaSeparated
  , QC.testProperty "Invalid literal hour rejected" propInvalidHourRejected
  , QC.testProperty "Step hour forms arithmetic progression" propStepHourProgression
  ]

propAllHoursRange :: QC.Property
propAllHoursRange =
  case evaluateHourT AllHours of
    Left err -> QC.counterexample err False
    Right xs -> xs QC.=== [0..23]

propAtHourSingleton :: QC.Property
propAtHourSingleton =
  QC.forAll (QC.chooseInt (0, 23)) $ \h ->
    case evaluateHourT (AtHour h) of
      Left err -> QC.counterexample err False
      Right xs -> xs QC.=== [h]

propExpressionsWithinBounds :: QC.Property
propExpressionsWithinBounds =
  QC.forAll genHourExpr $ \expr ->
    case evaluateHourT 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 23 xs && strictlyAscending xs)

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

propInvalidHourRejected :: QC.Property
propInvalidHourRejected =
  QC.forAll invalidHour $ \h ->
    case evaluateHourT (AtHour h) of
      Left _ -> QC.property True
      Right xs ->
        QC.counterexample ("unexpected success: " <> show xs) False

propStepHourProgression :: QC.Property
propStepHourProgression =
  QC.forAll (QC.chooseInt (0, 23)) $ \start ->
    QC.forAll (QC.chooseInt (1, 12)) $ \step ->
      case evaluateHourT (StepHour start step) of
        Left err -> QC.counterexample err False
        Right xs ->
          QC.counterexample ("step output=" <> show xs)
            (withinBoundsInt 0 23 xs && isStepProgression start step xs)

genHourExpr :: QC.Gen HoursExprT
genHourExpr = 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 AllHours
      , AtHour <$> QC.chooseInt (0, 23)
      ]
    genRange = do
      start <- QC.chooseInt (0, 23)
      end <- QC.chooseInt (start, 23)
      pure (RangeHour start end)
    genStep = do
      start <- QC.chooseInt (0, 23)
      step <- QC.chooseInt (1, 12)
      pure (StepHour start step)
    genUnion depth = do
      let sub = max 0 depth
      left <- go sub
      right <- go sub
      pure (UnionHour left right)

invalidHour :: QC.Gen Int
invalidHour = QC.oneof [QC.chooseInt (-24, -1), QC.chooseInt (24, 96)]

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