seihou-core-0.4.0.0: test/Seihou/Dhall/MigrationDecoderSpec.hs
module Seihou.Dhall.MigrationDecoderSpec (tests) where
import Data.Text (Text)
import Data.Text qualified as T
import Data.Text.IO qualified as TIO
import Seihou.Core.Migration (Migration (..), MigrationOp (..))
import Seihou.Core.Types (Module (..), ModuleLoadError)
import Seihou.Dhall.Eval (evalModuleFromFile)
import System.Directory (createDirectoryIfMissing)
import System.FilePath ((</>))
import System.IO.Temp (withSystemTempDirectory)
import Test.Hspec
import Test.Tasty
import Test.Tasty.Hspec (testSpec)
tests :: IO TestTree
tests = testSpec "Seihou.Dhall.MigrationDecoder" spec
spec :: Spec
spec = do
describe "moduleDecoder migrations field" $ do
it "decodes a module with no migrations field as []" $
withModuleDhall noMigrationsField $ \result ->
case result of
Right m -> m.migrations `shouldBe` []
Left err -> expectationFailure ("Expected Right, got: " <> show err)
it "decodes a module with an empty migrations list" $
withModuleDhall emptyMigrations $ \result ->
case result of
Right m -> m.migrations `shouldBe` []
Left err -> expectationFailure ("Expected Right, got: " <> show err)
it "decodes a single MoveFile migration" $
withModuleDhall (oneMigration moveFileOp) $ \result ->
case result of
Right m -> case m.migrations of
[Migration {from = "1.0.0", to = "2.0.0", ops = [op]}] ->
op `shouldBe` MoveFile {src = "old/Path.hs", dest = "new/Path.hs"}
other -> expectationFailure ("Unexpected migrations: " <> show other)
Left err -> expectationFailure ("Expected Right, got: " <> show err)
it "decodes a MoveDir migration" $
withModuleDhall (oneMigration moveDirOp) $ \result ->
case result of
Right m -> case m.migrations of
[Migration {ops = [op]}] ->
op `shouldBe` MoveDir {src = "app", dest = "src"}
other -> expectationFailure ("Unexpected migrations: " <> show other)
Left err -> expectationFailure ("Expected Right, got: " <> show err)
it "decodes a DeleteFile migration" $
withModuleDhall (oneMigration deleteFileOp) $ \result ->
case result of
Right m -> case m.migrations of
[Migration {ops = [op]}] ->
op `shouldBe` DeleteFile {path = "Setup.hs"}
other -> expectationFailure ("Unexpected migrations: " <> show other)
Left err -> expectationFailure ("Expected Right, got: " <> show err)
it "decodes a DeleteDir migration" $
withModuleDhall (oneMigration deleteDirOp) $ \result ->
case result of
Right m -> case m.migrations of
[Migration {ops = [op]}] ->
op `shouldBe` DeleteDir {path = "obsolete"}
other -> expectationFailure ("Unexpected migrations: " <> show other)
Left err -> expectationFailure ("Expected Right, got: " <> show err)
it "decodes a RunCommand migration without workDir" $
withModuleDhall (oneMigration runCommandNoWorkDir) $ \result ->
case result of
Right m -> case m.migrations of
[Migration {ops = [op]}] ->
op `shouldBe` RunCommand {run = "echo hi", workDir = Nothing}
other -> expectationFailure ("Unexpected migrations: " <> show other)
Left err -> expectationFailure ("Expected Right, got: " <> show err)
it "decodes a RunCommand migration with workDir" $
withModuleDhall (oneMigration runCommandWithWorkDir) $ \result ->
case result of
Right m -> case m.migrations of
[Migration {ops = [op]}] ->
op `shouldBe` RunCommand {run = "make clean", workDir = Just "build"}
other -> expectationFailure ("Unexpected migrations: " <> show other)
Left err -> expectationFailure ("Expected Right, got: " <> show err)
it "decodes multiple ops in a single migration in declaration order" $
withModuleDhall multiOpMigration $ \result ->
case result of
Right m -> case m.migrations of
[Migration {ops}] ->
ops
`shouldBe` [ MoveDir {src = "app", dest = "src"},
DeleteFile {path = "Setup.hs"},
RunCommand {run = "echo hi", workDir = Nothing}
]
other -> expectationFailure ("Unexpected migrations: " <> show other)
Left err -> expectationFailure ("Expected Right, got: " <> show err)
it "decodes multiple migrations in declaration order" $
withModuleDhall twoChainedMigrations $ \result ->
case result of
Right m -> case m.migrations of
[m1, m2] -> do
m1.from `shouldBe` "1.0.0"
m1.to `shouldBe` "2.0.0"
m2.from `shouldBe` "2.0.0"
m2.to `shouldBe` "3.0.0"
other -> expectationFailure ("Unexpected migrations: " <> show other)
Left err -> expectationFailure ("Expected Right, got: " <> show err)
-- ----------------------------------------------------------------------------
-- Helper: write a tmp module.dhall and run the file evaluator on it.
-- ----------------------------------------------------------------------------
withModuleDhall ::
Text ->
(Either ModuleLoadError Module -> IO ()) ->
IO ()
withModuleDhall body action =
withSystemTempDirectory "seihou-migration-decoder" $ \dir -> do
createDirectoryIfMissing True (dir </> "files")
let path = dir </> "module.dhall"
TIO.writeFile path body
result <- evalModuleFromFile path
action result
-- ----------------------------------------------------------------------------
-- Module fixtures expressed inline as Dhall text.
-- ----------------------------------------------------------------------------
-- Bare-record skeleton for a module without the migrations field. The
-- decoder's withDefaults injects an empty list when the field is absent.
noMigrationsField :: Text
noMigrationsField =
T.unlines
[ "{ name = \"sample\"",
", version = Some \"1.0.0\"",
", description = None Text",
", vars = [] : List { name : Text, type : Text, default : Optional Text, description : Optional Text, required : Bool, validation : Optional Text }",
", exports = [] : List { var : Text, alias : Optional Text }",
", prompts = [] : List { var : Text, text : Text, when : Optional Text, choices : Optional (List Text) }",
", steps = [] : List { strategy : Text, src : Text, dest : Text, when : Optional Text, patch : Optional Text }",
", commands = [] : List { run : Text, workDir : Optional Text, when : Optional Text }",
", dependencies = [] : List { module : Text, vars : List { name : Text, value : Text } }",
", removal = None { steps : List { action : Text, dest : Text, src : Optional Text }, commands : List { run : Text, workDir : Optional Text, when : Optional Text } }",
"}"
]
migrationFieldType :: Text
migrationFieldType =
"List { from : Text, to : Text, ops : List < MoveFile : { src : Text, dest : Text } | MoveDir : { src : Text, dest : Text } | DeleteFile : { path : Text } | DeleteDir : { path : Text } | RunCommand : { run : Text, workDir : Optional Text } > }"
emptyMigrations :: Text
emptyMigrations = withMigrations ("[] : " <> migrationFieldType)
oneMigration :: Text -> Text
oneMigration opLit =
withMigrations $
T.intercalate
"\n"
[ "[ { from = \"1.0.0\"",
" , to = \"2.0.0\"",
" , ops = [ " <> opLit <> " ]",
" }",
"]"
]
multiOpMigration :: Text
multiOpMigration =
withMigrations $
T.intercalate
"\n"
[ "[ { from = \"1.0.0\"",
" , to = \"2.0.0\"",
" , ops =",
" [ " <> moveDirOp,
" , " <> deleteFileOp,
" , " <> runCommandNoWorkDir,
" ]",
" }",
"]"
]
twoChainedMigrations :: Text
twoChainedMigrations =
withMigrations $
T.intercalate
"\n"
[ "[ { from = \"1.0.0\"",
" , to = \"2.0.0\"",
" , ops = [ " <> moveDirOp <> " ]",
" }",
", { from = \"2.0.0\"",
" , to = \"3.0.0\"",
" , ops = [ " <> deleteFileOp <> " ]",
" }",
"]"
]
-- | Splice an explicit migrations literal into the bare-record skeleton.
withMigrations :: Text -> Text
withMigrations migrationsLit =
T.unlines
[ "{ name = \"sample\"",
", version = Some \"1.0.0\"",
", description = None Text",
", vars = [] : List { name : Text, type : Text, default : Optional Text, description : Optional Text, required : Bool, validation : Optional Text }",
", exports = [] : List { var : Text, alias : Optional Text }",
", prompts = [] : List { var : Text, text : Text, when : Optional Text, choices : Optional (List Text) }",
", steps = [] : List { strategy : Text, src : Text, dest : Text, when : Optional Text, patch : Optional Text }",
", commands = [] : List { run : Text, workDir : Optional Text, when : Optional Text }",
", dependencies = [] : List { module : Text, vars : List { name : Text, value : Text } }",
", removal = None { steps : List { action : Text, dest : Text, src : Optional Text }, commands : List { run : Text, workDir : Optional Text, when : Optional Text } }",
", migrations = " <> migrationsLit,
"}"
]
-- Dhall union literals for each MigrationOp variant. Each is a fully
-- qualified, type-annotated expression so it can be embedded in a list
-- without an enclosing type annotation on the list elements.
migrationOpType :: Text
migrationOpType =
"< MoveFile : { src : Text, dest : Text } | MoveDir : { src : Text, dest : Text } | DeleteFile : { path : Text } | DeleteDir : { path : Text } | RunCommand : { run : Text, workDir : Optional Text } >"
opLit :: Text -> Text -> Text
opLit ctor body =
"(" <> migrationOpType <> ").\n " <> ctor <> " " <> body
moveFileOp :: Text
moveFileOp = opLit "MoveFile" "{ src = \"old/Path.hs\", dest = \"new/Path.hs\" }"
moveDirOp :: Text
moveDirOp = opLit "MoveDir" "{ src = \"app\", dest = \"src\" }"
deleteFileOp :: Text
deleteFileOp = opLit "DeleteFile" "{ path = \"Setup.hs\" }"
deleteDirOp :: Text
deleteDirOp = opLit "DeleteDir" "{ path = \"obsolete\" }"
runCommandNoWorkDir :: Text
runCommandNoWorkDir = opLit "RunCommand" "{ run = \"echo hi\", workDir = None Text }"
runCommandWithWorkDir :: Text
runCommandWithWorkDir = opLit "RunCommand" "{ run = \"make clean\", workDir = Some \"build\" }"