seihou-core-0.14.0.0: test/Seihou/Composition/RecipeSpec.hs
module Seihou.Composition.RecipeSpec (tests) where
import Control.Lens ((&), (.~), (^.))
import Data.Either (isLeft)
import Data.Generics.Labels ()
import Data.Map.Strict qualified as Map
import Data.Text (Text)
import Data.Text qualified as T
import Seihou.Composition.Recipe
import Seihou.Composition.RecipeChain (loadRecipeChain)
import Seihou.Core.Recipe (validateRecipe)
import Seihou.Core.Types
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.Composition.Recipe" spec
expectExpanded :: Recipe -> (ModuleName, [ModuleName], Map.Map VarName Text, [VarDecl], [Prompt], [Dependency])
expectExpanded recipe =
case expandRecipe recipe of
Right expanded -> expanded
Left errs -> error ("expected recipe expansion, got errors: " <> show errs)
spec :: Spec
spec = do
describe "loadRecipeChain" $ do
it "reports indirect base cycles before looking up members" $ do
withSystemTempDirectory "recipe-chain-cycle" $ \dir -> do
let child = derived "child" "base"
createDirectoryIfMissing True (dir </> "base")
writeFile
(dir </> "base" </> "recipe.dhall")
"{ name = \"base\", version = None Text, description = None Text, modules = [] : List Text, vars = [] : List { name : Text, type : Text, default : Optional Text, description : Optional Text, required : Bool, validation : Optional Text }, prompts = [] : List {var : Text, text : Text, when : Optional Text, choices : Optional (List Text)}, extends = Some {recipe = \"child\"} }"
writeChildDescriptor dir
result <- loadRecipeChain dir [dir] child (dir </> "child")
case result of
Left (ValidationError _ messages) -> T.unwords messages `shouldSatisfy` T.isInfixOf "child -> base -> child"
other -> expectationFailure (show other)
it "rejects an explicit source with unknown local provenance" $ do
withSystemTempDirectory "recipe-chain-origin" $ \dir -> do
let child = derived "child" "base" & #extends .~ Just (RecipeReference "base" (Just "https://example.com/registry") Nothing)
createDirectoryIfMissing True (dir </> "base")
writeFile
(dir </> "base" </> "recipe.dhall")
"{ name = \"base\", version = None Text, description = None Text, modules = [\"one\"], vars = [] : List { name : Text, type : Text, default : Optional Text, description : Optional Text, required : Bool, validation : Optional Text }, prompts = [] : List {var : Text, text : Text, when : Optional Text, choices : Optional (List Text)} }"
result <- loadRecipeChain dir [dir] child (dir </> "child")
case result of
Left (ValidationError _ messages) -> T.unwords messages `shouldSatisfy` T.isInfixOf "different or unknown"
other -> expectationFailure (show other)
describe "mergeRecipeChain" $ do
it "keeps oldest primary, deduplicates members, and preserves override attribution across three layers" $ do
let base = flat "base" [bound "one" "old", bound "two" "old"]
child = derived "child" "base" & #moduleOverrides .~ [RecipeModuleOverride "one" (Map.singleton "x" "new")]
grandchild = derived "grandchild" "child"
case mergeRecipeChain (map node [base, child, grandchild]) of
Left errs -> expectationFailure (show errs)
Right expanded -> do
expanded ^. #primary `shouldBe` "one"
map (^. #dependency . #module_) (expanded ^. #members) `shouldBe` ["one", "two"]
fmap (^. #value) (Map.lookup "x" (expanded ^. #literals)) `shouldBe` Just "new"
fmap (^. #recipe) (Map.lookup "x" (expanded ^. #literals)) `shouldBe` Just "child"
it "rejects unequal same-layer literals across additions and overrides" $ do
let base = flat "base" [bound "one" "old"]
child =
derived "child" "base"
& #modules .~ [bound "two" "different"]
& #moduleOverrides .~ [RecipeModuleOverride "one" (Map.singleton "x" "new")]
mergeRecipeChain (map node [base, child]) `shouldSatisfy` isLeft
it "retains a literal when a child supplies only a declaration default" $ do
let decl = VarDecl "x" VTText Nothing Nothing False Nothing
base = flat "base" [bound "one" "literal"] & #vars .~ [decl]
child = derived "child" "base" & #vars .~ [decl & #default_ .~ Just (VText "default")]
case mergeRecipeChain (map node [base, child]) of
Left errs -> expectationFailure (show errs)
Right expanded -> fmap (^. #value) (Map.lookup "x" (expanded ^. #literals)) `shouldBe` Just "literal"
it "rejects constraint changes and preserves explicitly empty descriptions" $ do
let decl = VarDecl "x" VTText Nothing (Just "parent") False Nothing
base = flat "base" [simpleDep "one"] & #vars .~ [decl]
child = derived "child" "base" & #vars .~ [decl & #description .~ Just ""]
fmap (^. #declarations) (mergeRecipeChain (map node [base, child])) `shouldBe` Right [decl & #description .~ Just ""]
mergeRecipeChain (map node [base, child & #vars .~ [decl & #required .~ True]]) `shouldSatisfy` isLeft
it "replaces inherited prompts in place and resolves inherited declarations" $ do
let decl = VarDecl "x" VTText Nothing Nothing False Nothing
question = Prompt "x" "Old?" Nothing Nothing
replacement = question & #text .~ "New?"
base = flat "base" [simpleDep "one"] & #vars .~ [decl] & #prompts .~ [question]
child = derived "child" "base" & #promptOverrides .~ [replacement]
fmap (^. #prompts) (mergeRecipeChain (map node [base, child])) `shouldBe` Right [replacement]
mergeRecipeChain (map node [base, child & #promptOverrides .~ [] & #prompts .~ [replacement]]) `shouldSatisfy` isLeft
it "rejects a repeated member from another origin" $ do
let base = flat "base" [simpleDep "one"]
child = derived "child" "base" & #modules .~ [simpleDep "one"]
childNode = node child & #members .~ [m & #memberOrigin .~ RemoteOrigin "https://other.example/repo" "one" Nothing | m <- node child ^. #members]
mergeRecipeChain [node base, childNode] `shouldSatisfy` isLeft
it "deduplicates compatible repeated members and rejects missing override targets" $ do
let base = flat "base" [simpleDep "one"]
child = derived "child" "base" & #modules .~ [simpleDep "one"]
fmap (length . (^. #members)) (mergeRecipeChain (map node [base, child])) `shouldBe` Right 1
mergeRecipeChain (map node [base, derived "child" "base" & #moduleOverrides .~ [RecipeModuleOverride "missing" mempty]]) `shouldSatisfy` isLeft
it "enforces exclusive upper bounds and trailing-zero version equivalence" $ do
let base = flat "base" [simpleDep "one"] & #version .~ Just "1.0.0"
child = derived "child" "base" & #extends .~ Just (RecipeReference "base" Nothing (Just (RecipeVersionRange "1" "2")))
mergeRecipeChain (map node [base, child]) `shouldSatisfy` either (const False) (const True)
mergeRecipeChain (map node [base & #version .~ Just "2", child]) `shouldSatisfy` isLeft
mergeRecipeChain (map node [base & #version .~ Nothing, child]) `shouldSatisfy` isLeft
it "does not silently expand an unresolved base through the legacy API" $ do
expandRecipe (derived "child" "base") `shouldSatisfy` isLeft
describe "expandRecipe" $ do
it "keeps the first camelCase binding across the flattened composition" $ do
let recipe = Recipe "policy" Nothing Nothing [Dependency "one" (Map.singleton "git.githubOwner" "first") Nothing, Dependency "two" (Map.singleton "git.githubOwner" "second") Nothing] [] [] Nothing [] []
(_, _, overrides, _, _, _) = expectExpanded recipe
Map.lookup "git.githubOwner" overrides `shouldBe` Just "first"
it "returns correct primary and additional module names" $ do
let recipe =
Recipe
{ name = "combo",
version = Just "1.0.0",
description = Nothing,
modules =
[ simpleDep "mod-a",
Dependency "mod-b" mempty (Just "https://example.com/registry"),
simpleDep "mod-c"
],
vars = [],
prompts = [],
extends = Nothing,
moduleOverrides = [],
promptOverrides = []
}
(primary, additional, _, _, _, requirements) = expectExpanded recipe
primary `shouldBe` ModuleName "mod-a"
additional `shouldBe` [ModuleName "mod-b", ModuleName "mod-c"]
requirements `shouldBe` [simpleDep "mod-a", Dependency "mod-b" mempty (Just "https://example.com/registry"), simpleDep "mod-c"]
it "collects variable overrides from module entries" $ do
let recipe =
Recipe
{ name = "pinned",
version = Nothing,
description = Nothing,
modules =
[ Dependency (ModuleName "base") (Map.singleton "project.name" "my-app") Nothing,
Dependency (ModuleName "nix") (Map.singleton "nix.system" "aarch64-darwin") Nothing
],
vars = [],
prompts = [],
extends = Nothing,
moduleOverrides = [],
promptOverrides = []
}
(_, _, overrides, _, _, _) = expectExpanded recipe
Map.lookup (VarName "project.name") overrides `shouldBe` Just "my-app"
Map.lookup (VarName "nix.system") overrides `shouldBe` Just "aarch64-darwin"
it "passes through recipe-level vars and prompts" $ do
let recipeVar =
VarDecl
{ name = "project.name",
type_ = VTText,
default_ = Nothing,
description = Just "Project name",
required = True,
validation = Nothing
}
recipePrompt =
Prompt
{ var = "project.name",
text = "Enter name:",
condition = Nothing,
choices = Nothing
}
recipe =
Recipe
{ name = "prompted",
version = Nothing,
description = Nothing,
modules = [simpleDep "base"],
vars = [recipeVar],
prompts = [recipePrompt],
extends = Nothing,
moduleOverrides = [],
promptOverrides = []
}
(_, _, _, vars, prompts, _) = expectExpanded recipe
vars `shouldBe` [recipeVar]
prompts `shouldBe` [recipePrompt]
it "handles a single-module recipe (alias)" $ do
let recipe =
Recipe
{ name = "alias",
version = Nothing,
description = Nothing,
modules = [simpleDep "the-module"],
vars = [],
prompts = [],
extends = Nothing,
moduleOverrides = [],
promptOverrides = []
}
(primary, additional, overrides, _, _, _) = expectExpanded recipe
primary `shouldBe` ModuleName "the-module"
additional `shouldBe` []
Map.null overrides `shouldBe` True
it "returns validation errors for an empty recipe" $ do
let recipe =
Recipe
{ name = "empty",
version = Nothing,
description = Nothing,
modules = [],
vars = [],
prompts = [],
extends = Nothing,
moduleOverrides = [],
promptOverrides = []
}
expandRecipe recipe `shouldBe` Left ["modules: recipe must list at least one module"]
it "shares rejection of malformed prompt targets and conditions with descriptor validation" $ do
let recipe =
Recipe
"bad-prompts"
Nothing
Nothing
[simpleDep "unavailable"]
[]
[Prompt "missing-target" "Question" (Just (ExprNot (ExprIsSet "missing-condition"))) Nothing]
Nothing
[]
[]
case validateRecipe recipe of
Left errors -> do
length errors `shouldBe` 2
expandRecipe recipe `shouldBe` Left errors
Right _ -> expectationFailure "Expected malformed prompt references to fail"
flat :: RecipeName -> [Dependency] -> Recipe
flat name deps = Recipe name Nothing Nothing deps [] [] Nothing [] []
derived :: RecipeName -> RecipeName -> Recipe
derived name base = flat name [] & #extends .~ Just (RecipeReference base Nothing Nothing)
bound :: ModuleName -> Text -> Dependency
bound name value = Dependency name (Map.singleton "x" value) Nothing
node :: Recipe -> ResolvedRecipeNode
node recipe = ResolvedRecipeNode recipe origin "." [ResolvedRecipeMember dep (recipe ^. #name) origin "." (LocalOrigin (dep ^. #module_ . #unModuleName)) | dep <- recipe ^. #modules]
where
origin = LocalOrigin (recipe ^. #name . #unRecipeName)
writeChildDescriptor :: FilePath -> IO ()
writeChildDescriptor dir = do
createDirectoryIfMissing True (dir </> "child")
writeFile
(dir </> "child" </> "recipe.dhall")
"{ name = \"child\", version = None Text, description = None Text, modules = [] : List Text, vars = [] : List { name : Text, type : Text, default : Optional Text, description : Optional Text, required : Bool, validation : Optional Text }, prompts = [] : List {var : Text, text : Text, when : Optional Text, choices : Optional (List Text)}, extends = Some {recipe = \"base\"} }"