packages feed

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

{-# LANGUAGE OverloadedStrings #-}

module AWS.EventBridge.DayOfMonthSpec (tests) where

import AWS.EventBridge.DayOfMonth
import Data.Time.Calendar (fromGregorian, gregorianMonthLength)
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-month"
    [ unitTests
    , propertyTests
    ]

unitTests :: TestTree
unitTests = testGroup "manual"
  [ testCase "parse L" $ parseDayOfMonthText "L" @?= Right DomLast
  , testCase "parse L-2" $ parseDayOfMonthText "L-2" @?= Right (DomLastOffset 2)
  , testCase "parse 15W" $ parseDayOfMonthText "15W" @?= Right (DomClosestWeekday 15)
    , testCase "parse numeric union" $ do
      expr <- expectParseWith "day-of-month" parseDayOfMonthText "1,5,10-12"
      result <- expectEvalWith "day-of-month" (evaluateDayOfMonthT 2024 2) expr
      result @?= [1,5,10,11,12]
  , testCase "parse ? cannot union" $ assertLeft (parseDayOfMonthText "?,5")
  , testCase "evaluate DomLast" $ do
      let dim = gregorianMonthLength 2025 3
      result <- expectEvalWith "day-of-month" (evaluateDayOfMonthT 2025 3) DomLast
      result @?= [dim]
  , testCase "closestWeekday Saturday at start" $
      closestWeekday 2025 3 1 @?= Just 3
  , testCase "closestWeekday Sunday at end" $
      closestWeekday 2021 1 31 @?= Just 29
  , testCase "lastWeekday handles weekend" $
      lastWeekday 2021 1 @?= 29
  , testCase "DomLastOffset clamps" $ do
      result <- expectEvalWith "day-of-month" (evaluateDayOfMonthT 2024 5) (DomLastOffset 2)
      result @?= [29]
  , testCase "DomLastOffset rejects overshoot" $
      assertLeft (evaluateDayOfMonthT 2024 2 (DomLastOffset 29))
  ]

propertyTests :: TestTree
propertyTests = testGroup "properties"
  [ QC.testProperty "closestWeekday stays within bounds and weekdays" propClosestWeekdayWithinBounds
  , QC.testProperty "closestWeekday rejects out-of-range days" propClosestWeekdayRejectsOutOfRange
  , QC.testProperty "lastWeekday produces weekday inside month" propLastWeekdayIsWeekday
  , QC.testProperty "DomLast evaluates to final day" propDomLastMatchesMonthLength
  , QC.testProperty "DomLastOffset yields expected day when within month" propDomLastOffsetWithinMonth
  , QC.testProperty "DomLastOffset rejects offsets >= month length" propDomLastOffsetTooLarge
  , QC.testProperty "Numeric expressions produce in-range ascending days" propNumericExpressionsWithinBounds
  , QC.testProperty "DomAny evaluates to empty set" propDomAnyEmpty
  ]

propClosestWeekdayWithinBounds :: QC.Property
propClosestWeekdayWithinBounds =
  QC.forAll genYearMonth $ \(y, m) ->
    QC.forAll (QC.chooseInt (1, gregorianMonthLength y m)) $ \d ->
      let result = closestWeekday y m d
          dim = gregorianMonthLength y m
      in case result of
          Nothing -> QC.counterexample "expected Just" False
          Just w ->
            let (_, _, dow) = toWeekDate (fromGregorian y m w)
            in QC.counterexample (failureMsg y m d w dow)
                 (w >= 1 && w <= dim && dow >= 1 && dow <= 5 && abs (w - d) <= 2)

propClosestWeekdayRejectsOutOfRange :: QC.Property
propClosestWeekdayRejectsOutOfRange =
  QC.forAll genYearMonth $ \(y, m) ->
    QC.forAll outOfRangeDay $ \d ->
      closestWeekday y m d QC.=== Nothing

propLastWeekdayIsWeekday :: QC.Property
propLastWeekdayIsWeekday =
  QC.forAll genYearMonth $ \(y, m) ->
    let lw = lastWeekday y m
        dim = gregorianMonthLength y m
        (_, _, dow) = toWeekDate (fromGregorian y m lw)
    in QC.counterexample (failureMsg y m dim lw dow)
         (lw >= 1 && lw <= dim && dow >= 1 && dow <= 5)

propDomLastMatchesMonthLength :: QC.Property
propDomLastMatchesMonthLength =
  QC.forAll genYearMonth $ \(y, m) ->
    case evaluateDayOfMonthT y m DomLast of
      Left err -> QC.counterexample err False
      Right xs ->
        let dim = gregorianMonthLength y m
        in QC.counterexample ("expected " <> show [dim] <> ", got " <> show xs)
             (xs == [dim])

propDomLastOffsetWithinMonth :: QC.Property
propDomLastOffsetWithinMonth =
  QC.forAll genDomLastOffsetWithin $ \(y, m, off) ->
    case evaluateDayOfMonthT y m (DomLastOffset off) of
      Left err -> QC.counterexample err False
      Right xs ->
        let dim = gregorianMonthLength y m
            expected = dim - off
        in QC.counterexample ("expected " <> show [expected] <> ", got " <> show xs)
             (xs == [expected])

propDomLastOffsetTooLarge :: QC.Property
propDomLastOffsetTooLarge =
  QC.forAll genDomLastOffsetTooLarge $ \(y, m, off) ->
    case evaluateDayOfMonthT y m (DomLastOffset off) of
      Left _ -> QC.property True
      Right xs ->
        let dim = gregorianMonthLength y m
        in QC.counterexample
             ("unexpected success " <> show xs <> " for month length " <> show dim <> " and offset " <> show off)
             False

propNumericExpressionsWithinBounds :: QC.Property
propNumericExpressionsWithinBounds =
  QC.forAll genYearMonth $ \(y, m) ->
    QC.forAll genNumericExpr $ \expr ->
      case evaluateDayOfMonthT y m expr of
        Left err -> QC.counterexample ("expr=" <> show expr <> ": " <> err) False
        Right xs ->
          let dim = gregorianMonthLength y m
              message = "expr=" <> show expr <> " xs=" <> show xs <> " dim=" <> show dim
          in QC.counterexample message (withinBoundsInt 1 dim xs && strictlyAscending xs)

propDomAnyEmpty :: QC.Property
propDomAnyEmpty =
  QC.forAll genYearMonth $ \(y, m) ->
    case evaluateDayOfMonthT y m DomAny of
      Left err -> QC.counterexample err False
      Right xs -> QC.counterexample ("xs=" <> show xs) (null xs)

genYearMonth :: QC.Gen (Integer, Int)
genYearMonth = do
  year <- QC.chooseInt (1900, 2100)
  month <- QC.chooseInt (1, 12)
  pure (fromIntegral year, month)

outOfRangeDay :: QC.Gen Int
outOfRangeDay = QC.oneof [QC.chooseInt (-5, 0), QC.chooseInt (32, 40)]

genDomLastOffsetWithin :: QC.Gen (Integer, Int, Int)
genDomLastOffsetWithin = do
  (y, m) <- genYearMonth
  let dim = gregorianMonthLength y m
  off <- QC.chooseInt (1, dim - 1)
  pure (y, m, off)

genDomLastOffsetTooLarge :: QC.Gen (Integer, Int, Int)
genDomLastOffsetTooLarge = do
  (y, m) <- genYearMonth
  let dim = gregorianMonthLength y m
  off <- QC.chooseInt (dim, dim + 10)
  pure (y, m, off)

genNumericExpr :: QC.Gen DayOfMonthExprT
genNumericExpr = 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 DomAll
      , DomAt <$> QC.chooseInt (1, 31)
      ]
    genRange = do
      start <- QC.chooseInt (1, 31)
      end <- QC.chooseInt (start, 31)
      pure (DomRange start end)
    genStep = do
      start <- QC.chooseInt (1, 31)
      step <- QC.chooseInt (1, 10)
      pure (DomStep start step)
    genUnion depth = do
      let sub = max 0 depth
      left <- go sub
      right <- go sub
      pure (DomUnion left right)

failureMsg :: Integer -> Int -> Int -> Int -> Int -> String
failureMsg y m d w dow =
  "y=" <> show y <> " m=" <> show m <> " requested=" <> show d <>
  " resolved=" <> show w <> " weekday=" <> show dow