aws-eventbridge-cron-0.1.0.0: test/AWS/EventBridge/DayOfWeekSpec.hs
{-# LANGUAGE OverloadedStrings #-}
module AWS.EventBridge.DayOfWeekSpec (tests) where
import AWS.EventBridge.DayOfWeek
import qualified Data.Set as Set
import qualified Data.Text as T
import Data.Time.Calendar (gregorianMonthLength, fromGregorian)
import Data.Time.Calendar.WeekDate (toWeekDate)
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 "day-of-week"
[ manualTests
, propertyTests
]
manualTests :: TestTree
manualTests = testGroup "manual"
[ testCase "parse *" $ parseDayOfWeekText "*" @?= Right DowAll
, testCase "parse ?" $ parseDayOfWeekText "?" @?= Right DowAny
, testCase "parse named literal" $ parseDayOfWeekText "Mon" @?= Right (DowAt 2)
, testCase "parse numeric literal" $ parseDayOfWeekText "3" @?= Right (DowAt 3)
, testCase "parse range" $ parseDayOfWeekText "MON-FRI" @?= Right (DowRange 2 6)
, testCase "parse single-day range" $ parseDayOfWeekText "FRI-FRI" @?= Right (DowRange 6 6)
, testCase "parse nth" $ parseDayOfWeekText "TUE#2" @?= Right (DowNth 3 2)
, testCase "parse numeric nth" $ parseDayOfWeekText "5#1" @?= Right (DowNth 5 1)
, testCase "parse union" $ parseDayOfWeekText "SUN,WED" @?= Right (DowUnion (DowAt 1) (DowAt 4))
, testCase "parse named alias" $ parseDayOfWeekText "Thur" @?= Right (DowAt 5)
, testCase "? cannot mix" $
case parseDayOfWeekText "?,MON" of
Left _ -> pure ()
Right val -> fail ("unexpected success: " <> show val)
, testCase "# cannot mix" $
case parseDayOfWeekText "MON#1,FRI" of
Left _ -> pure ()
Right val -> fail ("unexpected success: " <> show val)
, testCase "invalid day name fails" $
case parseDayOfWeekText "FUNDAY" of
Left _ -> pure ()
Right val -> fail ("unexpected success: " <> show val)
, testCase "evaluate MON in April 2025" $ do
expr <- expectParseWith "dow" parseDayOfWeekText "MON"
result <- expectEvalWith "dow" (evaluateDayOfWeekT 2025 4) expr
result @?= [7,14,21,28]
, testCase "nth occurrence absent yields empty" $ do
expr <- expectParseWith "dow" parseDayOfWeekText "SUN#5"
result <- expectEvalWith "dow" (evaluateDayOfWeekT 2025 2) expr
result @?= []
, testCase "nth occurrence resolves" $ do
expr <- expectParseWith "dow" parseDayOfWeekText "THU#3"
result <- expectEvalWith "dow" (evaluateDayOfWeekT 2025 5) expr
result @?= [15]
, testCase "union duplicates collapse" $ do
expr <- expectParseWith "dow" parseDayOfWeekText "MON,mon"
result <- expectEvalWith "dow" (evaluateDayOfWeekT 2025 5) expr
result @?= [5,12,19,26]
, testCase "invalid occurrence rejected" $
assertLeft (evaluateDayOfWeekT 2025 5 (DowNth 3 6))
, testCase "invalid day literal rejected" $
assertLeft (evaluateDayOfWeekT 2025 5 (DowAt 9))
]
propertyTests :: TestTree
propertyTests = testGroup "properties"
[ QC.testProperty "All days cover month" propAllCoversMonth
, QC.testProperty "Any produces empty" propAnyEmpty
, QC.testProperty "At day matches weekday" propAtMatchesWeekday
, QC.testProperty "Range equals union of days" propRangeMatchesUnion
, QC.testProperty "Range with same endpoints equals literal" propRangeDegenerateAsLiteral
, QC.testProperty "Union combines unique days" propUnionCombines
, QC.testProperty "Union with duplicates collapses" propUnionDeduplicates
, QC.testProperty "Nth occurrence matches calendar" propNthMatchesCalendar
, QC.testProperty "Generated expressions evaluate within bounds" propExpressionsWithinBounds
, QC.testProperty "Named tokens parse case-insensitively" propNamedParseCaseInsensitive
, QC.testProperty "Multiple # expressions rejected" propMultipleHashRejected
]
propAllCoversMonth :: QC.Property
propAllCoversMonth =
QC.forAll genYearMonth $ \(y, m) ->
case evaluateDayOfWeekT y m DowAll of
Left err -> QC.counterexample err False
Right xs -> xs QC.=== [1 .. gregorianMonthLength y m]
propAnyEmpty :: QC.Property
propAnyEmpty =
QC.forAll genYearMonth $ \(y, m) ->
case evaluateDayOfWeekT y m DowAny of
Left err -> QC.counterexample err False
Right xs -> xs QC.=== []
propAtMatchesWeekday :: QC.Property
propAtMatchesWeekday =
QC.forAll genYearMonth $ \(y, m) ->
QC.forAll (QC.chooseInt (1, 7)) $ \dow ->
case evaluateDayOfWeekT y m (DowAt dow) of
Left err -> QC.counterexample err False
Right xs ->
let msg = "y=" <> show y <> " m=" <> show m <> " dow=" <> show dow <> " xs=" <> show xs
in QC.counterexample msg (all (\d -> awsDow y m d == dow) xs)
propRangeMatchesUnion :: QC.Property
propRangeMatchesUnion =
QC.forAll genYearMonth $ \(y, m) ->
QC.forAll genRangeBounds $ \(start, end) ->
let expected = Set.toAscList (Set.fromList (concatMap (expectedDays y m) [start .. end]))
in case evaluateDayOfWeekT y m (DowRange start end) of
Left err -> QC.counterexample err False
Right xs -> xs QC.=== expected
propUnionCombines :: QC.Property
propUnionCombines =
QC.forAll genYearMonth $ \(y, m) ->
QC.forAll genDayPair $ \(a, b) ->
let expected = Set.toAscList (Set.fromList (expectedDays y m a ++ expectedDays y m b))
in case evaluateDayOfWeekT y m (DowUnion (DowAt a) (DowAt b)) of
Left err -> QC.counterexample err False
Right xs -> xs QC.=== expected
propRangeDegenerateAsLiteral :: QC.Property
propRangeDegenerateAsLiteral =
QC.forAll genYearMonth $ \(y, m) ->
QC.forAll (QC.chooseInt (1, 7)) $ \dow ->
case evaluateDayOfWeekT y m (DowRange dow dow) of
Left err -> QC.counterexample err False
Right xs ->
case evaluateDayOfWeekT y m (DowAt dow) of
Left err -> QC.counterexample err False
Right expected -> xs QC.=== expected
propUnionDeduplicates :: QC.Property
propUnionDeduplicates =
QC.forAll genYearMonth $ \(y, m) ->
QC.forAll (QC.chooseInt (1, 7)) $ \dow ->
let expr = DowUnion (DowAt dow) (DowAt dow)
in case evaluateDayOfWeekT y m expr of
Left err -> QC.counterexample err False
Right xs ->
case evaluateDayOfWeekT y m (DowAt dow) of
Left err -> QC.counterexample err False
Right expected -> xs QC.=== expected
propNthMatchesCalendar :: QC.Property
propNthMatchesCalendar =
QC.forAll genYearMonth $ \(y, m) ->
QC.forAll (QC.chooseInt (1, 7)) $ \dow ->
QC.forAll (QC.chooseInt (1, 5)) $ \nth ->
let occurrences = expectedDays y m dow
in case evaluateDayOfWeekT y m (DowNth dow nth) of
Left err -> QC.counterexample err False
Right xs ->
case drop (nth - 1) occurrences of
(d : _) -> xs QC.=== [d]
[] -> xs QC.=== []
propExpressionsWithinBounds :: QC.Property
propExpressionsWithinBounds =
QC.forAll genYearMonth $ \(y, m) ->
QC.forAll genDayOfWeekExpr $ \expr ->
case evaluateDayOfWeekT y m expr of
Left err -> QC.counterexample ("expr=" <> show expr <> ": " <> err) False
Right xs ->
let dim = gregorianMonthLength y m
msg = "expr=" <> show expr <> " xs=" <> show xs
in QC.counterexample msg (withinBoundsInt 1 dim xs && strictlyAscending xs)
propNamedParseCaseInsensitive :: QC.Property
propNamedParseCaseInsensitive =
QC.forAll (QC.elements namedTokenCases) $ \(token, expected) ->
case parseDayOfWeekText token of
Left err -> QC.counterexample err False
Right expr -> expr QC.=== expected
propMultipleHashRejected :: QC.Property
propMultipleHashRejected =
QC.forAll genYearMonth $ \(y, m) ->
let candidate = "MON#1,TUE#2"
in case parseDayOfWeekText candidate of
Left _ -> QC.property True
Right expr ->
let eval = evaluateDayOfWeekT y m expr
in QC.counterexample ("unexpected success: " <> show expr <> ", eval=" <> show eval) False
-- Generators
genYearMonth :: QC.Gen (Integer, Int)
genYearMonth = do
year <- QC.chooseInteger (1970, 2199)
month <- QC.chooseInt (1, 12)
pure (year, month)
genRangeBounds :: QC.Gen (Int, Int)
genRangeBounds = do
start <- QC.chooseInt (1, 7)
end <- QC.chooseInt (start, 7)
pure (start, end)
genDayPair :: QC.Gen (Int, Int)
genDayPair = do
a <- QC.chooseInt (1, 7)
b <- QC.chooseInt (1, 7)
pure (a, b)
genDayOfWeekExpr :: QC.Gen DayOfWeekExprT
genDayOfWeekExpr = QC.sized go
where
go n
| n <= 1 = QC.oneof base
| otherwise = QC.frequency
[ (3, QC.oneof base)
, (2, do
start <- QC.chooseInt (1, 7)
end <- QC.chooseInt (start, 7)
pure (DowRange start end))
, (2, do
let sub = max 1 (n `div` 2)
DowUnion <$> go sub <*> go sub)
]
base =
[ pure DowAny
, pure DowAll
, DowAt <$> QC.chooseInt (1, 7)
, DowNth <$> QC.chooseInt (1, 7) <*> QC.chooseInt (1, 5)
]
namedTokenCases :: [(T.Text, DayOfWeekExprT)]
namedTokenCases =
[ ("sun", DowAt 1)
, ("MON", DowAt 2)
, ("tue", DowAt 3)
, ("Wed", DowAt 4)
, ("thurs", DowAt 5)
, ("FRI", DowAt 6)
, ("Sat", DowAt 7)
]
-- Helpers
awsDow :: Integer -> Int -> Int -> Int
awsDow y m d =
let (_, _, iso) = toWeekDate (fromGregorian y m d)
in (iso `mod` 7) + 1
expectedDays :: Integer -> Int -> Int -> [Int]
expectedDays y m dow =
[ dayOfMonth
| dayOfMonth <- [1 .. gregorianMonthLength y m]
, awsDow y m dayOfMonth == dow
]