packages feed

keiki-codec-json-0.1.0.0: test/Keiki/Codec/JSON/THSpec.hs

{-# LANGUAGE TemplateHaskell #-}

-- | EP-38 M3 tests for 'Keiki.Codec.JSON.TH.deriveRegFileCodec'.
--
-- The splice emits three top-level functions per record type. This spec
-- exercises the round-trip and strict-decoder properties of the emitted
-- functions against two test records — a non-trivial @TestRec@ and the
-- @Empty@ singleton — and verifies the encoding-path / value-path
-- semantic-round-trip agreement (the same invariant the in-tree M2 and
-- M3 specs exercise for the underlying class).
--
-- == Negative-test procedure (manual)
--
-- The splice's contract is that a record whose field type lacks
-- 'Aeson.ToJSON' or 'Aeson.FromJSON' fails to compile. This is not
-- expressible in a passing unit test, so the procedure is manual:
--
-- 1. Edit @TestRec@ below to add a field @trBad :: Int -> Int@.
-- 2. Run @cabal build keiki-codec-json:keiki-codec-json-test@.
-- 3. The build must fail with @No instance for 'Aeson.ToJSON' (Int -> Int)@
--    pointing at the use site of @testRecToJSON@.
-- 4. Revert. The expected error proves the splice's elaboration trips
--    the missing-instance check; an automated should-not-compile test
--    is out of scope for v0.2.
module Keiki.Codec.JSON.THSpec (spec) where

import Data.Aeson qualified as Aeson
import Data.Aeson.Encoding qualified as AesonEnc
import Data.Maybe (fromJust)
import Data.Text (Text)
import Data.Text qualified as T
import GHC.Generics (Generic)
import Keiki.Codec.JSON.TH (deriveRegFileCodec)
import Test.Hspec (Spec, describe, it, shouldBe, shouldSatisfy)

-- * Test fixtures -----------------------------------------------------------

-- | Non-trivial two-field record used to exercise round-trip and strict
-- decoder behaviour.
data TestRec = TestRec
  { trCount :: Int,
    trNote :: Text
  }
  deriving stock (Eq, Show, Generic)

$(deriveRegFileCodec ''TestRec)

-- | Singleton record used to exercise the empty-slot-list edge case.
data Empty = Empty
  deriving stock (Eq, Show, Generic)

$(deriveRegFileCodec ''Empty)

-- * Spec --------------------------------------------------------------------

spec :: Spec
spec = describe "deriveRegFileCodec" $ do
  let tr = TestRec {trCount = 7, trNote = T.pack "hi"}

  describe "TestRec — round-trip and encoding agreement" $ do
    it "Value path round-trips" $
      testRecFromJSON (testRecToJSON tr) `shouldBe` Right tr

    it "Encoding path bytes parse back to the same value" $ do
      let bytes = AesonEnc.encodingToLazyByteString (testRecToEncoding tr)
          v = fromJust (Aeson.decode bytes :: Maybe Aeson.Value)
      testRecFromJSON v `shouldBe` Right tr

    it "Value path emits both slots in slot-list order" $
      testRecToJSON tr
        `shouldBe` Aeson.object
          [ "trCount" Aeson..= (7 :: Int),
            "trNote" Aeson..= T.pack "hi"
          ]

  describe "TestRec — strict decoder" $ do
    it "rejects an Object missing trCount with a slot-prefixed message" $ do
      let v = Aeson.object ["trNote" Aeson..= T.pack "hi"]
      testRecFromJSON v `shouldBe` Left "trCount: missing slot"

    it "rejects an Object with an unknown extra field" $ do
      let v =
            Aeson.object
              [ "trCount" Aeson..= (7 :: Int),
                "trNote" Aeson..= T.pack "hi",
                "bogus" Aeson..= (1 :: Int)
              ]
      testRecFromJSON v `shouldSatisfy` isExtraFieldsLeft

    it "rejects a type-mismatched field with a slot-prefixed message" $ do
      let v =
            Aeson.object
              [ "trCount" Aeson..= T.pack "seven",
                "trNote" Aeson..= T.pack "hi"
              ]
      testRecFromJSON v `shouldSatisfy` hasPrefix "trCount:"

  describe "Empty — empty-slot-list edge case" $ do
    it "encodes Empty to the empty JSON object" $
      emptyToJSON Empty `shouldBe` Aeson.object []

    it "decodes the empty JSON object back to Empty" $
      emptyFromJSON (Aeson.object []) `shouldBe` Right Empty

    it "encodes Empty via the streaming path to the literal bytes {}" $
      AesonEnc.encodingToLazyByteString (emptyToEncoding Empty)
        `shouldBe` AesonEnc.encodingToLazyByteString (AesonEnc.pairs mempty)

    it "rejects a non-empty JSON object as unknown extra fields" $
      emptyFromJSON (Aeson.object ["x" Aeson..= (1 :: Int)])
        `shouldSatisfy` isExtraFieldsLeft

-- * Helpers -----------------------------------------------------------------

isExtraFieldsLeft :: Either String a -> Bool
isExtraFieldsLeft = \case
  Left msg -> T.pack "regfile: unknown extra fields:" `T.isPrefixOf` T.pack msg
  Right _ -> False

hasPrefix :: String -> Either String a -> Bool
hasPrefix p = \case
  Left msg -> T.pack p `T.isPrefixOf` T.pack msg
  Right _ -> False