packages feed

timeline-0.1.0.0: test/Data/TimelineTest.hs

{-# LANGUAGE ImportQualifiedPost #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedLists #-}
{-# LANGUAGE TypeApplications #-}

module Data.TimelineTest where

import Control.Applicative (liftA2)
import Control.Monad.Trans.Writer.CPS (execWriter, tell)
import Data.ByteString.Lazy qualified as LBS
import Data.Functor.WithIndex (imap)
import Data.Hashable
import Data.Map.Strict qualified as Map
import Data.Maybe (catMaybes, isJust, isNothing)
import Data.Set qualified as Set
import Data.Text (Text)
import Data.Text qualified as T
import Data.Text.Encoding qualified as T
import Data.Time
  ( UTCTime (UTCTime),
    addUTCTime,
    fromGregorian,
    secondsToDiffTime,
    secondsToNominalDiffTime,
  )
import Data.Timeline
import Data.Timeline.Hedgehog (gen)
import Hedgehog (MonadGen, forAll, property, (===))
import Hedgehog.Gen qualified as Gen
import Hedgehog.Range qualified as Range
import Test.Tasty (TestName, TestTree)
import Test.Tasty.Golden (goldenVsString)
import Test.Tasty.HUnit (testCase, (@=?), (@?=))
import Test.Tasty.Hedgehog (testProperty)

test_makeRecord :: [TestTree]
test_makeRecord =
  [ testProperty "it's always valid to have no effective-to" $ property $ do
      t <- forAll genUTCTime
      isJust (makeRecord @UTCTime @Int t Nothing 1) === True,
    testProperty "effectiveFrom must be less than effective-to" $ property $ do
      t1 <- forAll genUTCTime
      t2 <- forAll genUTCTime
      let tMin = min t1 t2
          tMax = max t1 t2
      if t1 == t2
        then isNothing (makeRecord @UTCTime @Int t1 (Just t2) 1) === True
        else isJust (makeRecord @UTCTime @Int tMin (Just tMax) 1) === True
  ]

test_fromRecords :: [TestTree]
test_fromRecords =
  [ testCase "empty input" $
      fromRecords @UTCTime @Int [] @?= Right (pure Nothing),
    testCase'
      "one change"
      [ makeRecord @UTCTime @Int
          (UTCTime (fromGregorian 2023 1 26) 7200)
          Nothing
          100
      ],
    testCase'
      "one change, with effective to"
      [ makeRecord @UTCTime @Int
          (UTCTime (fromGregorian 2023 1 26) 7200)
          (Just $ UTCTime (fromGregorian 2023 2 28) 0)
          100
      ],
    testCase'
      "all non-overlapping situations together"
      [ makeRecord @UTCTime @Int
          (UTCTime (fromGregorian 2023 1 26) 7200)
          (Just $ UTCTime (fromGregorian 2023 2 28) 0)
          100,
        makeRecord @UTCTime @Int
          (UTCTime (fromGregorian 2023 3 1) 0)
          (Just $ UTCTime (fromGregorian 2023 4 1) 0)
          200,
        makeRecord @UTCTime @Int
          (UTCTime (fromGregorian 2023 4 1) 0)
          Nothing
          300,
        makeRecord @UTCTime @Int
          (UTCTime (fromGregorian 2023 5 1) 0)
          Nothing
          400,
        makeRecord @UTCTime @Int
          (UTCTime (fromGregorian 2023 6 1) 0)
          Nothing
          500
      ],
    testCase'
      "overlaps"
      [ makeRecord @UTCTime @Int
          (UTCTime (fromGregorian 2023 1 26) 7200)
          (Just $ UTCTime (fromGregorian 2023 2 28) 0)
          100,
        makeRecord @UTCTime @Int
          (UTCTime (fromGregorian 2023 2 27) 0)
          (Just $ UTCTime (fromGregorian 2023 3 5) 0)
          200,
        makeRecord @UTCTime @Int
          (UTCTime (fromGregorian 2023 3 1) 0)
          Nothing
          300
      ],
    testCase'
      "two groups of overlap"
      [ makeRecord @UTCTime @Int
          (UTCTime (fromGregorian 2023 1 26) 7200)
          (Just $ UTCTime (fromGregorian 2023 2 28) 0)
          100,
        makeRecord @UTCTime @Int
          (UTCTime (fromGregorian 2023 3 1) 0)
          (Just $ UTCTime (fromGregorian 2023 3 5) 0)
          200,
        makeRecord @UTCTime @Int
          (UTCTime (fromGregorian 2023 3 3) 0)
          (Just $ UTCTime (fromGregorian 2023 3 4) 0)
          300,
        makeRecord @UTCTime @Int
          (UTCTime (fromGregorian 2023 3 6) 0)
          (Just $ UTCTime (fromGregorian 2023 3 8) 0)
          400,
        makeRecord @UTCTime @Int
          (UTCTime (fromGregorian 2023 3 8) 0)
          (Just $ UTCTime (fromGregorian 2023 3 15) 0)
          500,
        makeRecord @UTCTime @Int
          (UTCTime (fromGregorian 2023 3 14) 0)
          Nothing
          600
      ]
  ]
  where
    testCase' :: (Show a) => TestName -> [Maybe (Record UTCTime a)] -> TestTree
    testCase' name = buildGoldenTest pretty name . fromRecords . catMaybes

    pretty (Left overlaps) = prettyOverlaps overlaps
    pretty (Right timeline) = prettyTimeline timeline

test_peek :: [TestTree]
test_peek =
  [ testCase "constant" $ 1 @=? peek @UTCTime @Int (pure 1) (UTCTime (fromGregorian 2023 1 26) 0),
    testCase "before first change" $
      1
        @=? peek @UTCTime @Int
          (Timeline 1 (Map.singleton (UTCTime (fromGregorian 2023 1 16) 0) 2))
          (UTCTime (fromGregorian 2023 1 15) 0),
    testCase "between changes" $
      2
        @=? peek @UTCTime @Int
          ( Timeline
              1
              [ (UTCTime (fromGregorian 2023 1 16) 0, 2),
                (UTCTime (fromGregorian 2023 1 19) 0, 3)
              ]
          )
          (UTCTime (fromGregorian 2023 1 18) 0),
    testCase "at the last change" $
      3
        @=? peek @UTCTime @Int
          ( Timeline
              1
              [ (UTCTime (fromGregorian 2023 1 16) 0, 2),
                (UTCTime (fromGregorian 2023 1 19) 0, 3)
              ]
          )
          (UTCTime (fromGregorian 2023 1 19) 0),
    testCase "after all changes" $
      3
        @=? peek @UTCTime @Int
          ( Timeline
              1
              [ (UTCTime (fromGregorian 2023 1 16) 0, 2),
                (UTCTime (fromGregorian 2023 1 19) 0, 3)
              ]
          )
          (UTCTime (fromGregorian 2023 1 20) 0)
  ]

-- The purpose of the first testProperty is to verify this rewrite rule
-- suggested by hlint is legal. (We are testing if the Applicative instance is correct)
{-# ANN test_apply ("HLint: ignore Use <$>" :: String) #-}
test_apply :: [TestTree]
test_apply =
  [ testProperty "pure f <*> x === f <$> x" $
      property $ do
        timeline <- forAll $ gen genUTCTime (Gen.int (Range.linear 0 1000))
        fmap (+ 1) timeline === (pure (+ 1) <*> timeline),
    testProperty "combined timeline" $
      property $ do
        t1 <- forAll $ gen genUTCTime (Gen.int (Range.linear 0 100))
        t2 <- forAll $ gen genUTCTime (Gen.int (Range.linear (-100) 0))
        let combined = liftA2 (+) t1 t2
        -- check the size
        changes t1 `Set.union` changes t2 === changes combined
        -- for random time
        time <- forAll genUTCTime
        peek t1 time + peek t2 time === peek combined time
        -- for the times that changes happen
        let timepoints = Set.toList $ changes combined
        zipWith (+) (fmap (peek t1) timepoints) (fmap (peek t2) timepoints) === fmap (peek combined) timepoints
  ]

test_imap :: [TestTree]
test_imap =
  [ testProperty "when ignoring the range, it works the same as fmap" $ property $ do
      tl <- forAll $ gen genUTCTime (Gen.int (Range.constant 0 1000))
      imap (const (+ 1)) tl === fmap (+ 1) tl,
    testCase "check the time ranges" $ do
      let t1 = UTCTime (fromGregorian 2023 1 16) 0
          t2 = UTCTime (fromGregorian 2023 1 19) 0
          timeline =
            Timeline @UTCTime @Int
              1
              [ (t1, 2),
                (t2, 3)
              ]
          result = execWriter . sequenceA $ imap (\range _ -> tell @[TimeRange UTCTime] [range]) timeline
      result
        @?= [ TimeRange Nothing (Just t1),
              TimeRange (Just t1) (Just (addUTCTime (secondsToNominalDiffTime 259200) t1)),
              TimeRange (Just t2) Nothing
            ],
    testProperty "law: imap f . imap g === imap (\\i -> f i . g i)" $ property $ do
      tl <- forAll $ gen genUTCTime (Gen.int (Range.constant 0 1000))
      let hashTimeRange :: TimeRange UTCTime -> Int
          hashTimeRange TimeRange {from, to} = hash (show from) `hashWithSalt` show to
          f :: TimeRange UTCTime -> Int -> Int
          f tr x = hashTimeRange tr + x
          g :: TimeRange UTCTime -> Int -> Int
          g tr x = hashTimeRange tr - x
      (imap f . imap g) tl === imap (\i -> f i . g i) tl,
    testProperty "law: imap (\\_ a -> a) === id" $ property $ do
      tl <- forAll $ gen genUTCTime (Gen.int (Range.constant 0 1000))
      imap (\_ a -> a) tl === tl
  ]

buildGoldenTest :: (a -> Text) -> TestName -> a -> TestTree
buildGoldenTest pretty name value =
  goldenVsString
    name
    ("test/golden/" <> fmap (\ch -> if ch == ' ' then '_' else ch) name <> ".txt")
    $ pure . LBS.fromStrict . T.encodeUtf8 . pretty
    $ value

-- | A 'UTCTime' generator
genUTCTime :: (MonadGen m) => m UTCTime
genUTCTime = do
  y <- toInteger <$> Gen.int (Range.constant 2000 2030)
  m <- Gen.int (Range.constant 1 12)
  d <- Gen.int (Range.constant 1 28)
  let day = fromGregorian y m d
  secs <- toInteger <$> Gen.int (Range.constant 0 86401)
  let diff = secondsToDiffTime secs
  pure $ UTCTime day diff