keiki-codec-json-0.2.0.0: test/Keiki/Codec/JSON/PropSpec.hs
-- | EP-36 M3 property tests: roundtrip on both encoder paths plus
-- within-path encoding determinism (R9).
--
-- The Value path and Encoding path are exercised independently. Cross-
-- path byte equality is /not/ asserted because aeson 2.2's
-- @Aeson.Value@ Object iterates 'Aeson.KeyMap' in (alphabetical)
-- KeyMap order, while the Encoding path emits in slot-list order via
-- @Aeson.Series@. See EP-36 Surprises & Discoveries for the
-- 2026-05-13 M2 entry documenting this.
module Keiki.Codec.JSON.PropSpec (spec) where
import Data.Aeson qualified as Aeson
import Data.Aeson.Encoding qualified as AesonEnc
import Data.Proxy (Proxy (..))
import Keiki.Codec.JSON (RegFileToJSON, regFileFromJSON, regFileToEncoding, regFileToJSON)
import Keiki.Codec.JSON.Fixtures
( ArbitraryRegFile (arbRegFile),
EqRegFile (eqRegFile),
ExemplarSlots,
MaybeSlots,
NestedMaybeSlots,
)
import Keiki.Core (RegFile)
import Keiki.Core qualified as Core
import Test.Hspec (Spec, describe, it, shouldBe)
import Test.QuickCheck (Property, forAllShow, (===))
-- | RegFile rs has no Show instance (the slot list is heterogeneous);
-- use the JSON encoding as the renderer for QuickCheck counterexamples.
showRegFile :: (RegFileToJSON rs) => RegFile rs -> String
showRegFile = show . regFileToJSON
spec :: Spec
spec = do
describe "ExemplarSlots" (codecProps @ExemplarSlots)
describe "MaybeSlots" (codecProps @MaybeSlots)
nestedMaybeSpec
codecProps ::
forall rs.
(RegFileToJSON rs, ArbitraryRegFile rs, EqRegFile rs) =>
Spec
codecProps = do
describe "Roundtrip" $ do
it "Value path round-trips" $
forAllShow (arbRegFile @rs) showRegFile (valueRoundTrip @rs)
it "Encoding path round-trips" $
forAllShow (arbRegFile @rs) showRegFile (encodingRoundTrip @rs)
describe "Determinism (R9 within-path)" $ do
it "Value path is deterministic" $
forAllShow (arbRegFile @rs) showRegFile valueDeterministic
it "Encoding path is deterministic" $
forAllShow (arbRegFile @rs) showRegFile encodingDeterministic
-- | @regFileFromJSON . regFileToJSON ≡ Right rf@. We compare decoded
-- slot values with the inductive 'EqRegFile' walker, so a lossy decode
-- cannot hide behind identical re-encoded bytes.
valueRoundTrip :: forall rs. (RegFileToJSON rs, EqRegFile rs) => RegFile rs -> Property
valueRoundTrip rf =
let bytes = Aeson.encode (regFileToJSON rf)
in case Aeson.decode bytes of
Nothing ->
False === error "Aeson.decode failed on our own encoder output"
Just v -> case regFileFromJSON @rs v of
Left msg ->
False === error ("regFileFromJSON failed: " <> msg)
Right rf' -> eqRegFile rf' rf === True
-- | Encoding path round-trip via
-- @regFileFromJSON . fromJust . Aeson.decode . AesonEnc.encodingToLazyByteString . regFileToEncoding@.
encodingRoundTrip :: forall rs. (RegFileToJSON rs, EqRegFile rs) => RegFile rs -> Property
encodingRoundTrip rf =
let bytes = AesonEnc.encodingToLazyByteString (regFileToEncoding rf)
in case Aeson.decode bytes of
Nothing ->
False === error "Aeson.decode failed on streaming-encoder output"
Just v -> case regFileFromJSON @rs v of
Left msg ->
False === error ("regFileFromJSON failed: " <> msg)
Right rf' -> eqRegFile rf' rf === True
-- | Re-encoding the same RegFile via the Value path produces byte-
-- equal output.
valueDeterministic :: (RegFileToJSON rs) => RegFile rs -> Property
valueDeterministic rf =
Aeson.encode (regFileToJSON rf)
=== Aeson.encode (regFileToJSON rf)
-- | Re-encoding via the Encoding path produces byte-equal output.
encodingDeterministic :: (RegFileToJSON rs) => RegFile rs -> Property
encodingDeterministic rf =
AesonEnc.encodingToLazyByteString (regFileToEncoding rf)
=== AesonEnc.encodingToLazyByteString (regFileToEncoding rf)
nestedMaybeSpec :: Spec
nestedMaybeSpec = describe "Nested Maybe wire semantics" $ do
it "encodes Just Nothing as null and decodes it as outer Nothing" $ do
let rf = nestedRegFile (Just Nothing)
regFileToJSON rf
`shouldBe` Aeson.object ["nested" Aeson..= Aeson.Null]
assertNestedDecode rf Nothing
it "round-trips Just (Just 42)" $
assertNestedDecode (nestedRegFile (Just (Just 42))) (Just (Just 42))
it "round-trips Nothing" $
assertNestedDecode (nestedRegFile Nothing) Nothing
it "rejects an absent Maybe slot rather than treating it as Nothing" $
case regFileFromJSON @MaybeSlots
( Aeson.object
[ "approvedAt" Aeson..= Aeson.Null,
"shippingAddress" Aeson..= Aeson.Null
]
) of
Left message -> message `shouldBe` "lastError: missing slot"
Right _ -> error "expected an absent-slot failure"
nestedRegFile :: Maybe (Maybe Int) -> RegFile NestedMaybeSlots
nestedRegFile value = Core.RCons (Proxy @"nested") value Core.RNil
assertNestedDecode :: RegFile NestedMaybeSlots -> Maybe (Maybe Int) -> IO ()
assertNestedDecode rf expected =
case regFileFromJSON @NestedMaybeSlots (regFileToJSON rf) of
Left message -> error ("nested Maybe decode failed: " <> message)
Right (Core.RCons _ actual Core.RNil) -> actual `shouldBe` expected