packages feed

wireform-proto-0.3.0.0: test-regression/Main.hs

{-# LANGUAGE AllowAmbiguousTypes #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TypeApplications #-}

module Main (main) where

import Control.Exception (ErrorCall, evaluate)
import Control.Monad (forM_)
import Data.Aeson qualified as Aeson
import Data.Aeson.Types qualified as Aeson
import Data.ByteString qualified as BS
import Data.Either (isLeft)
import Data.Map.Strict qualified as Map
import Data.Text qualified as T
import Data.Text.IO qualified as TIO
import Generated.Edition2023 qualified as G23
import Generated.Edition2024 qualified as G24
import Generated.Integermaps qualified as GM
import Proto.CodeGen
import Proto.Decode
import Proto.Encode
import Proto.IDL.Parser
import Proto.IDL.Parser.Resolver (ResolvedProto (..))
import Proto.Internal.JSON qualified as JSON
import THFixtures qualified as TH
import Test.Syd


main :: IO ()
main = sydTest $ do
  describe "Editions field presence" $ do
    it "standalone codegen preserves every explicitly present scalar default" $
      forM_ scalarDefaults $
        roundtrip @G23.Fields23
    it "loadProto preserves every explicitly present scalar default" $
      forM_ scalarDefaults $
        roundtrip @TH.Fields23
    it "absent and explicitly zero fields remain distinct in both generators" $ do
      fmap G23.fields23Count (decodeMessage BS.empty) `shouldBe` Right Nothing
      fmap G23.fields23Count (decodeMessage (BS.pack [8, 0])) `shouldBe` Right (Just 0)
      fmap TH.fields23Count (decodeMessage BS.empty) `shouldBe` Right Nothing
      fmap TH.fields23Count (decodeMessage (BS.pack [8, 0])) `shouldBe` Right (Just 0)
    it "file and field overrides, aggregate options and nested messages agree" $ do
      forM_ [BS.pack [16, 0], BS.pack [26, 2, 16, 0]] $ roundtrip @G24.Fields24
      forM_ [BS.pack [16, 0], BS.pack [26, 2, 16, 0]] $ roundtrip @TH.Fields24
      fmap G24.fields24Count (decodeMessage (BS.pack [8, 0])) `shouldBe` Right 0
      fmap TH.fields24Count (decodeMessage (BS.pack [8, 0])) `shouldBe` Right 0
      fmap encodeMessage (decodeMessage (BS.pack [8, 0]) :: Either DecodeError G24.Fields24) `shouldBe` Right BS.empty
      fmap encodeMessage (decodeMessage (BS.pack [8, 0]) :: Either DecodeError TH.Fields24) `shouldBe` Right BS.empty
      fmap encodeMessage (decodeMessage (BS.pack [64, 0, 112, 0]) :: Either DecodeError G23.Fields23) `shouldBe` Right BS.empty
      fmap encodeMessage (decodeMessage (BS.pack [64, 0, 112, 0]) :: Either DecodeError TH.Fields23) `shouldBe` Right BS.empty
    it "repeated, map, oneof and nested fields retain their wire shapes" $ do
      forM_ collectionValues $ roundtrip @G23.Fields23
      forM_ collectionValues $ roundtrip @TH.Fields23
    it "merge preserves an explicitly present zero over a nonzero value" $ do
      case (decodeMessage (BS.pack [8, 7]), decodeMessage (BS.pack [8, 0])) of
        (Right a, Right b) -> encodeMessage (a <> b :: G23.Fields23) `shouldBe` BS.pack [8, 0]
        _ -> expectationFailure "standalone fixture decode failed"
      case (decodeMessage (BS.pack [8, 7]), decodeMessage (BS.pack [8, 0])) of
        (Right a, Right b) -> encodeMessage (a <> b :: TH.Fields23) `shouldBe` BS.pack [8, 0]
        _ -> expectationFailure "TH fixture decode failed"
    it "does not silently accept unsupported LEGACY_REQUIRED presence" $ do
      pf <-
        either (fail . show) pure $
          parseProtoFile
            "unsupported.proto"
            "edition = \"2023\"; message M { int32 x = 1 [features.field_presence = LEGACY_REQUIRED]; }"
      evaluate (T.length (generateModuleText defaultGenerateOpts Map.empty "unsupported.proto" pf))
        `shouldThrow` (\(_ :: ErrorCall) -> True)
    it "committed standalone fixtures exactly match the generator" $
      forM_ ["edition2023", "edition2024", "integerMaps"] $ \name -> do
        let file = name <> ".proto"
        source <- TIO.readFile ("test-regression/proto/" <> file)
        pf <- either (fail . show) pure (parseProtoFile file source)
        let opts = defaultGenerateOpts {genModulePrefix = "Generated"}
            registry = buildTypeRegistry opts [(file, ResolvedProto pf file Map.empty)]
        actual <- TIO.readFile ("test-regression/" <> T.unpack (T.replace "." "/" (moduleNameForProto opts file pf)) <> ".hs")
        actual `shouldBe` generateModuleText opts registry file pf
  describe "Standalone integer maps" $ do
    it "integer value functions compile and agree with TH for limits and zero" $
      forM_ integerMapValues $ \message -> do
        let wire = encodeMessage message
        decodeMessage wire `shouldBe` Right message
        messageSize message `shouldBe` BS.length wire
        encodeMessageSized message `shouldBe` wire
        fmap encodeMessage (decodeMessage wire :: Either DecodeError TH.IntegerMaps) `shouldBe` Right wire
    it "sint32 and sint64 map values use canonical ZigZag and exact sizes" $ do
      let small = GM.defaultIntegerMaps {GM.integerMapsSint32Values = Map.singleton "a" (-1)}
          large = GM.defaultIntegerMaps {GM.integerMapsSint64Values = Map.singleton "a" (-1)}
      encodeMessage small `shouldBe` BS.pack [42, 5, 10, 1, 97, 16, 1]
      encodeMessage large `shouldBe` BS.pack [50, 5, 10, 1, 97, 16, 1]
      messageSize small `shouldBe` 7
      messageSize large `shouldBe` 7
  describe "ProtoJSON quoted numbers" $ do
    it "generated Float and Double accept e/E and signed exponents" $
      forM_ (["1e2", "1E+2", "10000e-2", "1.0e2"] :: [T.Text]) $ \number -> do
        let json = Aeson.object ["value" Aeson..= number, "small" Aeson..= number]
        fmap TH.jsonNumbersValue (Aeson.parseEither Aeson.parseJSON json) `shouldBe` Right 100
        fmap TH.jsonNumbersSmall (Aeson.parseEither Aeson.parseJSON json) `shouldBe` Right 100
    it "optional and oneof floating fields use the same number grammar" $ do
      let json = "{\"optionalValue\":\"1e2\",\"selected\":\"1E2\"}"
      case Aeson.eitherDecode json of
        Left err -> expectationFailure err
        Right result -> do
          TH.jsonNumbersOptionalValue result `shouldBe` Just 100
          case TH.jsonNumbersChoice result of
            Just (TH.JsonNumbers'Choice'Selected value) -> value `shouldBe` 100
            _ -> expectationFailure "quoted oneof number was dropped"
    it "runtime float/double and int64 helpers accept quoted exponents" $ do
      Aeson.parseEither JSON.protoDoubleFromJSON (Aeson.String "1e2") `shouldBe` Right 100
      Aeson.parseEither JSON.protoFloatFromJSON (Aeson.String "1E2") `shouldBe` Right 100
      Aeson.parseEither JSON.protoInt64FromJSON (Aeson.String "1e2") `shouldBe` Right 100
      Aeson.parseEither JSON.protoWord64FromJSON (Aeson.String "1E2") `shouldBe` Right 100
    it "rejects whitespace, trailing junk, noncanonical sentinels and overflow" $
      forM_ ["", " 1", "1 ", "1e", "1e2x", "nan", "NAN", "+nan", "-nan", " NaN", "NaN ", "inf", "INF", "+Infinity", " Infinity", "Infinity ", "-inf", "-INF", " -Infinity", "-Infinity ", "1e1000"] $ \number -> do
        isLeft (Aeson.parseEither JSON.protoDoubleFromJSON (Aeson.String number)) `shouldBe` True
        isLeft (Aeson.parseEither JSON.protoFloatFromJSON (Aeson.String number)) `shouldBe` True
        isLeft (Aeson.parseEither Aeson.parseJSON (Aeson.object ["value" Aeson..= number]) :: Either String TH.JsonNumbers) `shouldBe` True
    it "retains canonical NaN/Infinity strings" $ do
      fmap isNaN (Aeson.parseEither JSON.protoDoubleFromJSON (Aeson.String "NaN")) `shouldBe` Right True
      fmap isInfinite (Aeson.parseEither JSON.protoDoubleFromJSON (Aeson.String "Infinity")) `shouldBe` Right True
      Aeson.parseEither JSON.protoDoubleFromJSON (Aeson.String "-Infinity") `shouldBe` Right (negate (1 / 0))


scalarDefaults :: [BS.ByteString]
scalarDefaults = fmap BS.pack [[8, 0], [18, 0], [26, 0], [32, 0], [40, 0], [49, 0, 0, 0, 0, 0, 0, 0, 0], [61, 0, 0, 0, 0]]


collectionValues :: [BS.ByteString]
collectionValues = fmap BS.pack [[74, 2, 1, 2], [82, 2, 8, 0], [90, 6, 10, 1, 97, 18, 1, 98], [96, 0], [106, 0]]


roundtrip :: forall a. (MessageDecode a, MessageEncode a) => BS.ByteString -> IO ()
roundtrip wire = fmap (encodeMessage @a) (decodeMessage wire) `shouldBe` Right wire


integerMapValues :: [GM.IntegerMaps]
integerMapValues =
  [ GM.defaultIntegerMaps {GM.integerMapsInt32Values = Map.fromList [("a", minBound), ("b", 0), ("c", maxBound)]}
  , GM.defaultIntegerMaps {GM.integerMapsInt64Values = Map.fromList [("a", minBound), ("b", 0), ("c", maxBound)]}
  , GM.defaultIntegerMaps {GM.integerMapsUint32Values = Map.fromList [("a", 0), ("b", maxBound)]}
  , GM.defaultIntegerMaps {GM.integerMapsUint64Values = Map.fromList [("a", 0), ("b", maxBound)]}
  , GM.defaultIntegerMaps {GM.integerMapsSint32Values = Map.fromList [("a", minBound), ("b", 0), ("c", maxBound)]}
  , GM.defaultIntegerMaps {GM.integerMapsSint64Values = Map.fromList [("a", minBound), ("b", 0), ("c", maxBound)]}
  , GM.defaultIntegerMaps {GM.integerMapsSfixed32Values = Map.fromList [("a", minBound), ("b", 0), ("c", maxBound)]}
  , GM.defaultIntegerMaps {GM.integerMapsSfixed64Values = Map.fromList [("a", minBound), ("b", 0), ("c", maxBound)]}
  , GM.defaultIntegerMaps {GM.integerMapsFixed32Values = Map.fromList [("a", 0), ("b", maxBound)]}
  , GM.defaultIntegerMaps {GM.integerMapsFixed64Values = Map.fromList [("a", 0), ("b", maxBound)]}
  , GM.defaultIntegerMaps {GM.integerMapsIntegerKeys = Map.fromList [(minBound, minBound), (0, 0), (maxBound, maxBound)]}
  , GM.defaultIntegerMaps {GM.integerMapsZigzagKeys = Map.fromList [(minBound, "min"), (0, "zero"), (maxBound, "max")]}
  ]