packages feed

rapidhash-0.2.0.0: test/RapidHashTest.hs

module RapidHashTest where

import Control.Monad (forM_)
import Data.Binary qualified as Binary
import Data.ByteString qualified as BS
import Data.ByteString.Short qualified as SBS
import Data.Primitive.PrimArray (primArrayFromList)
import Data.Text qualified as T
import Data.Text.Encoding qualified as TE
import Data.Vector.Primitive qualified as PV
import Data.Vector.Storable qualified as SV
import Data.Word (Word64, Word8)
import Hedgehog (Property, forAll, property, (===))
import Hedgehog.Gen qualified as Gen
import Hedgehog.Range qualified as Range
import Numeric (readHex)
import Test.Tasty.HUnit (Assertion, assertBool, assertEqual, assertFailure, (@?=))

import Data.Hash.RapidHash (
  RapidHash (RapidHash),
  RapidHashMicro (RapidHashMicro),
  RapidSeed (RapidSeed),
  rapidhash,
  rapidhashMicro,
  rapidhashMicroWithSeed,
  rapidhashWithSeed,
 )

unit_showIsTaggedPaddedHex :: Assertion
unit_showIsTaggedPaddedHex = do
  show (RapidHash 0xabc) @?= "rhv3:0000000000000abc"
  show (RapidHashMicro 0xabc) @?= "rhmv3:0000000000000abc"

unit_readRejectsForeignTags :: Assertion
unit_readRejectsForeignTags = do
  reads @RapidHash "rhv4:0000000000000abc" @?= [] -- future
  reads @RapidHash "fnv1a64:0000000000000abc" @?= [] -- other hash
  reads @RapidHash "rhv3:abc" @?= [] -- invalid hash length
  reads @RapidHashMicro "rhmv4:0000000000000abc" @?= []
  reads @RapidHashMicro "fnv1a64:0000000000000abc" @?= []
  reads @RapidHashMicro "rhmv3:abc" @?= []

-- | The two variants disagree above 80 bytes, so a digest of one must never
-- read back as the other — that is the whole point of the distinct tags.
unit_variantTagsDoNotCrossParse :: Assertion
unit_variantTagsDoNotCrossParse = do
  reads @RapidHash (show (RapidHashMicro 0xabc)) @?= []
  reads @RapidHashMicro (show (RapidHash 0xabc)) @?= []

-- | Same, for the compact 9-byte @Binary@ encoding: the leading magic tag must
-- keep the two variants apart.
unit_binaryTagsDoNotCrossDecode :: Assertion
unit_binaryTagsDoNotCrossDecode = do
  let encoded = Binary.encode (RapidHash 0x1234)
      encodedMicro = Binary.encode (RapidHashMicro 0x1234)
  assertBool "micro bytes rejected as RapidHash" $
    isLeft (Binary.decodeOrFail @RapidHash encodedMicro)
  assertBool "rapidhash bytes rejected as RapidHashMicro" $
    isLeft (Binary.decodeOrFail @RapidHashMicro encoded)
  Binary.decode encoded @?= RapidHash 0x1234
  Binary.decode encodedMicro @?= RapidHashMicro 0x1234
 where
  isLeft = either (const True) (const False)

hprop_showReadRoundtrip :: Property
hprop_showReadRoundtrip = property do
  w <- forAll (Gen.word64 Range.linearBounded)
  let h = RapidHash w
  read (show h) === h

-- | Rendering goes through the @Addr#@ prefix literal while parsing goes through
-- the prefix pattern synonym, so this property is what keeps the two definitions
-- of @rhmv3:@ in agreement.
hprop_microShowReadRoundtrip :: Property
hprop_microShowReadRoundtrip = property do
  w <- forAll (Gen.word64 Range.linearBounded)
  let h = RapidHashMicro w
  read (show h) === h

-- | rapidhashMicro's block loop only kicks in above 80 bytes, so up to and
-- including 80 it must agree with rapidhash exactly, at any seed. This pins the
-- boundary the haddocks document.
hprop_microAgreesWithRapidhashThrough80 :: Property
hprop_microAgreesWithRapidhashThrough80 = property do
  len <- forAll (Gen.int (Range.linear 0 80))
  s <- forAll (Gen.word64 Range.linearBounded)
  let bs = bufBS len
      seed = RapidSeed s
      RapidHash expected = rapidhashWithSeed seed bs
      RapidHashMicro actual = rapidhashMicroWithSeed seed bs
  actual === expected
  let RapidHash expected' = rapidhash bs
      RapidHashMicro actual' = rapidhashMicro bs
  actual' === expected'

-- | The other side of the boundary: from 81 bytes on the variants must diverge,
-- otherwise the separate type and tag would be pointless.
unit_microDivergesFrom81 :: Assertion
unit_microDivergesFrom81 =
  forM_ [81, 82, 112, 113, 256, 1024] \len -> do
    let bs = bufBS len
        RapidHash expected = rapidhash bs
        RapidHashMicro actual = rapidhashMicro bs
    assertBool ("variants diverge at length " <> show len) (actual /= expected)

-- | The Text entry hashes the UTF-8 payload in place; it must agree with
-- hashing the encoded bytes (and the ShortByteString copy of them) — at
-- the default seed, at a random seed (pinning the seed plumbing of the
-- ByteArray# entry points), and for a Text with a nonzero internal offset.
hprop_representationsAgree :: Property
hprop_representationsAgree = property do
  t <- forAll (Gen.text (Range.linear 0 300) Gen.unicode)
  s <- forAll (Gen.word64 Range.linearBounded)
  let bytes = TE.encodeUtf8 t
      seed = RapidSeed s
  rapidhash t === rapidhash bytes
  rapidhash (SBS.toShort bytes) === rapidhash bytes
  rapidhash (BS.copy bytes) === rapidhash bytes
  rapidhashWithSeed seed t === rapidhashWithSeed seed bytes
  rapidhashWithSeed seed (SBS.toShort bytes) === rapidhashWithSeed seed bytes
  let t' = T.drop 1 t
  rapidhash t' === rapidhash (TE.encodeUtf8 t')
  -- Same again for the micro variant, which has its own set of FFI wrappers and
  -- its own offset shim.
  rapidhashMicro t === rapidhashMicro bytes
  rapidhashMicro (SBS.toShort bytes) === rapidhashMicro bytes
  rapidhashMicro (BS.copy bytes) === rapidhashMicro bytes
  rapidhashMicroWithSeed seed t === rapidhashMicroWithSeed seed bytes
  rapidhashMicroWithSeed seed (SBS.toShort bytes) === rapidhashMicroWithSeed seed bytes
  rapidhashMicro t' === rapidhashMicro (TE.encodeUtf8 t')

hprop_vectorInstancesAgreeWithBytes :: Property
hprop_vectorInstancesAgreeWithBytes = property do
  ws <- forAll (Gen.list (Range.linear 0 64) (Gen.word64 Range.linearBounded))
  k <- forAll (Gen.int (Range.linear 0 (length ws)))
  let sv = SV.fromList ws
      pv = PV.fromList ws
      pvSlice = PV.drop k pv
      pvBytes (PV.Vector off len ba) = PV.Vector (8 * off) (8 * len) ba :: PV.Vector Word8
  rapidhash sv === rapidhash (SV.unsafeCast sv :: SV.Vector Word8)
  rapidhash pv === rapidhash (pvBytes pv)
  rapidhash sv === rapidhash pv
  rapidhash (primArrayFromList ws) === rapidhash pv
  rapidhash pvSlice === rapidhash (pvBytes pvSlice)
  rapidhashMicro sv === rapidhashMicro (SV.unsafeCast sv :: SV.Vector Word8)
  rapidhashMicro pv === rapidhashMicro (pvBytes pv)
  rapidhashMicro sv === rapidhashMicro pv
  rapidhashMicro (primArrayFromList ws) === rapidhashMicro pv
  rapidhashMicro pvSlice === rapidhashMicro (pvBytes pvSlice)

-- | Known-answer vectors generated from the vendored upstream header by
-- @test/pin/pin.c@ (verify or regenerate with @scripts/rapidhash-pin.sh@
-- in the project repository).
unit_matchesUpstreamCReference :: Assertion
unit_matchesUpstreamCReference = do
  golden <- lines <$> readFile "test/pin/rapidhash-v3-pin.txt"
  assertBool "golden file contains vectors" (not (null golden))
  forM_ golden \line -> case words line of
    ["default", lenS, hashS] ->
      assertEqual line (RapidHash (hex hashS)) $
        rapidhash (bufBS (read lenS))
    ["withSeed", lenS, seedS, hashS] ->
      assertEqual line (RapidHash (hex hashS)) $
        rapidhashWithSeed (RapidSeed (hex seedS)) (bufBS (read lenS))
    ["offset", offS, lenS, seedS, hashS] ->
      assertEqual line (RapidHash (hex hashS)) $
        rapidhashWithSeed (RapidSeed (hex seedS)) (sliceOf (read offS) (read lenS))
    ["micro", lenS, hashS] ->
      assertEqual line (RapidHashMicro (hex hashS)) $
        rapidhashMicro (bufBS (read lenS))
    ["microWithSeed", lenS, seedS, hashS] ->
      assertEqual line (RapidHashMicro (hex hashS)) $
        rapidhashMicroWithSeed (RapidSeed (hex seedS)) (bufBS (read lenS))
    ["microOffset", offS, lenS, seedS, hashS] ->
      assertEqual line (RapidHashMicro (hex hashS)) $
        rapidhashMicroWithSeed (RapidSeed (hex seedS)) (sliceOf (read offS) (read lenS))
    _ -> assertFailure ("unparseable golden line: " <> line)
 where
  hex :: String -> Word64
  hex s = case readHex s of
    [(v, "")] -> v
    _ -> error ("bad hex field in golden file: " <> s)

  -- A sliced Vector, to drive the offset shim rather than a pre-cut buffer.
  sliceOf :: Int -> Int -> PV.Vector Word8
  sliceOf off len = PV.drop off (PV.fromList (bufBytes (off + len)))

-- | Deterministic filler; mirrors byte_at in test/pin/pin.c.
bufBytes :: Int -> [Word8]
bufBytes n = [fromIntegral (i * 167 + 13) | i <- [0 .. n - 1]]

bufBS :: Int -> BS.ByteString
bufBS = BS.pack . bufBytes