jsonlogic-hs-0.1.0.0: conformance/Conformance.hs
-- |
-- Conformance runner for the classic JsonLogic test suite
-- (@test/tests.json@, from jsonlogic.com/tests.json).
--
-- The suite is a JSON array. Each case is a three-element array
-- @[rule, data, expected]@; bare strings are section comments. Every
-- case is run through 'apply' and its result compared with @expected@.
--
-- This runner cannot pass by running nothing. It exits non-zero if:
--
-- * the fixture is missing, unreadable, or not a JSON array;
-- * any entry is neither a three-element case nor a string comment;
-- * the number of cases is not exactly 'pinnedCases'; or
-- * any case fails.
--
-- The classic suite passes in full today. Should a change make a case
-- fail, it is either a bug to fix or a deliberate deviation to record
-- in docs/COMPATIBILITY.md: a human call, not a threshold. If the suite is
-- ever updated, change 'pinnedCases' in the same commit.
module Main (main) where
import Control.Monad (unless)
import Data.Aeson (Value (..), eitherDecodeFileStrict, encode)
import qualified Data.ByteString.Lazy.Char8 as Char8
import Data.Foldable (for_, toList)
import System.Directory (doesFileExist)
import System.Exit (exitFailure)
import System.IO (hPutStrLn, stderr)
import JsonLogic (apply)
suitePath :: FilePath
suitePath = "test/tests.json"
-- | The exact number of cases in the vendored suite.
pinnedCases :: Int
pinnedCases = 278
main :: IO ()
main = do
exists <- doesFileExist suitePath
unless exists $
abort
( "required fixture "
++ suitePath
++ " not found (run from the package root).\n"
++ " The classic suite is vendored in the repository; restore it with:\n"
++ " git checkout -- "
++ suitePath
)
decoded <- eitherDecodeFileStrict suitePath
case decoded of
Left reason -> abort ("cannot read " ++ suitePath ++ ": " ++ reason)
Right (Array entries) -> report (toList entries)
Right _ -> abort (suitePath ++ " is not a JSON array")
abort :: String -> IO a
abort message = hPutStrLn stderr ("conformance: " ++ message) >> exitFailure
-- | A case, a comment, or neither.
data Entry = Case Value Value Value | Comment | Malformed Value
classify :: Value -> Entry
classify (Array parts) = case toList parts of
[rule, input, expected] -> Case rule input expected
_ -> Malformed (Array parts)
classify (String _) = Comment
classify other = Malformed other
report :: [Value] -> IO ()
report entries = do
let classified = map classify entries
cases = [(rule, input, expected) | Case rule input expected <- classified]
malformed = [v | Malformed v <- classified]
failures =
[ ((rule, input, expected), outcome)
| (rule, input, expected) <- cases
, let outcome = apply rule input
, outcome /= Right expected
]
for_ malformed $ \v ->
hPutStrLn stderr ("conformance: entry is neither a case nor a comment: " ++ render v)
for_ failures $ \((rule, input, expected), outcome) -> do
putStrLn ("FAIL " ++ render rule ++ " on " ++ render input)
putStrLn (" expected " ++ render expected)
putStrLn (" got " ++ either show render outcome)
putStrLn
( show (length cases - length failures)
++ " of "
++ show (length cases)
++ " cases pass; "
++ show (length entries - length cases - length malformed)
++ " comments skipped."
)
unless (length cases == pinnedCases) $
abort
( "expected exactly "
++ show pinnedCases
++ " cases but found "
++ show (length cases)
++ ": the fixture is truncated or changed"
)
unless (null malformed) exitFailure
unless (null failures) exitFailure
render :: Value -> String
render = Char8.unpack . encode