packages feed

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

{-# LANGUAGE OverloadedStrings #-}

module AWS.EventBridge.MonthsSpec (tests) where

import AWS.EventBridge.Months
import Data.List (nub, sort)
import qualified Data.Set as Set
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
  , expectEvalEquals
  , expectEvalWith
  , expectParseWith
  , strictlyAscending
  , withinBoundsInt
  )

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

manualTests :: TestTree
manualTests = testGroup "manual"
  [ testCase "parse *" $ parseMonthsText "*" @?= Right AllMonths
  , testCase "parse numeric literal" $ parseMonthsText "3" @?= Right (AtMonth 3)
  , testCase "parse month name" $ parseMonthsText "Feb" @?= Right (AtMonth 2)
  , testCase "parse range" $ parseMonthsText "3-5" @?= Right (RangeMonth 3 5)
  , testCase "parse step" $ parseMonthsText "6/2" @?= Right (StepMonth 6 2)
  , testCase "parse mixed union" $ do
      expr <- expectParseWith "months" parseMonthsText "Jan,4,Aug"
      result <- expectEvalWith "months" evaluateMonthT expr
      result @?= [1,4,8]
  , testCase "parse union with duplicates collapses" $ do
      expr <- expectParseWith "months" parseMonthsText "Jan,jan,1"
      result <- expectEvalWith "months" evaluateMonthT expr
      result @?= [1]
  , testCase "evaluate AllMonths" $ expectEvalEquals [1..12] (evaluateMonthT AllMonths)
  , testCase "invalid month literal fails" $ assertLeft (evaluateMonthT (AtMonth 20))
  , testCase "invalid month name fails" $
      case parseMonthsText "Foo" of
        Left _ -> pure ()
        Right val -> fail ("unexpected success: " <> show val)
  , testCase "descending range rejected" $
      assertLeft (evaluateMonthT (RangeMonth 5 3))
  , testCase "step start out of range fails" $
      assertLeft (evaluateMonthT (StepMonth 0 2))
  , testCase "step increment zero fails" $
      assertLeft (evaluateMonthT (StepMonth 5 0))
  ]

propertyTests :: TestTree
propertyTests = testGroup "properties"
  [ QC.testProperty "AllMonths covers 1..12" propAllMonthsRange
  , QC.testProperty "AtMonth returns singleton" propAtMonthSingleton
  , QC.testProperty "Generated expressions evaluate within bounds" propExpressionsWithinBounds
  , QC.testProperty "Parse of comma list matches sorted unique" propParseCommaSeparated
  , QC.testProperty "Named months parse to expected indices" propNamedMonthsParse
  , QC.testProperty "Invalid literal month rejected" propInvalidMonthRejected
  , QC.testProperty "Range month evaluates inclusively" propRangeMonthInclusive
  , QC.testProperty "Descending range rejected" propRangeMonthRejectsDescending
  , QC.testProperty "Step month forms arithmetic progression" propStepMonthProgression
  , QC.testProperty "Union month combines operands" propUnionMonthCombines
  ]

propAllMonthsRange :: QC.Property
propAllMonthsRange =
  case evaluateMonthT AllMonths of
    Left err -> QC.counterexample err False
    Right xs -> xs QC.=== [1..12]

propAtMonthSingleton :: QC.Property
propAtMonthSingleton =
  QC.forAll (QC.chooseInt (1, 12)) $ \m ->
    case evaluateMonthT (AtMonth m) of
      Left err -> QC.counterexample err False
      Right xs -> xs QC.=== [m]

propExpressionsWithinBounds :: QC.Property
propExpressionsWithinBounds =
  QC.forAll genMonthExpr $ \expr ->
    case evaluateMonthT 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 1 12 xs && strictlyAscending xs)

propParseCommaSeparated :: QC.Property
propParseCommaSeparated =
  QC.forAll (QC.listOf1 genMonthTokenText) $ \monthsTxt ->
    let txt = T.intercalate "," monthsTxt
        expected = sort (nub (map parseTokenUnsafe monthsTxt))
    in case parseMonthsText txt of
        Left err -> QC.counterexample err False
        Right expr ->
          case evaluateMonthT expr of
            Left evalErr -> QC.counterexample evalErr False
            Right xs -> xs QC.=== expected

propNamedMonthsParse :: QC.Property
propNamedMonthsParse =
  QC.forAll genNamedMonthText $ \nameTxt ->
    case parseMonthsText nameTxt of
      Left err -> QC.counterexample err False
      Right expr ->
        case expr of
          AtMonth m ->
            let expected = parseTokenUnsafe nameTxt
            in QC.counterexample ("expected " <> show expected <> ", got " <> show m) (m == expected)
          other -> QC.counterexample ("expected AtMonth, got " <> show other) False

propInvalidMonthRejected :: QC.Property
propInvalidMonthRejected =
  QC.forAll invalidMonth $ \m ->
    case evaluateMonthT (AtMonth m) of
      Left _ -> QC.property True
      Right xs -> QC.counterexample ("unexpected success: " <> show xs) False

propRangeMonthInclusive :: QC.Property
propRangeMonthInclusive =
  QC.forAll (QC.chooseInt (1, 12)) $ \start ->
    QC.forAll (QC.chooseInt (start, 12)) $ \end ->
      case evaluateMonthT (RangeMonth start end) of
        Left err -> QC.counterexample err False
        Right xs -> xs QC.=== [start..end]

propRangeMonthRejectsDescending :: QC.Property
propRangeMonthRejectsDescending =
  QC.forAll (QC.chooseInt (2, 12)) $ \start ->
    QC.forAll (QC.chooseInt (1, start - 1)) $ \end ->
      case evaluateMonthT (RangeMonth start end) of
        Left _ -> QC.property True
        Right xs -> QC.counterexample ("unexpected success: " <> show xs) False

propStepMonthProgression :: QC.Property
propStepMonthProgression =
  QC.forAll (QC.chooseInt (1, 12)) $ \start ->
    QC.forAll (QC.chooseInt (1, 6)) $ \step ->
      case evaluateMonthT (StepMonth start step) of
        Left err -> QC.counterexample err False
        Right xs ->
          QC.counterexample ("step output=" <> show xs)
            (withinBoundsInt 1 12 xs && isStepProgression start step xs)

propUnionMonthCombines :: QC.Property
propUnionMonthCombines =
  QC.forAll genMonthExpr $ \lhs ->
    QC.forAll genMonthExpr $ \rhs ->
      case ( evaluateMonthT lhs
           , evaluateMonthT rhs
           , evaluateMonthT (UnionMonth lhs rhs)
           ) of
        (Right xs, Right ys, Right zs) ->
          let expected = Set.toAscList (Set.fromList (xs ++ ys))
              msg = "lhs=" <> show lhs <> " rhs=" <> show rhs <> " expected=" <> show expected <> " actual=" <> show zs
          in QC.counterexample msg (zs == expected)
        (Left err, _, _) -> QC.counterexample ("lhs failed: " <> err) False
        (_, Left err, _) -> QC.counterexample ("rhs failed: " <> err) False
        (_, _, Left err) -> QC.counterexample ("union failed: " <> err) False

-- Generators

genMonthExpr :: QC.Gen MonthsExprT
genMonthExpr = 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 AllMonths
      , AtMonth <$> QC.chooseInt (1, 12)
      ]
    genRange = do
      start <- QC.chooseInt (1, 12)
      end <- QC.chooseInt (start, 12)
      pure (RangeMonth start end)
    genStep = do
      start <- QC.chooseInt (1, 12)
      step <- QC.chooseInt (1, 6)
      pure (StepMonth start step)
    genUnion depth = do
      let sub = max 0 depth
      left <- go sub
      right <- go sub
      pure (UnionMonth left right)

genMonthTokenText :: QC.Gen T.Text
genMonthTokenText = QC.oneof
  [ T.pack . show <$> QC.chooseInt (1, 12)
  , QC.elements (map T.pack ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"])
  ]

genNamedMonthText :: QC.Gen T.Text
genNamedMonthText =
  QC.elements (map T.pack ["JAN","feb","Mar","apr","may","JUN","jul","AuG","sep","OCT","nov","dec"])

invalidMonth :: QC.Gen Int
invalidMonth = QC.oneof [QC.chooseInt (-12, 0), QC.chooseInt (13, 36)]

-- Helpers

parseTokenUnsafe :: T.Text -> Int
parseTokenUnsafe t =
  case parseAt (T.strip t) of
    Right m -> m
    Left err -> error ("unexpected parse failure: " <> err)
  where
    parseAt txt = do
      expr <- parseMonthsText txt
      case expr of
        AtMonth m -> Right m
        _         -> Left ("expected AtMonth for token " <> show txt)

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