packages feed

aeson-dependent-sum-0.1.0.0: test/Data/Aeson/Dependent/SumTest.hs

{-# LANGUAGE DataKinds #-}
{-# LANGUAGE DeriveAnyClass #-}
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE DerivingVia #-}
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE GADTs #-}
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE MultiParamTypeClasses #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RankNTypes #-}
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE StandaloneDeriving #-}
{-# LANGUAGE TemplateHaskell #-}
{-# LANGUAGE TypeFamilies #-}
{-# LANGUAGE UndecidableInstances #-}

module Data.Aeson.Dependent.SumTest where

import Data.Aeson
  ( FromJSON (..),
    FromJSONKey (..),
    ToJSON (..),
    ToJSONKey (..),
    eitherDecode',
    encode,
    object,
    withObject,
    withText,
    (.:),
    (.=),
  )
import Data.Aeson.Dependent.Sum
import Data.ByteString.Lazy (ByteString)
import Data.Constraint.Extras (ArgDict)
import Data.Constraint.Extras.TH (deriveArgDict)
import Data.Dependent.Sum (DSum, (==>))
import Data.Functor.Identity (Identity)
import Data.GADT.Compare.TH (deriveGEq)
import Data.GADT.Show.TH (deriveGShow)
import Data.Some (Some (..), foldSome, mapSome, withSome)
import GHC.Generics (Generic)
import Hedgehog (Gen, Property, forAll, property, tripping)
import qualified Hedgehog.Gen as Gen
import qualified Hedgehog.Range as Range
import Test.Tasty (TestName, TestTree, testGroup)
import Test.Tasty.HUnit (testCase, (@?=))

data CharacterClass a where
  Fighter :: CharacterClass Fighter
  Rogue :: CharacterClass Rogue
  Wizard :: CharacterClass Wizard

instance FromJSON (Some CharacterClass) where
  parseJSON = withText "CharacterClass" $ \t -> case t of
    "fighter" -> pure $ Some Fighter
    "rogue" -> pure $ Some Rogue
    "wizard" -> pure $ Some Wizard
    _ -> fail $ "Unexpected CharacterClass: " <> show t

instance ToJSON (Some CharacterClass) where
  toJSON = foldSome $ \case
    Fighter -> "fighter"
    Rogue -> "rogue"
    Wizard -> "wizard"

deriving anyclass instance FromJSONKey (Some CharacterClass)

deriving anyclass instance ToJSONKey (Some CharacterClass)

data Fighter = F
  { favouredWeapon :: String,
    attackBonus :: Int
  }
  deriving stock (Eq, Show, Generic)
  deriving anyclass (FromJSON, ToJSON)

data Rogue = R
  { sneakiness :: Int,
    skills :: [String]
  }
  deriving stock (Eq, Show, Generic)
  deriving anyclass (FromJSON, ToJSON)

data Wizard = W
  { frogsLegs :: Int,
    eyesOfNewt :: Int,
    darkPatron :: String
  }
  deriving stock (Eq, Show, Generic)
  deriving anyclass (FromJSON, ToJSON)

-- This has to be after the individual class records, as GHC cannot
-- look beyond the splices.
$(deriveArgDict ''CharacterClass)
$(deriveGEq ''CharacterClass)
$(deriveGShow ''CharacterClass)

-- Newtype with contrived FromJSONKey/ToJSONKey instances to test
-- non-string encoding/decoding.
newtype NonStringKeyCharacterClass a
  = NonStringKeyCharacterClass (CharacterClass a)
  deriving newtype (ArgDict c)

instance FromJSON (Some NonStringKeyCharacterClass) where
  parseJSON =
    withObject "NonStringKeyCharacterClass" $ \o ->
      mapSome NonStringKeyCharacterClass <$> o .: "class"

instance ToJSON (Some NonStringKeyCharacterClass) where
  toJSON (Some (NonStringKeyCharacterClass c)) =
    object ["class" .= Some c]

instance FromJSONKey (Some NonStringKeyCharacterClass)

instance ToJSONKey (Some NonStringKeyCharacterClass)

newtype CharacterTaggedObjectInline = CharacterTaggedObjectInline (DSum CharacterClass Identity)
  deriving stock (Eq, Show)
  deriving
    (FromJSON, ToJSON)
    via (TaggedObjectInline "Character" "class" CharacterClass Identity)

newtype CharacterTaggedObject = CharacterTaggedObject (DSum CharacterClass Identity)
  deriving stock (Eq, Show)
  deriving
    (FromJSON, ToJSON)
    via (TaggedObject "Character" "class" "data" CharacterClass Identity)

newtype CharacterObjectWithSingleField = CharacterObjectWithSingleField (DSum CharacterClass Identity)
  deriving stock (Eq, Show)
  deriving
    (FromJSON, ToJSON)
    via (ObjectWithSingleField "Character" CharacterClass Identity)

newtype CharacterObjectWithSingleDodgyField = CharacterObjectWithSingleDodgyField (DSum CharacterClass Identity)
  deriving stock (Eq, Show)
  deriving
    (FromJSON, ToJSON)
    via (ObjectWithSingleField "Character" NonStringKeyCharacterClass Identity)

newtype CharacterTwoElemArray = CharacterTwoElemArray (DSum CharacterClass Identity)
  deriving stock (Eq, Show)
  deriving
    (FromJSON, ToJSON)
    via (TwoElemArray "Character" CharacterClass Identity)

-- Unit Tests --

test_DecodeTaggedObjectInline :: TestTree
test_DecodeTaggedObjectInline =
  makeParseTests
    "DecodeTaggedObjectInline"
    taggedObjectInlineJson
    CharacterTaggedObjectInline

test_DecodeTaggedObject :: TestTree
test_DecodeTaggedObject =
  makeParseTests
    "DecodeTaggedObject"
    taggedObjectJson
    CharacterTaggedObject

test_DecodeObjectWithSingleField :: TestTree
test_DecodeObjectWithSingleField =
  makeParseTests
    "ObjectWithSingleField"
    objectWithSingleFieldJson
    CharacterObjectWithSingleField

test_DecodeObjectWithSingleDodgyField :: TestTree
test_DecodeObjectWithSingleDodgyField =
  makeParseTests
    "ObjectWithSingleDodgyField"
    objectWithSingleDodgyFieldJson
    CharacterObjectWithSingleDodgyField

test_DecodeTwoElemArray :: TestTree
test_DecodeTwoElemArray =
  makeParseTests
    "TwoElemArray"
    twoElemArrayJson
    CharacterTwoElemArray

makeParseTests ::
  (Eq a, FromJSON a, Show a) =>
  TestName ->
  (forall x. CharacterClass x -> ByteString) ->
  (DSum CharacterClass Identity -> a) ->
  TestTree
makeParseTests label jsonFor wrap =
  testGroup
    label
    [ testCase "fighter" $
        eitherDecode' (jsonFor Fighter)
          @?= Right (wrap $ expectedParses Fighter),
      testCase "rogue" $
        eitherDecode' (jsonFor Rogue)
          @?= Right (wrap $ expectedParses Rogue),
      testCase "wizard" $
        eitherDecode' (jsonFor Wizard)
          @?= Right (wrap $ expectedParses Wizard)
    ]

expectedParses :: CharacterClass a -> DSum CharacterClass Identity
expectedParses = \case
  Fighter ->
    Fighter
      ==> F
        { favouredWeapon = "greatsword",
          attackBonus = 12
        }
  Rogue ->
    Rogue
      ==> R
        { sneakiness = 999,
          skills = ["sneaking", "stealing", "lockpicking"]
        }
  Wizard ->
    Wizard
      ==> W
        { frogsLegs = 42,
          eyesOfNewt = 9001,
          darkPatron = "Damo"
        }

taggedObjectInlineJson :: CharacterClass a -> ByteString
taggedObjectInlineJson = \case
  Fighter ->
    "{\
    \  \"class\": \"fighter\",\
    \  \"favouredWeapon\": \"greatsword\",\
    \  \"attackBonus\": 12\
    \}"
  Rogue ->
    "{\
    \  \"class\": \"rogue\",\
    \  \"sneakiness\": 999,\
    \  \"skills\": [\"sneaking\", \"stealing\", \"lockpicking\"]\
    \}"
  Wizard ->
    "{\
    \  \"class\": \"wizard\",\
    \  \"frogsLegs\": 42,\
    \  \"eyesOfNewt\": 9001,\
    \  \"darkPatron\": \"Damo\"\
    \}"

taggedObjectJson :: CharacterClass a -> ByteString
taggedObjectJson = \case
  Fighter ->
    "{\
    \  \"class\": \"fighter\",\
    \  \"data\": {\
    \    \"favouredWeapon\": \"greatsword\",\
    \    \"attackBonus\": 12\
    \  }\
    \}"
  Rogue ->
    "{\
    \  \"class\": \"rogue\",\
    \  \"data\": {\
    \    \"sneakiness\": 999,\
    \    \"skills\": [\"sneaking\", \"stealing\", \"lockpicking\"]\
    \  }\
    \}"
  Wizard ->
    "{\
    \  \"class\": \"wizard\",\
    \  \"data\": {\
    \    \"frogsLegs\": 42,\
    \    \"eyesOfNewt\": 9001,\
    \    \"darkPatron\": \"Damo\"\
    \  }\
    \}"

objectWithSingleFieldJson :: CharacterClass a -> ByteString
objectWithSingleFieldJson = \case
  Fighter ->
    "{\
    \  \"fighter\": {\
    \    \"favouredWeapon\": \"greatsword\",\
    \    \"attackBonus\": 12\
    \  }\
    \}"
  Rogue ->
    "{\
    \  \"rogue\": {\
    \    \"sneakiness\": 999,\
    \    \"skills\": [\"sneaking\", \"stealing\", \"lockpicking\"]\
    \  }\
    \}"
  Wizard ->
    "{\
    \  \"wizard\": {\
    \    \"frogsLegs\": 42,\
    \    \"eyesOfNewt\": 9001,\
    \    \"darkPatron\": \"Damo\"\
    \  }\
    \}"

objectWithSingleDodgyFieldJson :: CharacterClass a -> ByteString
objectWithSingleDodgyFieldJson = \case
  Fighter ->
    "[\
    \  { \"class\": \"fighter\" },\
    \  {\
    \    \"favouredWeapon\": \"greatsword\",\
    \    \"attackBonus\": 12\
    \  }\
    \]"
  Rogue ->
    "[\
    \  { \"class\": \"rogue\" },\
    \  {\
    \    \"sneakiness\": 999,\
    \    \"skills\": [\"sneaking\", \"stealing\", \"lockpicking\"]\
    \  }\
    \]"
  Wizard ->
    "[\
    \  { \"class\": \"wizard\" },\
    \  {\
    \    \"frogsLegs\": 42,\
    \    \"eyesOfNewt\": 9001,\
    \    \"darkPatron\": \"Damo\"\
    \  }\
    \]"

twoElemArrayJson :: CharacterClass a -> ByteString
twoElemArrayJson = \case
  Fighter ->
    "[\
    \  \"fighter\",\
    \  {\
    \    \"favouredWeapon\": \"greatsword\",\
    \    \"attackBonus\": 12\
    \  }\
    \]"
  Rogue ->
    "[\
    \  \"rogue\",\
    \  {\
    \    \"sneakiness\": 999,\
    \    \"skills\": [\"sneaking\", \"stealing\", \"lockpicking\"]\
    \  }\
    \]"
  Wizard ->
    "[\
    \  \"wizard\",\
    \  {\
    \    \"frogsLegs\": 42,\
    \    \"eyesOfNewt\": 9001,\
    \    \"darkPatron\": \"Damo\"\
    \  }\
    \]"

-- Property Tests --

genCharacterDSum :: Gen (DSum CharacterClass Identity)
genCharacterDSum = do
  let string :: Gen String
      string = Gen.string (Range.linear 1 100) Gen.unicode
      int :: Gen Int
      int = Gen.int (Range.linear 1 100)
      list :: Gen a -> Gen [a]
      list = Gen.list (Range.linear 1 100)

  someClass <- Gen.element [Some Fighter, Some Rogue, Some Wizard]
  withSome someClass $ \case
    Fighter ->
      (Fighter ==>) <$> do
        favouredWeapon <- string
        attackBonus <- int
        pure F {..}
    Rogue ->
      (Rogue ==>) <$> do
        sneakiness <- int
        skills <- list string
        pure R {..}
    Wizard ->
      (Wizard ==>) <$> do
        frogsLegs <- int
        eyesOfNewt <- int
        darkPatron <- string
        pure W {..}

hprop_TrippingTaggedObjectInline :: Property
hprop_TrippingTaggedObjectInline = property $ do
  ch <- forAll genCharacterDSum
  tripping (CharacterTaggedObjectInline ch) encode eitherDecode'

hprop_TrippingTaggedObject :: Property
hprop_TrippingTaggedObject = property $ do
  ch <- forAll genCharacterDSum
  tripping (CharacterTaggedObject ch) encode eitherDecode'

hprop_TrippingObjectWithSingleField :: Property
hprop_TrippingObjectWithSingleField = property $ do
  ch <- forAll genCharacterDSum
  tripping (CharacterObjectWithSingleField ch) encode eitherDecode'

hprop_TrippingObjectWithSingleDodgyField :: Property
hprop_TrippingObjectWithSingleDodgyField = property $ do
  ch <- forAll genCharacterDSum
  tripping (CharacterObjectWithSingleDodgyField ch) encode eitherDecode'

hprop_TrippingTwoElemArray :: Property
hprop_TrippingTwoElemArray = property $ do
  ch <- forAll genCharacterDSum
  tripping (CharacterTwoElemArray ch) encode eitherDecode'