packages feed

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

{-# LANGUAGE OverloadedStrings #-}

module AWS.EventBridge.YearsSpec (tests) where

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

manualTests :: TestTree
manualTests = testGroup "manual"
  [ testCase "parse *" $ parseYearsText "*" @?= Right AllYears
  , testCase "parse literal year" $ parseYearsText "2025" @?= Right (AtYear 2025)
  , testCase "parse range" $ parseYearsText "1975-1980" @?= Right (RangeYear 1975 1980)
  , testCase "parse step" $ parseYearsText "2000/5" @?= Right (StepYear 2000 5)
  , testCase "parse union" $ do
      expr <- expectParseWith "years" parseYearsText "1970,2020,2199"
      result <- expectEvalWith "years" evaluateYearT expr
      result @?= [1970,2020,2199]
    , testCase "parse union with duplicates collapses" $ do
      expr <- expectParseWith "years" parseYearsText "1970,1970,2199"
      result <- expectEvalWith "years" evaluateYearT expr
      result @?= [1970,2199]
  , testCase "evaluate AllYears" $ expectEvalEquals [1970..2199] (evaluateYearT AllYears)
  , testCase "invalid step start fails" $
      assertLeft (evaluateYearT (StepYear 1969 2))
  , testCase "invalid step increment fails" $
      assertLeft (evaluateYearT (StepYear 1980 0))
  , testCase "literal out of range fails" $
      assertLeft (evaluateYearT (AtYear 2250))
    , testCase "descending range rejected" $
      assertLeft (evaluateYearT (RangeYear 2025 2020))
  ]

propertyTests :: TestTree
propertyTests = testGroup "properties"
  [ QC.testProperty "AllYears covers 1970..2199" propAllYearsRange
  , QC.testProperty "AtYear returns singleton" propAtYearSingleton
  , QC.testProperty "Generated expressions evaluate within bounds" propExpressionsWithinBounds
  , QC.testProperty "Parse of comma list matches sorted unique" propParseCommaSeparated
  , QC.testProperty "Invalid literal year rejected" propInvalidYearRejected
  , QC.testProperty "Step year forms arithmetic progression" propStepYearProgression
    , QC.testProperty "Range year evaluates inclusively" propRangeYearInclusive
    , QC.testProperty "Descending range rejected" propRangeYearRejectsDescending
    , QC.testProperty "Union year combines operands" propUnionYearCombines
  ]

propAllYearsRange :: QC.Property
propAllYearsRange =
  case evaluateYearT AllYears of
    Left err -> QC.counterexample err False
    Right xs -> xs QC.=== [1970..2199]

propAtYearSingleton :: QC.Property
propAtYearSingleton =
  QC.forAll (QC.chooseInt (1970, 2199)) $ \y ->
    case evaluateYearT (AtYear y) of
      Left err -> QC.counterexample err False
      Right xs -> xs QC.=== [y]

propExpressionsWithinBounds :: QC.Property
propExpressionsWithinBounds =
  QC.forAll genYearExpr $ \expr ->
    case evaluateYearT 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 1970 2199 xs && strictlyAscending xs)

propParseCommaSeparated :: QC.Property
propParseCommaSeparated =
  QC.forAll (QC.listOf1 (QC.chooseInt (1970, 2199))) $ \years ->
    let txt = T.intercalate "," (map (T.pack . show) years)
        expected = sort (nub years)
    in case parseYearsText txt of
        Left err -> QC.counterexample err False
        Right expr ->
          case evaluateYearT expr of
            Left evalErr -> QC.counterexample evalErr False
            Right xs -> xs QC.=== expected

propInvalidYearRejected :: QC.Property
propInvalidYearRejected =
  QC.forAll invalidYear $ \y ->
    case evaluateYearT (AtYear y) of
      Left _ -> QC.property True
      Right xs -> QC.counterexample ("unexpected success: " <> show xs) False

propRangeYearInclusive :: QC.Property
propRangeYearInclusive =
  QC.forAll (QC.chooseInt (1970, 2199)) $ \start ->
    QC.forAll (QC.chooseInt (start, 2199)) $ \end ->
      case evaluateYearT (RangeYear start end) of
        Left err -> QC.counterexample err False
        Right xs -> xs QC.=== [start..end]

propRangeYearRejectsDescending :: QC.Property
propRangeYearRejectsDescending =
  QC.forAll (QC.chooseInt (1971, 2199)) $ \start ->
    QC.forAll (QC.chooseInt (1970, start - 1)) $ \end ->
      case evaluateYearT (RangeYear start end) of
        Left _ -> QC.property True
        Right xs -> QC.counterexample ("unexpected success: " <> show xs) False

propStepYearProgression :: QC.Property
propStepYearProgression =
  QC.forAll (QC.chooseInt (1970, 2199)) $ \start ->
    QC.forAll (QC.chooseInt (1, 40)) $ \step ->
      case evaluateYearT (StepYear start step) of
        Left err -> QC.counterexample err False
        Right xs ->
          QC.counterexample ("step output=" <> show xs)
            (withinBoundsInt 1970 2199 xs && isStepProgression start step xs)

propUnionYearCombines :: QC.Property
propUnionYearCombines =
  QC.forAll genYearExpr $ \lhs ->
    QC.forAll genYearExpr $ \rhs ->
      case ( evaluateYearT lhs
           , evaluateYearT rhs
           , evaluateYearT (UnionYear 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

genYearExpr :: QC.Gen YearsExprT
genYearExpr = 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 AllYears
      , AtYear <$> QC.chooseInt (1970, 2199)
      ]
    genRange = do
      start <- QC.chooseInt (1970, 2199)
      end <- QC.chooseInt (start, 2199)
      pure (RangeYear start end)
    genStep = do
      start <- QC.chooseInt (1970, 2199)
      step <- QC.chooseInt (1, 40)
      pure (StepYear start step)
    genUnion depth = do
      let sub = max 0 depth
      left <- go sub
      right <- go sub
      pure (UnionYear left right)

invalidYear :: QC.Gen Int
invalidYear = QC.oneof [QC.chooseInt (1800, 1969), QC.chooseInt (2200, 2300)]

-- Helpers

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