packages feed

nova-cache-0.10.0.0: test/ZstdTest.hs

-- | Tests for the bounded zstd codec.  A separate suite because the
-- codec lives in the nova-cache:zstandard sublibrary; the compressed
-- fixtures come from the sublibrary's own pure 'Zstd.compress', so
-- no external tool runs at test time.
module Main (main) where

import Control.Exception (try)
import Data.ByteString (ByteString)
import qualified Data.ByteString as BS
import Data.IORef (newIORef, readIORef, writeIORef)
import qualified NovaCache.Zstd as Zstd
import System.Exit (exitFailure, exitSuccess)
import System.IO (hFlush, stdout)

-- ---------------------------------------------------------------------------
-- Harness (mirrors test/XzTest.hs)
-- ---------------------------------------------------------------------------

test :: String -> IO Bool -> IO Bool
test name action = do
  putStr ("  " ++ name ++ "... ")
  hFlush stdout
  result <- action
  putStrLn (if result then "OK" else "FAILED")
  pure result

assertEqual :: (Eq a, Show a) => String -> a -> a -> IO Bool
assertEqual label expected actual
  | expected == actual = pure True
  | otherwise = do
      putStrLn ""
      putStrLn ("    " ++ label)
      putStrLn ("    expected: " ++ show expected)
      putStrLn ("    actual:   " ++ show actual)
      pure False

assertTrue :: String -> Bool -> IO Bool
assertTrue _ True = pure True
assertTrue label False = do
  putStrLn ""
  putStrLn ("    " ++ label ++ ": expected True")
  pure False

-- ---------------------------------------------------------------------------
-- Fixtures
-- ---------------------------------------------------------------------------

-- | Compressible ASCII payload, 2048 bytes.
payload :: ByteString
payload = BS.concat (replicate 64 "nova-cache zstd fixture payload\n")

payloadSize :: Word
payloadSize = fromIntegral (BS.length payload)

limitsOf :: Word -> Zstd.ZstdLimits
limitsOf n = Zstd.ZstdLimits {Zstd.zstdMaxOutputBytes = fromIntegral n}

compressedPayload :: ByteString
compressedPayload = Zstd.compress Zstd.defaultCompressionLevel payload

-- | A pull source yielding the given chunks, then empty forever.
chunkSource :: [ByteString] -> IO (IO ByteString)
chunkSource chunks = do
  ref <- newIORef chunks
  pure $ do
    remaining <- readIORef ref
    case remaining of
      [] -> pure BS.empty
      (c : cs) -> writeIORef ref cs >> pure c

-- | Split a payload into bounded chunks so the streaming path sees
-- many small feeds, as a network body would deliver.
chunksOf :: Int -> ByteString -> [ByteString]
chunksOf n bs
  | BS.null bs = []
  | otherwise = BS.take n bs : chunksOf n (BS.drop n bs)

-- | Drain a decompressed pull source into one strict ByteString.
collectSource :: IO ByteString -> IO ByteString
collectSource pull = go []
  where
    go acc = do
      chunk <- pull
      if BS.null chunk
        then pure (BS.concat (reverse acc))
        else go (chunk : acc)

-- ---------------------------------------------------------------------------
-- Tests
-- ---------------------------------------------------------------------------

main :: IO ()
main = do
  putStrLn "zstd"
  results <-
    sequence
      [ test "roundtrip under the exact bound" $ do
          out <- Zstd.decompress (limitsOf payloadSize) compressedPayload
          assertEqual "roundtrip" (Right payload) out,
        test "one byte under the real size refuses" $ do
          out <- Zstd.decompress (limitsOf (payloadSize - 1)) compressedPayload
          assertEqual "over-bound" (Left (Zstd.ZstdOutputOverBound (fromIntegral (payloadSize - 1)))) out,
        test "garbage refuses" $ do
          out <- Zstd.decompress (limitsOf 64) "not a zstd stream"
          assertTrue "stream error" $ case out of
            Left (Zstd.ZstdStreamError _) -> True
            _ -> False,
        test "concatenated frames decode as one output" $ do
          let second = BS.concat (replicate 8 "second frame\n")
              joined = compressedPayload <> Zstd.compress Zstd.defaultCompressionLevel second
          out <- Zstd.decompress (limitsOf (payloadSize + fromIntegral (BS.length second))) joined
          assertEqual "concatenated" (Right (payload <> second)) out,
        test "trailing garbage after a frame refuses" $ do
          out <- Zstd.decompress (limitsOf (payloadSize + 64)) (compressedPayload <> "trailing garbage")
          assertTrue "trailing" $ case out of
            Left (Zstd.ZstdStreamError _) -> True
            _ -> False,
        test "empty input is empty output" $ do
          out <- Zstd.decompress (limitsOf 0) BS.empty
          assertEqual "empty" (Right BS.empty) out,
        test "source: chunked roundtrip" $ do
          source <- chunkSource (chunksOf 7 compressedPayload)
          out <- Zstd.withZstdSource (limitsOf payloadSize) source collectSource
          assertEqual "source roundtrip" payload out,
        test "source: over-bound throws" $ do
          source <- chunkSource (chunksOf 7 compressedPayload)
          out <- try (Zstd.withZstdSource (limitsOf (payloadSize - 1)) source collectSource)
          assertEqual "source over-bound" (Left (Zstd.ZstdOutputOverBound (fromIntegral (payloadSize - 1)))) out,
        test "source: garbage throws" $ do
          source <- chunkSource ["not a zstd stream"]
          out <- try (Zstd.withZstdSource (limitsOf 64) source collectSource) :: IO (Either Zstd.ZstdError ByteString)
          assertTrue "source garbage" $ case out of
            Left (Zstd.ZstdStreamError _) -> True
            _ -> False
      ]
  if and results then exitSuccess else exitFailure