taiwan-id-0.1.1.0: components/test/taiwan-id-cli-test/Main.hs
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE DerivingStrategies #-}
{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE NumericUnderscores #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TypeApplications #-}
{-# OPTIONS_GHC -Wno-orphans #-}
{- HLINT ignore "Functor law" -}
module Main (main) where
import Control.Monad
( unless
)
import Control.Monad.Random
( evalRand
)
import Data.Data
( Proxy (Proxy)
)
import Data.Functor.Identity
( Identity (runIdentity)
)
import Data.Text
( Text
)
import GHC.Stack
( HasCallStack
)
import System.Directory
( createDirectoryIfMissing
, doesFileExist
)
import System.FilePath
( (<.>)
, (</>)
)
import System.Random
( mkStdGen
)
import Taiwan.ID.CLI
( Command
, CommandLineResult (CommandLineFailure, CommandLineSuccess)
, DecodeCommand (DecodeCommand, language)
, GenerateCommand (GenerateCommand, count, seed)
, Stage (Raw)
, ValidateCommand (ValidateCommand, idText)
)
import Test.QuickCheck
( Arbitrary1 (liftArbitrary)
, Gen
, arbitraryBoundedEnum
, choose
)
import Test.QuickCheck.Gen
( unGen
)
import Test.QuickCheck.Random
( mkQCGen
)
import Test.Tasty
( TestTree
, askOption
, defaultIngredients
, defaultMainWithIngredients
, includingOptions
, testGroup
)
import Test.Tasty.HUnit
( assertFailure
, testCase
)
import Test.Tasty.Options
( IsOption (..)
, OptionDescription (Option)
, mkOptionCLParser
)
import Text.Printf
( printf
)
import qualified Data.Text as Text
import qualified Data.Text.IO as TIO
import qualified Taiwan.ID.CLI as CLI
import qualified Taiwan.ID.CLI as Command
import qualified Taiwan.ID.Test as Test
--------------------------------------------------------------------------------
-- Constants
--------------------------------------------------------------------------------
testRootDirectory :: FilePath
testRootDirectory = "data" </> "taiwan-id-cli-test"
-- | Number of golden tests for each command type.
testsPerCommand :: Int
testsPerCommand = 1000
-- | Seed for random number generation when executing CLI commands in tests.
-- Ensures that commands without an explicit '--seed' parameter produce
-- deterministic output.
executionSeed :: Int
executionSeed = 0
-- | Seed for QuickCheck generators that produce test command invocations.
generatorSeed :: Int
generatorSeed = 0
-- | QuickCheck size parameter used by generators.
generatorSize :: Int
generatorSize = 10
--------------------------------------------------------------------------------
-- Modes
--------------------------------------------------------------------------------
-- | The mode in which the test suite runs.
--
-- The default mode (if none is specified) is 'Verify'.
data Mode
= -- | Create golden files from randomly-generated command invocations.
-- Existing golden files are never overwritten.
Create
| -- | Run each recorded command invocation and overwrite the golden file
-- with the observed output if it differs from the expected output.
Update
| -- | Run each recorded command invocation and fail if the observed output
-- differs from the expected output recorded in the golden file.
Verify
deriving stock (Bounded, Enum, Eq, Show)
instance IsOption Mode where
defaultValue = Verify
optionName = pure "mode"
optionHelp = pure modeHelpText
optionCLParser = mkOptionCLParser mempty
parseValue = \case
"create" -> Just Create
"update" -> Just Update
"verify" -> Just Verify
_ -> Nothing
modeHelpText :: String
modeHelpText =
"Test mode: create, update, or verify (default)"
--------------------------------------------------------------------------------
-- Main
--------------------------------------------------------------------------------
data CommandSpec = CommandSpec
{ commandName :: String
, commandGen :: Gen (Command Raw)
}
commandSpecs :: [CommandSpec]
commandSpecs =
[ CommandSpec "decode" genCommandDecode
, CommandSpec "generate" genCommandGenerate
, CommandSpec "validate" genCommandValidate
]
main :: IO ()
main = defaultMainWithIngredients ingredients testTree
where
ingredients = includingOptions [Option (Proxy @Mode)] : defaultIngredients
testTree =
testGroup
"CLI"
[ testGroup commandName (testsFromDirectory commandSpec)
| commandSpec@CommandSpec {commandName} <- commandSpecs
]
testsFromDirectory :: CommandSpec -> [TestTree]
testsFromDirectory spec =
[ testFromIndex spec index
| index <- take testsPerCommand [0 ..]
]
testFromIndex :: CommandSpec -> Int -> TestTree
testFromIndex CommandSpec {commandName, commandGen} index =
askOption $ \mode ->
testCase testFilePath $
case mode of
Create -> create
Update -> update
Verify -> verify
where
testDirectoryPath :: FilePath
testDirectoryPath = testRootDirectory </> commandName
testFilePath :: FilePath
testFilePath = testDirectoryPath </> padIndex index <.> "golden"
where
padIndex :: Int -> FilePath
padIndex = printf "%03d"
create :: IO ()
create = do
exists <- doesFileExist testFilePath
unless exists $ do
createDirectoryIfMissing True testDirectoryPath
writeCommandExpectationToFile testFilePath expectation
where
expectation :: CommandExpectation
expectation = commandInvocationToExpectation invocation
invocation :: CommandInvocation
invocation =
unGen
(genCommandInvocation commandGen)
(mkQCGen (generatorSeed + index))
generatorSize
update :: IO ()
update = do
expectation <- readCommandExpectationFromFile testFilePath
let observedOutputLines =
runCommandLine (inputCommandLineArgs expectation)
unless (observedOutputLines == expectedOutputLines expectation) $
writeCommandExpectationToFile
testFilePath
expectation {expectedOutputLines = observedOutputLines}
verify :: IO ()
verify = do
CommandExpectation
{ inputCommandLineArgs
, expectedOutputLines
} <-
readCommandExpectationFromFile testFilePath
let observedOutputLines = runCommandLine inputCommandLineArgs
unless (observedOutputLines == expectedOutputLines) $
assertFailure $
Text.unpack $
Text.unlines
[ "path:"
, blockIndent [Text.pack testFilePath]
, "input:"
, blockIndent [renderPrompt inputCommandLineArgs]
, "output expected:"
, blockIndent expectedOutputLines
, "output observed:"
, blockIndent observedOutputLines
]
where
blockIndent :: [Text] -> Text
blockIndent = Text.unlines . map (" " <>)
--------------------------------------------------------------------------------
-- Command expectations
--------------------------------------------------------------------------------
-- | Bundles a command with the output we expect from running the command.
data CommandExpectation = CommandExpectation
{ inputCommandLineArgs :: [Text]
, expectedOutputLines :: [Text]
}
writeCommandExpectationToFile :: FilePath -> CommandExpectation -> IO ()
writeCommandExpectationToFile
path
CommandExpectation {inputCommandLineArgs, expectedOutputLines} =
TIO.writeFile path $
Text.unlines $
renderPrompt inputCommandLineArgs : expectedOutputLines
readCommandExpectationFromFile
:: HasCallStack => FilePath -> IO CommandExpectation
readCommandExpectationFromFile path = do
contents <- TIO.readFile path
case Text.lines contents of
[] -> reportEmptyFile
(prompt : expectedOutputLines) ->
case parsePrompt prompt of
Just inputCommandLineArgs ->
pure CommandExpectation {inputCommandLineArgs, expectedOutputLines}
Nothing ->
reportInvalidPrompt
where
reportEmptyFile =
assertFailure $
unwords
[ "readCommandExpectationFromFile: empty file:"
, path
]
reportInvalidPrompt =
assertFailure $
unwords
[ "readCommandExpectationFromFile: invalid prompt line in:"
, path
]
-- | The prefix used for prompt lines in golden files.
promptPrefix :: Text
promptPrefix = "$ taiwan-id "
-- | Parse the arguments from a prompt line of the form:
-- @$ taiwan-id <args...>@
-- Returns 'Nothing' if the line does not have the expected prefix.
parsePrompt :: Text -> Maybe [Text]
parsePrompt = fmap Text.words . Text.stripPrefix promptPrefix
-- | Render a list of arguments as a prompt line of the form:
-- @$ taiwan-id <args...>@
renderPrompt :: [Text] -> Text
renderPrompt args = promptPrefix <> Text.unwords args
--------------------------------------------------------------------------------
-- CLI execution
--------------------------------------------------------------------------------
runCommandLine :: [Text] -> [Text]
runCommandLine args =
case result of
CommandLineSuccess ls -> ls
CommandLineFailure ls -> ls
where
result =
evalRand
(CLI.run (map Text.unpack args))
(mkStdGen executionSeed)
commandInvocationToExpectation :: CommandInvocation -> CommandExpectation
commandInvocationToExpectation CommandInvocation {command, optionStyle} =
CommandExpectation
{ inputCommandLineArgs
, expectedOutputLines = runCommandLine inputCommandLineArgs
}
where
inputCommandLineArgs = renderInvocationArgs optionStyle command
renderInvocationArgs :: OptionStyle -> Command Raw -> [Text]
renderInvocationArgs style =
removeEmptyArgs . \case
Command.Decode DecodeCommand {idText, language} ->
["decode", runIdentity idText]
++ renderOption style "language" language
Command.Generate GenerateCommand {count, seed} ->
["generate"]
++ renderOption style "count" count
++ renderOption style "seed" seed
Command.Validate ValidateCommand {idText} ->
["validate", runIdentity idText]
where
removeEmptyArgs :: [Text] -> [Text]
removeEmptyArgs = filter (not . Text.null)
renderOption :: Show a => OptionStyle -> Text -> Maybe a -> [Text]
renderOption _ _ Nothing = []
renderOption style name (Just value) = case style of
EqualSeparated -> ["--" <> name <> "=" <> Text.show value]
SpaceSeparated -> ["--" <> name, Text.show value]
--------------------------------------------------------------------------------
-- Command invocations
--------------------------------------------------------------------------------
data CommandInvocation = CommandInvocation
{ command :: Command Raw
, optionStyle :: OptionStyle
}
deriving stock (Eq, Show)
data OptionStyle
= EqualSeparated
| SpaceSeparated
deriving stock (Bounded, Enum, Eq, Show)
--------------------------------------------------------------------------------
-- Generators
--------------------------------------------------------------------------------
genCommandInvocation :: Gen (Command Raw) -> Gen CommandInvocation
genCommandInvocation genCommand =
CommandInvocation
<$> genCommand
<*> genOptionStyle
genCommandDecode :: Gen (Command Raw)
genCommandDecode = do
idText <- liftArbitrary Test.genIDText
language <- liftArbitrary Test.genLanguage
pure $ Command.Decode $ DecodeCommand {idText, language}
genCommandGenerate :: Gen (Command Raw)
genCommandGenerate = do
count <- liftArbitrary (choose (-1, 4))
seed <- liftArbitrary (choose (1, 1_000_000))
pure $ Command.Generate $ GenerateCommand {count, seed}
genCommandValidate :: Gen (Command Raw)
genCommandValidate = do
idText <- liftArbitrary Test.genIDText
pure $ Command.Validate $ ValidateCommand {idText}
genOptionStyle :: Gen OptionStyle
genOptionStyle = arbitraryBoundedEnum