keiki-codec-json-test 0.1.0.0 → 0.2.0.0
raw patch · 8 files changed
+233/−25 lines, 8 filesdep ~keikidep ~keiki-codec-jsondep ~keiki-codec-json-testPVP ok
version bump matches the API change (PVP)
Dependency ranges changed: keiki, keiki-codec-json, keiki-codec-json-test
API changes (from Hackage documentation)
+ Keiki.Codec.JSON.Test: class EqRegFile (rs :: [Slot])
+ Keiki.Codec.JSON.Test: eqRegFile :: EqRegFile rs => RegFile rs -> RegFile rs -> Bool
+ Keiki.Codec.JSON.Test: instance (GHC.Classes.Eq t, Keiki.Codec.JSON.Test.EqRegFile rs) => Keiki.Codec.JSON.Test.EqRegFile ('(s, t) : rs)
+ Keiki.Codec.JSON.Test: instance Keiki.Codec.JSON.Test.EqRegFile '[]
+ Keiki.Codec.JSON.Test: regFileCodecPropsEq :: forall (rs :: [Slot]). (RegFileToJSON rs, ArbitraryRegFile rs, EqRegFile rs) => Spec
+ Keiki.Codec.JSON.Test.GoldenFile: regFileGoldenFileSpec :: forall (rs :: [Slot]). (RegFileToJSON rs, EqRegFile rs) => String -> FilePath -> RegFile rs -> Spec
Files
- CHANGELOG.md +12/−0
- README.md +28/−3
- keiki-codec-json-test.cabal +23/−15
- src/Keiki/Codec/JSON/Test.hs +88/−7
- src/Keiki/Codec/JSON/Test/GoldenFile.hs +59/−0
- test/Keiki/Codec/JSON/Test/Demo.hs +9/−0
- test/Spec.hs +13/−0
- test/golden/demo-regfile.value.json +1/−0
CHANGELOG.md view
@@ -9,6 +9,18 @@ ## [Unreleased] +## [0.2.0.0] — 2026-07-13++### Added++- `EqRegFile` and `regFileCodecPropsEq` provide value-level heterogeneous+ round-trip checks. The original byte-comparing `regFileCodecProps` remains for+ compatibility, with its nested-`Maybe` blind spot now documented.+- `Keiki.Codec.JSON.Test.GoldenFile.regFileGoldenFileSpec` pins a whole+ register file against checked-in JSON in both directions, with a deliberately+ failing `KEIKI_UPDATE_GOLDENS` regeneration workflow.++ ## [0.1.0.0] — 2026-06-07 Initial Hackage release. Co-released with `keiki-0.1.0.0` and
README.md view
@@ -33,7 +33,7 @@ import Test.Hspec (describe, hspec) import Keiki.Codec.JSON.Test- ( regFileCodecProps+ ( regFileCodecPropsEq , regFileShapeSensitivitySpec , someKnownShape )@@ -41,6 +41,9 @@ ( SlotGolden (..) , slotGoldenSpec )+import Keiki.Codec.JSON.Test.GoldenFile+ ( regFileGoldenFileSpec+ ) -- Your slot type and slot lists: -- data Email = Email Text deriving (...)@@ -56,8 +59,15 @@ , sgBytes = "\"alice@example.com\"" }) + -- Whole-snapshot backward compatibility. `myFixture` is a fixed+ -- `RegFile MySlots`; add the JSON file to extra-source-files.+ regFileGoldenFileSpec+ "MySlots"+ "test/golden/my-slots.value.json"+ myFixture+ -- Round-trip + determinism over the snapshot's slot list.- describe "props: MySlots" (regFileCodecProps @MySlots)+ describe "props: MySlots" (regFileCodecPropsEq @MySlots) -- Sensitivity: every named mutation must flip the shape hash. describe "sensitivity: MySlots" $@@ -73,6 +83,21 @@ lacks `Arbitrary`, write one — it is typically a one-liner via `quickcheck-instances` for the standard library types. +`regFileCodecPropsEq` is the preferred round-trip helper: its inductive+`EqRegFile` comparison checks decoded slot values. The older+`regFileCodecProps` remains available for slot types without `Eq`, but it compares+re-encoded bytes and therefore cannot detect lossy decodes that re-encode+identically (notably `Just Nothing -> null -> Nothing`).++When an intentional wire change requires regenerating whole-snapshot files, use+the guarded two-run workflow:++```sh+KEIKI_UPDATE_GOLDENS=1 cabal test # rewrites files, then fails deliberately+cabal test # must pass with the variable unset+git diff test/golden/ # review every changed byte+```+ ## When you don't need this `keiki-codec-json` alone is sufficient for production use. This@@ -88,4 +113,4 @@ The self-test exercises every public helper against a small toy fixture (`Email`, `DemoSlots`, `DemoSlotsRenamed`). Expected-output: 7 examples, 0 failures.+output: 11 examples, 0 failures.
keiki-codec-json-test.cabal view
@@ -1,6 +1,6 @@-cabal-version: 3.0-name: keiki-codec-json-test-version: 0.1.0.0+cabal-version: 3.0+name: keiki-codec-json-test+version: 0.2.0.0 synopsis: Property-test toolkit for keiki-codec-json downstream consumers. @@ -22,17 +22,24 @@ for production do not transitively pick up @QuickCheck@ / @hspec@ / @quickcheck-instances@. -license: BSD-3-Clause-author: Nadeem Bitar-maintainer: nadeem@gmail.com-copyright: 2026 Nadeem Bitar-category: Codec, Testing-build-type: Simple-tested-with: GHC >=9.12 && <9.13+license: BSD-3-Clause+author: Nadeem Bitar+maintainer: nadeem@gmail.com+copyright: 2026 Nadeem Bitar+category: Codec, Testing+build-type: Simple+tested-with: GHC >=9.12 && <9.13 extra-doc-files: CHANGELOG.md README.md +extra-source-files: test/golden/*.json++source-repository head+ type: git+ location: https://github.com/shinzui/keiki.git+ subdir: keiki-codec-json-test+ common warnings ghc-options: -Wall -Wcompat -Widentities -Wincomplete-record-updates@@ -54,6 +61,7 @@ exposed-modules: Keiki.Codec.JSON.Test Keiki.Codec.JSON.Test.Golden+ Keiki.Codec.JSON.Test.GoldenFile hs-source-dirs: src build-depends:@@ -61,8 +69,8 @@ , base ^>=4.21 , bytestring ^>=0.12 , hspec ^>=2.11- , keiki ^>=0.1- , keiki-codec-json ^>=0.1+ , keiki ^>=0.2+ , keiki-codec-json ^>=0.2 , QuickCheck ^>=2.15 , quickcheck-instances ^>=0.3 , text ^>=2.1@@ -78,9 +86,9 @@ , base ^>=4.21 , bytestring ^>=0.12 , hspec ^>=2.11- , keiki ^>=0.1- , keiki-codec-json ^>=0.1- , keiki-codec-json-test ^>=0.1+ , keiki ^>=0.2+ , keiki-codec-json ^>=0.2+ , keiki-codec-json-test ^>=0.2 , QuickCheck ^>=2.15 , quickcheck-instances ^>=0.3 , text ^>=2.1
src/Keiki/Codec/JSON/Test.hs view
@@ -5,7 +5,7 @@ -- -- Wires three helpers into a consumer's @hspec@ test suite: ----- * 'regFileCodecProps' — four QuickCheck properties (Value-path and+-- * 'regFileCodecProps' / 'regFileCodecPropsEq' — four QuickCheck properties (Value-path and -- Encoding-path round-trip, within-path determinism on both -- paths) against the consumer's own slot list. Mirror of the -- EP-36 M3 in-tree property suite at@@ -34,8 +34,12 @@ ( -- * Arbitrary generator for slot lists ArbitraryRegFile (..), + -- * Value equality for slot lists+ EqRegFile (..),+ -- * Round-trip + determinism properties regFileCodecProps,+ regFileCodecPropsEq, -- * Sensitivity helper SomeKnownRegFileShape (..),@@ -92,6 +96,19 @@ where arbRegFile = RCons (Proxy @s) <$> arbitrary <*> arbRegFile +-- * Value equality for slot lists -------------------------------------------++-- | Inductive equality for a heterogeneous 'RegFile'. Consumers receive an+-- instance automatically when every slot value has 'Eq'.+class EqRegFile (rs :: [Slot]) where+ eqRegFile :: RegFile rs -> RegFile rs -> Bool++instance EqRegFile '[] where+ eqRegFile _ _ = True++instance (Eq t, EqRegFile rs) => EqRegFile ('(s, t) ': rs) where+ eqRegFile (RCons _ x xs) (RCons _ y ys) = x == y && eqRegFile xs ys+ -- * Round-trip + determinism properties ------------------------------------- -- | Run the EP-36 M3 codec property suite against an arbitrary slot@@ -107,12 +124,12 @@ -- 'RegFile' yields byte-equal output. -- * /Encoding path within-path determinism (R9):/ same. ----- Implementation note: 'RegFile' has no 'Eq' or 'Show' instance (the--- slot list is heterogeneous), so the round-trip assertion uses the--- re-encoded bytes as the comparison point. If the parsed--- 'RegFile' re-encodes to the same bytes as the original, the--- round-trip is structurally exact. This is the same trick the--- in-tree EP-36 M3 spec uses.+-- Implementation note: 'RegFile' has no general 'Eq' or 'Show' instance (the+-- slot list is heterogeneous), so this compatibility helper compares re-encoded+-- bytes. That cannot detect a lossy decode when the changed value re-encodes to+-- the same bytes; aeson's @Just Nothing -> null -> Nothing@ collapse is the known+-- example. Consumers whose slot values have 'Eq' should prefer+-- 'regFileCodecPropsEq', which compares decoded values through 'EqRegFile'. -- -- Type-application invocation form: --@@ -174,6 +191,70 @@ Right rf' -> AesonEnc.encodingToLazyByteString (regFileToEncoding rf') === bytes++ valueDeterministic :: RegFile rs -> Property+ valueDeterministic rf =+ Aeson.encode (regFileToJSON rf)+ === Aeson.encode (regFileToJSON rf)++ encodingDeterministic :: RegFile rs -> Property+ encodingDeterministic rf =+ AesonEnc.encodingToLazyByteString (regFileToEncoding rf)+ === AesonEnc.encodingToLazyByteString (regFileToEncoding rf)++-- | Value-comparing form of 'regFileCodecProps'. It runs the same four+-- properties but compares decoded slot values through 'EqRegFile', closing the+-- byte-idempotence blind spot of the compatibility helper.+--+-- @+-- regFileCodecPropsEq \@MyAppSnapshotSlots+-- @+regFileCodecPropsEq ::+ forall rs.+ ( RegFileToJSON rs,+ ArbitraryRegFile rs,+ EqRegFile rs+ ) =>+ Spec+regFileCodecPropsEq = do+ describe "Roundtrip" $ do+ it "Value path round-trips by value" $+ forAllShow (arbRegFile @rs) showRf valueRoundTrip+ it "Encoding path round-trips by value" $+ forAllShow (arbRegFile @rs) showRf encodingRoundTrip++ describe "Determinism (R9 within-path)" $ do+ it "Value path is deterministic" $+ forAllShow (arbRegFile @rs) showRf valueDeterministic+ it "Encoding path is deterministic" $+ forAllShow (arbRegFile @rs) showRf encodingDeterministic+ where+ showRf :: RegFile rs -> String+ showRf = show . regFileToJSON++ valueRoundTrip :: 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 Value-path encoder output"+ Just value -> case regFileFromJSON @rs value of+ Left message ->+ False === error ("regFileFromJSON failed: " <> message)+ Right decoded -> eqRegFile decoded rf === True++ encodingRoundTrip :: 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 value -> case regFileFromJSON @rs value of+ Left message ->+ False === error ("regFileFromJSON failed: " <> message)+ Right decoded -> eqRegFile decoded rf === True valueDeterministic :: RegFile rs -> Property valueDeterministic rf =
+ src/Keiki/Codec/JSON/Test/GoldenFile.hs view
@@ -0,0 +1,59 @@+-- |+-- Module : Keiki.Codec.JSON.Test.GoldenFile+-- Description : Checked-in whole-RegFile JSON golden-file discipline.+--+-- 'regFileGoldenFileSpec' checks both persistence directions: the current+-- decoder must read bytes checked into source control as the fixed expected+-- value, and the current Value-path encoder must reproduce those bytes exactly.+--+-- To accept an intentional wire-format change, run the consumer test suite once+-- with @KEIKI_UPDATE_GOLDENS=1@. The helper rewrites the file and deliberately+-- fails; unset the variable, review the diff, and rerun before committing.+module Keiki.Codec.JSON.Test.GoldenFile+ ( regFileGoldenFileSpec,+ )+where++import Data.Aeson qualified as Aeson+import Data.ByteString.Lazy qualified as LBS+import Keiki.Codec.JSON (RegFileToJSON, regFileFromJSON, regFileToJSON)+import Keiki.Codec.JSON.Test (EqRegFile (eqRegFile))+import Keiki.Core (RegFile)+import System.Environment (lookupEnv)+import Test.Hspec (Spec, describe, expectationFailure, it, runIO, shouldBe)++-- | Pin a fixed register file against a checked-in Value-path JSON file.+--+-- The file path is resolved relative to the consumer package's test working+-- directory. Add it to the package's @extra-source-files@ so source+-- distributions retain the compatibility evidence.+regFileGoldenFileSpec ::+ forall rs.+ (RegFileToJSON rs, EqRegFile rs) =>+ String ->+ FilePath ->+ RegFile rs ->+ Spec+regFileGoldenFileSpec label path expected = describe label $ do+ update <- runIO (lookupEnv "KEIKI_UPDATE_GOLDENS")+ case update of+ Just _ -> do+ runIO (LBS.writeFile path currentBytes)+ it "regenerates and requires a clean review run" $+ expectationFailure regenerationMessage+ Nothing -> do+ historicalBytes <- runIO (LBS.readFile path)+ it "decodes checked-in bytes to the fixed value" $+ case Aeson.decode historicalBytes of+ Nothing -> expectationFailure "golden file is not valid JSON"+ Just value -> case regFileFromJSON @rs value of+ Left message -> expectationFailure ("golden decode failed: " <> message)+ Right actual -> eqRegFile actual expected `shouldBe` True+ it "freezes current Value-path bytes" $+ currentBytes `shouldBe` historicalBytes+ where+ currentBytes = Aeson.encode (regFileToJSON expected)++regenerationMessage :: String+regenerationMessage =+ "golden regenerated; unset KEIKI_UPDATE_GOLDENS, re-run, and review git diff"
test/Keiki/Codec/JSON/Test/Demo.hs view
@@ -9,12 +9,16 @@ ( Email (..), DemoSlots, DemoSlotsRenamed,+ demoRegFile, ) where import Data.Aeson qualified as Aeson+import Data.Proxy (Proxy (..)) import Data.Text (Text)+import Data.Text qualified as T import GHC.Generics (Generic)+import Keiki.Core (RegFile (..)) import Keiki.Shape (CanonicalTypeName) import Test.QuickCheck (Arbitrary (..)) import Test.QuickCheck.Instances ()@@ -43,3 +47,8 @@ '[ '("emailAddress", Email), '("count", Int) ]++demoRegFile :: RegFile DemoSlots+demoRegFile =+ RCons (Proxy @"email") (Email (T.pack "a@b.c")) $+ RCons (Proxy @"count") (7 :: Int) RNil
test/Spec.hs view
@@ -13,6 +13,7 @@ import Data.Text qualified as T import Keiki.Codec.JSON.Test ( regFileCodecProps,+ regFileCodecPropsEq, regFileShapeSensitivitySpec, someKnownShape, )@@ -20,11 +21,13 @@ ( DemoSlots, DemoSlotsRenamed, Email (..),+ demoRegFile, ) import Keiki.Codec.JSON.Test.Golden ( SlotGolden (..), slotGoldenSpec, )+import Keiki.Codec.JSON.Test.GoldenFile (regFileGoldenFileSpec) import Test.Hspec (describe, hspec) main :: IO ()@@ -41,6 +44,16 @@ describe "Keiki.Codec.JSON.Test.regFileCodecProps @DemoSlots" (regFileCodecProps @DemoSlots)++ describe+ "Keiki.Codec.JSON.Test.regFileCodecPropsEq @DemoSlots"+ (regFileCodecPropsEq @DemoSlots)++ describe "Keiki.Codec.JSON.Test.GoldenFile.regFileGoldenFileSpec" $+ regFileGoldenFileSpec+ "DemoSlots"+ "test/golden/demo-regfile.value.json"+ demoRegFile describe "Keiki.Codec.JSON.Test.regFileShapeSensitivitySpec" $ regFileShapeSensitivitySpec
+ test/golden/demo-regfile.value.json view
@@ -0,0 +1,1 @@+{"count":7,"email":"a@b.c"}