keiki-0.3.1.0: test/Keiki/ShapeSpec.hs
-- | EP-36 M1: golden-value tests for 'Keiki.Shape'.
--
-- The raw 'renderStableTypeRep' expectations remain pinned for GHC 9.12.*.
-- Shape canonicalization uses explicit built-in names instead, so GHC-internal
-- module moves no longer change snapshot hashes.
module Keiki.ShapeSpec (spec) where
import Data.Int (Int16, Int32, Int64, Int8)
import Data.Kind (Type)
import Data.Proxy (Proxy (..))
import Data.Text (Text)
import Data.Text qualified as T
import Data.Time.Calendar (Day)
import Data.Time.Clock (UTCTime)
import Data.Word (Word16, Word32, Word64, Word8)
import GHC.Generics (Generic)
import GHC.TypeLits (Symbol)
import Keiki.Shape
( CanonicalStateShape (..),
CanonicalTypeName (..),
regFileShapeCanonical,
regFileShapeHash,
renderStableTypeRep,
sha256Hex,
stateShapeHash,
)
import Test.Hspec (Spec, describe, it, shouldBe, shouldSatisfy)
import Type.Reflection (someTypeRep)
data ApplicationType
instance CanonicalTypeName ApplicationType where
canonicalTypeName _ = T.pack "ApplicationType-v1"
data LifecycleState
= Pending
| Active
| Complete
deriving stock (Generic)
instance CanonicalStateShape LifecycleState
data LifecycleStateWithoutComplete
= PendingWithoutComplete
| ActiveWithoutComplete
deriving stock (Generic)
instance CanonicalStateShape LifecycleStateWithoutComplete
data LifecycleStateWithRenamedConstructor
= Waiting
| ActiveRenamed
| CompleteRenamed
deriving stock (Generic)
instance CanonicalStateShape LifecycleStateWithRenamedConstructor
data RecordStateInt = RecordStateInt
{ recordCountInt :: Int,
recordNoteInt :: Maybe Text
}
deriving stock (Generic)
instance CanonicalStateShape RecordStateInt
data RecordStateText = RecordStateText
{ recordCountText :: Text,
recordNoteText :: Maybe Text
}
deriving stock (Generic)
instance CanonicalStateShape RecordStateText
type BuiltInSlots =
'[ '("unit", ()),
'("bool", Bool),
'("char", Char),
'("int", Int),
'("int8", Int8),
'("int16", Int16),
'("int32", Int32),
'("int64", Int64),
'("integer", Integer),
'("word", Word),
'("word8", Word8),
'("word16", Word16),
'("word32", Word32),
'("word64", Word64),
'("double", Double),
'("float", Float),
'("text", Text),
'("utcTime", UTCTime),
'("day", Day),
'("maybe", Maybe Int),
'("list", [Text]),
'("either", Either Int Text),
'("pair", (Int, Text)),
'("triple", (Int, Text, Bool))
]
spec :: Spec
spec = do
describe "stateShapeCanonical" $ do
it "records datatype name, constructor names, and declaration order" $
stateShapeCanonical (Proxy @LifecycleState)
`shouldBe` T.pack "state:LifecycleState{Pending|Active|Complete}"
it "records constructor field types but not record field names" $
stateShapeCanonical (Proxy @RecordStateInt)
`shouldBe` T.pack "state:RecordStateInt{RecordStateInt(Int,Maybe(Text))}"
describe "stateShapeHash" $ do
it "changes when an enum constructor is removed" $
stateShapeHash (Proxy @LifecycleState)
`shouldSatisfy` (/= stateShapeHash (Proxy @LifecycleStateWithoutComplete))
it "changes when an enum constructor is renamed" $
stateShapeHash (Proxy @LifecycleState)
`shouldSatisfy` (/= stateShapeHash (Proxy @LifecycleStateWithRenamedConstructor))
it "changes when a record field type changes" $
stateShapeHash (Proxy @RecordStateInt)
`shouldSatisfy` (/= stateShapeHash (Proxy @RecordStateText))
it "is stable and matches its sha256Hex-of-canonical definition" $ do
let p = Proxy @LifecycleState
stateShapeHash p `shouldBe` stateShapeHash p
stateShapeHash p `shouldBe` sha256Hex (stateShapeCanonical p)
describe "renderStableTypeRep" $ do
it "renders Int as GHC.Types.Int (no module path drift on 9.12.*)" $
renderStableTypeRep (someTypeRep (Proxy @Int))
`shouldBe` T.pack "GHC.Types.Int"
it "renders Maybe Int as a parenthesised application" $
renderStableTypeRep (someTypeRep (Proxy @(Maybe Int)))
`shouldBe` T.pack "GHC.Internal.Maybe.Maybe(GHC.Types.Int)"
it "renders UTCTime as its time-library module path" $
renderStableTypeRep (someTypeRep (Proxy @UTCTime))
`shouldBe` T.pack "Data.Time.Clock.Internal.UTCTime.UTCTime"
describe "regFileShapeCanonical" $ do
it "anchors the empty slot list at \"regfile:0\"" $
regFileShapeCanonical (Proxy @('[] :: [(Symbol, Type)]))
`shouldBe` T.pack "regfile:0"
it "concatenates one slot in the documented R3 form" $
regFileShapeCanonical (Proxy @('[ '("retryCount", Int)] :: [(Symbol, Type)]))
`shouldBe` T.pack "retryCount:Int;regfile:0"
it "keeps GHC-internal module paths out of every built-in name" $ do
let canonical = regFileShapeCanonical (Proxy @BuiltInSlots)
canonical `shouldSatisfy` (not . T.isInfixOf (T.pack "GHC.Internal"))
canonical `shouldSatisfy` (not . T.isInfixOf (T.pack "GHC.Types"))
it "propagates an application override through containers" $
regFileShapeCanonical
(Proxy @('[ '("application", Maybe ApplicationType)] :: [(Symbol, Type)]))
`shouldBe` T.pack "application:Maybe(ApplicationType-v1);regfile:0"
describe "regFileShapeHash" $ do
it "produces the pinned SHA-256 of \"regfile:0\" for the empty list" $
regFileShapeHash (Proxy @('[] :: [(Symbol, Type)]))
`shouldBe` T.pack "0b262a9e301796f7a5b36bb6ea874e9ffccf7d1b4aff78a8d4b5436bd23914a6"
it "produces the pinned hash for a one-slot list (retryCount :: Int)" $
regFileShapeHash (Proxy @('[ '("retryCount", Int)] :: [(Symbol, Type)]))
`shouldBe` T.pack "de03289268ae222f84d8a1b9af8f4f78bc9d23a747c97c12f4974e2504485978"
it "differs when slot order is reversed (P10: slot order is identity)" $
regFileShapeHash
(Proxy @('[ '("retryCount", Int), '("cooldownUntil", UTCTime)] :: [(Symbol, Type)]))
`shouldBe` T.pack "22a08cf2b847545bf0ce24f505de379ee49c2edb8c2236b6f6bcfadba984b1ea"
it "matches its sha256Hex-of-canonical definition" $ do
let p = Proxy @('[ '("retryCount", Int), '("cooldownUntil", UTCTime)] :: [(Symbol, Type)])
regFileShapeHash p `shouldBe` sha256Hex (regFileShapeCanonical p)
describe "sha256Hex" $ do
-- "" → e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
-- "abc" → ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad
it "matches the empty-string SHA-256 vector" $
sha256Hex (T.pack "")
`shouldBe` T.pack "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
it "matches the \"abc\" SHA-256 vector" $
sha256Hex (T.pack "abc")
`shouldBe` T.pack "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"