packages feed

seihou-core-0.12.0.0: test/Seihou/Interaction/PromptSpec.hs

module Seihou.Interaction.PromptSpec (tests) where

import Control.Lens ((&), (.~), (^.))
import Data.Generics.Labels ()
import Data.Map.Strict qualified as Map
import Data.Text (Text)
import Effectful
import Seihou.Composition.Instance (primaryInstance)
import Seihou.Composition.Resolve (PromptPermission (..), RecipeInputSpec (..), resolveRecipeWithPrompts, resolveWithPromptPermission, resolveWithPrompts)
import Seihou.Core.Types
import Seihou.Effect.ConsolePure
import Seihou.Interaction.Prompt (promptForVar, runPrompts)
import Test.Hspec
import Test.Tasty
import Test.Tasty.Hspec (testSpec)

tests :: IO TestTree
tests = testSpec "Seihou.Interaction.Prompt" spec

-- | Helper to create a minimal module.
mkModule :: ModuleName -> [ModuleName] -> [VarDecl] -> [VarExport] -> [Prompt] -> Module
mkModule name deps vars exports prompts =
  Module
    { name = name,
      version = Nothing,
      description = Nothing,
      vars = vars,
      exports = exports,
      prompts = prompts,
      steps = [],
      commands = [],
      dependencies = map simpleDep deps,
      removal = Nothing,
      migrations = []
    }

-- | Helper to create a text variable declaration.
mkTextVar :: VarName -> Maybe VarValue -> Bool -> VarDecl
mkTextVar name defVal required =
  VarDecl
    { name = name,
      type_ = VTText,
      default_ = defVal,
      description = Nothing,
      required = required,
      validation = Nothing
    }

-- | Helper to create a bool variable declaration.
mkBoolVar :: VarName -> Maybe VarValue -> Bool -> VarDecl
mkBoolVar name defVal required =
  VarDecl
    { name = name,
      type_ = VTBool,
      default_ = defVal,
      description = Nothing,
      required = required,
      validation = Nothing
    }

-- | Helper to create a simple prompt for a variable.
mkPrompt :: VarName -> Text -> Prompt
mkPrompt var text =
  Prompt
    { var = var,
      text = text,
      condition = Nothing,
      choices = Nothing
    }

-- | Helper to create a prompt with choices.
mkChoicePrompt :: VarName -> Text -> [Text] -> Prompt
mkChoicePrompt var text choices =
  Prompt
    { var = var,
      text = text,
      condition = Nothing,
      choices = Just choices
    }

-- | Helper to create a prompt with a when condition.
mkConditionalPrompt :: VarName -> Text -> Expr -> Prompt
mkConditionalPrompt var text expr =
  Prompt
    { var = var,
      text = text,
      condition = Just expr,
      choices = Nothing
    }

-- | Helper to create an export without alias.
mkExport :: VarName -> VarExport
mkExport name = VarExport {var = name, alias = Nothing}

spec :: Spec
spec = do
  describe "recipe input resolution" $ do
    let feature = mkBoolVar "feature" Nothing True
        consumer = mkModule "consumer" [] [mkBoolVar "feature" (Just (VBool False)) False] [] []
        modules = [(primaryInstance "consumer", consumer, "/fixture")]
        resolve permission spec saved cli literals local =
          resolveRecipeWithPrompts permission False spec modules saved cli literals Map.empty "" "" local Map.empty Map.empty Map.empty
        recipe = RecipeInputSpec [feature] [mkPrompt "feature" "Feature?"] Nothing
    it "requires the recipe choice before considering a consumer default" $ do
      let (result, _) = runPureEff $ runConsolePureNonInteractive $ resolve PromptsForbidden recipe Map.empty Map.empty Map.empty Map.empty
      result `shouldBe` Left [MissingRequiredVar "feature"]
    it "broadcasts explicit false and retains prompt source" $ do
      let (result, console) = runPureEff $ runConsolePure ["false"] $ resolve PromptsAllowed recipe Map.empty Map.empty Map.empty Map.empty
      case result of
        Left errs -> expectationFailure (show errs)
        Right (inputs, values) -> do
          (inputs Map.! "feature") ^. #value `shouldBe` VBool False
          (values Map.! primaryInstance "consumer" Map.! "feature") ^. #source `shouldBe` FromPrompt
      console ^. #outputs `shouldBe` ["Feature?"]
    it "keeps CLI above literals, and literals above config" $ do
      let check cli literal local = fst $ runPureEff $ runConsolePureNonInteractive $ resolve PromptsForbidden recipe Map.empty cli literal local
      case check (Map.singleton "feature" "false") (Map.singleton "feature" "true") Map.empty of
        Right (inputs, _) -> (inputs Map.! "feature") ^. #value `shouldBe` VBool False
        Left errs -> expectationFailure (show errs)
      case check Map.empty (Map.singleton "feature" "false") (Map.singleton "feature" "true") of
        Right (inputs, _) -> (inputs Map.! "feature") ^. #value `shouldBe` VBool False
        Left errs -> expectationFailure (show errs)
    it "lets optional conditions see resolved siblings despite another missing input" $ do
      let details = mkTextVar "details" Nothing False
          spec =
            RecipeInputSpec
              [feature, mkBoolVar "other" Nothing True, details]
              [mkPrompt "other" "Other?", mkConditionalPrompt "details" "Details?" (ExprEq "feature" (VBool True))]
              Nothing
          (result, console) = runPureEff $ runConsolePure ["false", "owner"] $ resolve PromptsAllowed spec Map.empty Map.empty Map.empty (Map.singleton "feature" "true")
      case result of
        Right (inputs, _) -> (inputs Map.! "details") ^. #value `shouldBe` VText "owner"
        Left errs -> expectationFailure (show errs)
      console ^. #outputs `shouldBe` ["Other?", "Details? [skip]:"]
    it "rejects exhausted invalid optional answers" $ do
      let optional = feature & #required .~ False
          spec = RecipeInputSpec [optional] [mkPrompt "feature" "Feature?"] Nothing
          (result, _) = runPureEff $ runConsolePure ["bad", "bad", "bad"] $ resolve PromptsAllowed spec Map.empty Map.empty Map.empty Map.empty
      result `shouldBe` Left [CoercionFailed "feature" VTBool "bad"]
    it "adopts historical false through recipe coercion and rejects disagreeing consumers" $ do
      let second = primaryInstance "second"
          both = modules <> [(second, consumer, "/second")]
          check saved cli =
            fst $
              runPureEff $
                runConsolePureNonInteractive $
                  resolveRecipeWithPrompts PromptsForbidden False recipe both saved cli Map.empty Map.empty "" "" Map.empty Map.empty Map.empty Map.empty
          saved a b = Map.fromList [(primaryInstance "consumer", Map.singleton "feature" a), (second, Map.singleton "feature" b)]
      case check (saved "false" "no") Map.empty of
        Right (inputs, _) -> (inputs Map.! "feature") ^. #value `shouldBe` VBool False
        Left errs -> expectationFailure (show errs)
      case check (saved "false" "true") Map.empty of
        Left [ValidationFailed "feature" _] -> pure ()
        other -> expectationFailure (show other)
      case check (saved "false" "true") (Map.singleton "feature" "true") of
        Right (inputs, _) -> (inputs Map.! "feature") ^. #value `shouldBe` VBool True
        Left errs -> expectationFailure (show errs)
    it "validates the broadcast against each consumer declaration" $ do
      let invalid = consumer & #vars .~ [mkTextVar "feature" Nothing True & #validation .~ Just (ValMinLength 10)]
          (result, _) =
            runPureEff $
              runConsolePureNonInteractive $
                resolveRecipeWithPrompts PromptsForbidden False recipe [(primaryInstance "consumer", invalid, "/fixture")] Map.empty (Map.singleton "feature" "true") Map.empty Map.empty "" "" Map.empty Map.empty Map.empty Map.empty
      case result of
        Left [ValidationFailed "feature" _] -> pure ()
        other -> expectationFailure (show other)
    it "replays recorded choices before config and skips accepted optional omissions" $ do
      let optional = mkTextVar "details" Nothing False
          spec = RecipeInputSpec [feature, optional] [mkPrompt "details" "Details?"] (Just (Map.singleton "feature" "false"))
          (result, console) = runPureEff $ runConsolePure [] $ resolve PromptsAllowed spec Map.empty Map.empty Map.empty (Map.singleton "feature" "true")
      case result of
        Right (inputs, _) -> (inputs Map.! "feature") ^. #value `shouldBe` VBool False
        Left errs -> expectationFailure (show errs)
      console ^. #outputs `shouldBe` []

  describe "runPrompts" $ do
    it "fills a required text variable with FromPrompt source" $ do
      let decl = mkTextVar "project.name" Nothing True
          prompt = mkPrompt "project.name" "What is the project name?"
          bindings = Map.empty
      (result, st) <-
        runEff $
          runConsolePure ["my-app"] $
            runPrompts [prompt] [decl] bindings
      Map.member "project.name" result `shouldBe` True
      let rv = result Map.! "project.name"
      (rv ^. #value) `shouldBe` VText "my-app"
      (rv ^. #source) `shouldBe` FromPrompt
      -- The prompt text should have been output
      (st ^. #outputs) `shouldSatisfy` any (== "What is the project name?")

    it "fills a prompt with choices via selection number" $ do
      let decl = mkTextVar "license" Nothing True
          prompt = mkChoicePrompt "license" "Choose a license:" ["MIT", "Apache-2.0", "BSD-3-Clause"]
          bindings = Map.empty
      (result, _st) <-
        runEff $
          runConsolePure ["2"] $
            runPrompts [prompt] [decl] bindings
      Map.member "license" result `shouldBe` True
      ((result Map.! "license") ^. #value) `shouldBe` VText "Apache-2.0"
      ((result Map.! "license") ^. #source) `shouldBe` FromPrompt

    it "skips a prompt whose when condition evaluates to False" $ do
      let decl = mkTextVar "extra.flag" Nothing True
          -- Condition: IsSet license — but license is not in bindings
          prompt = mkConditionalPrompt "extra.flag" "Extra flag?" (ExprIsSet "license")
          bindings = Map.empty
      (result, st) <-
        runEff $
          runConsolePure ["some-value"] $
            runPrompts [prompt] [decl] bindings
      -- Prompt was skipped, so the variable is not resolved
      Map.member "extra.flag" result `shouldBe` False
      -- No prompt text was output
      (st ^. #outputs) `shouldSatisfy` all (/= "Extra flag?")

    it "shows a prompt whose when condition evaluates to True" $ do
      let decl = mkTextVar "extra.flag" Nothing True
          -- Condition: IsSet license — license IS in bindings
          prompt = mkConditionalPrompt "extra.flag" "Extra flag?" (ExprIsSet "license")
          bindings = Map.singleton "license" (VText "MIT")
      (result, _st) <-
        runEff $
          runConsolePure ["some-value"] $
            runPrompts [prompt] [decl] bindings
      Map.member "extra.flag" result `shouldBe` True
      ((result Map.! "extra.flag") ^. #value) `shouldBe` VText "some-value"

    it "skips a prompt for a variable not in the unresolved set" $ do
      let decl = mkTextVar "project.name" Nothing True
          -- Prompt references a different variable
          prompt = mkPrompt "other.var" "Other?"
          bindings = Map.empty
      (result, st) <-
        runEff $
          runConsolePure ["anything"] $
            runPrompts [prompt] [decl] bindings
      Map.null result `shouldBe` True
      (st ^. #outputs) `shouldSatisfy` all (/= "Other?")

  describe "default value display" $ do
    it "shows default value in prompt text and accepts Enter" $ do
      let decl = mkTextVar "project.version" (Just (VText "0.1.0.0")) True
          prompt = mkPrompt "project.version" "Project version"
      (result, st) <-
        runEff $
          runConsolePure [""] $
            promptForVar prompt decl Map.empty
      case result of
        Left err -> expectationFailure $ "Expected Right, got: " ++ show err
        Right rv -> do
          (rv ^. #value) `shouldBe` VText "0.1.0.0"
          (rv ^. #source) `shouldBe` FromPrompt
      -- Prompt text should include the default in brackets
      (st ^. #outputs) `shouldSatisfy` any (== "Project version [0.1.0.0]:")

    it "accepts user input over default when provided" $ do
      let decl = mkTextVar "project.version" (Just (VText "0.1.0.0")) True
          prompt = mkPrompt "project.version" "Project version"
      (result, st) <-
        runEff $
          runConsolePure ["1.0.0"] $
            promptForVar prompt decl Map.empty
      case result of
        Left err -> expectationFailure $ "Expected Right, got: " ++ show err
        Right rv ->
          (rv ^. #value) `shouldBe` VText "1.0.0"
      (st ^. #outputs) `shouldSatisfy` any (== "Project version [0.1.0.0]:")

    it "shows [skip] for optional variable without default" $ do
      let decl = mkTextVar "license" Nothing False
          prompt = mkPrompt "license" "License"
      (_result, st) <-
        runEff $
          runConsolePure [""] $
            promptForVar prompt decl Map.empty
      (st ^. #outputs) `shouldSatisfy` any (== "License [skip]:")

    it "shows bool default as yes/no" $ do
      let decl = mkBoolVar "enable.ci" (Just (VBool True)) False
          prompt = mkPrompt "enable.ci" "Enable CI?"
      (result, st) <-
        runEff $
          runConsolePure [""] $
            promptForVar prompt decl Map.empty
      case result of
        Left err -> expectationFailure $ "Expected Right, got: " ++ show err
        Right rv ->
          (rv ^. #value) `shouldBe` VBool True
      (st ^. #outputs) `shouldSatisfy` any (== "Enable CI? [yes]:")

  describe "promptForVar" $ do
    it "coerces boolean input correctly" $ do
      let decl = mkBoolVar "use.ci" Nothing True
          prompt = mkPrompt "use.ci" "Enable CI?"
      (result, _st) <-
        runEff $
          runConsolePure ["yes"] $
            promptForVar prompt decl Map.empty
      case result of
        Left err -> expectationFailure $ "Expected Right, got: " ++ show err
        Right rv -> do
          (rv ^. #value) `shouldBe` VBool True
          (rv ^. #source) `shouldBe` FromPrompt

    it "coerces 'no' to False for boolean variable" $ do
      let decl = mkBoolVar "use.ci" Nothing True
          prompt = mkPrompt "use.ci" "Enable CI?"
      (result, _st) <-
        runEff $
          runConsolePure ["no"] $
            promptForVar prompt decl Map.empty
      case result of
        Left err -> expectationFailure $ "Expected Right, got: " ++ show err
        Right rv ->
          (rv ^. #value) `shouldBe` VBool False

    it "retries on empty input then succeeds" $ do
      let decl = mkTextVar "project.name" Nothing True
          prompt = mkPrompt "project.name" "Project name?"
      (result, st) <-
        runEff $
          runConsolePure ["", "my-app"] $
            promptForVar prompt decl Map.empty
      case result of
        Left err -> expectationFailure $ "Expected Right, got: " ++ show err
        Right rv ->
          (rv ^. #value) `shouldBe` VText "my-app"
      -- Should have output a retry message
      (st ^. #outputs) `shouldSatisfy` any (== "Value cannot be empty. Please try again.")

    it "fails after exhausting retries on empty input" $ do
      let decl = mkTextVar "project.name" Nothing True
          prompt = mkPrompt "project.name" "Project name?"
      (result, _st) <-
        runEff $
          runConsolePure ["", "", ""] $
            promptForVar prompt decl Map.empty
      case result of
        Left (MissingRequiredVar _) -> pure ()
        Left err -> expectationFailure $ "Expected MissingRequiredVar, got: " ++ show err
        Right _ -> expectationFailure "Expected Left, got Right"

  describe "resolveWithPrompts" $ do
    it "prompts for missing required variables in interactive mode" $ do
      let m =
            mkModule
              "base"
              []
              [mkTextVar "project.name" Nothing True]
              []
              [mkPrompt "project.name" "What is the project name?"]
          modules = [(primaryInstance (m ^. #name), m, "/fake/base")]
      (result, st) <-
        runEff $
          runConsolePure ["my-app"] $
            resolveWithPrompts modules Map.empty Map.empty "" "" Map.empty Map.empty Map.empty Map.empty
      case result of
        Left errs -> expectationFailure $ "Expected Right, got: " ++ show errs
        Right resolved -> do
          let baseVars = resolved Map.! primaryInstance "base"
          ((baseVars Map.! "project.name") ^. #value) `shouldBe` VText "my-app"
          ((baseVars Map.! "project.name") ^. #source) `shouldBe` FromPrompt
      (st ^. #outputs) `shouldSatisfy` any (== "What is the project name?")

    it "does not prompt when all variables are provided via CLI" $ do
      let m =
            mkModule
              "base"
              []
              [mkTextVar "project.name" Nothing True]
              []
              [mkPrompt "project.name" "What is the project name?"]
          modules = [(primaryInstance (m ^. #name), m, "/fake/base")]
          cliOverrides = Map.singleton "project.name" "from-cli"
      (result, st) <-
        runEff $
          runConsolePure [] $
            resolveWithPrompts modules cliOverrides Map.empty "" "" Map.empty Map.empty Map.empty Map.empty
      case result of
        Left errs -> expectationFailure $ "Expected Right, got: " ++ show errs
        Right resolved -> do
          let baseVars = resolved Map.! primaryInstance "base"
          ((baseVars Map.! "project.name") ^. #value) `shouldBe` VText "from-cli"
          ((baseVars Map.! "project.name") ^. #source) `shouldBe` FromCLI
      -- No prompts should have been displayed
      (st ^. #outputs) `shouldSatisfy` all (/= "What is the project name?")

    it "skips prompts and errors in non-interactive mode" $ do
      let m =
            mkModule
              "base"
              []
              [mkTextVar "project.name" Nothing True]
              []
              [mkPrompt "project.name" "What is the project name?"]
          modules = [(primaryInstance (m ^. #name), m, "/fake/base")]
      (result, st) <-
        runEff $
          runConsolePureNonInteractive $
            resolveWithPrompts modules Map.empty Map.empty "" "" Map.empty Map.empty Map.empty Map.empty
      case result of
        Left errs -> case errs of
          [MissingRequiredVar name] -> name `shouldBe` "project.name"
          [other] -> expectationFailure $ "Expected MissingRequiredVar, got: " ++ show other
          _ -> expectationFailure $ "Expected exactly 1 error, got: " ++ show (length errs)
        Right _ -> expectationFailure "Expected Left (errors), got Right"
      -- No prompts should have been displayed
      (st ^. #outputs) `shouldSatisfy` all (/= "What is the project name?")

    it "forbids prompts even when the Console interpreter is interactive" $ do
      let m =
            mkModule
              "base"
              []
              [mkTextVar "project.name" Nothing True]
              []
              [mkPrompt "project.name" "What is the project name?"]
          modules = [(primaryInstance (m ^. #name), m, "/fake/base")]
      (result, st) <-
        runEff $
          runConsolePure ["must-not-be-read"] $
            resolveWithPromptPermission PromptsForbidden modules Map.empty Map.empty Map.empty "" "" Map.empty Map.empty Map.empty Map.empty
      result `shouldBe` Left [MissingRequiredVar "project.name"]
      (st ^. #inputs) `shouldBe` ["must-not-be-read"]
      (st ^. #outputs) `shouldSatisfy` all (/= "What is the project name?")

    it "prompts for optional variables after required resolution" $ do
      let m =
            mkModule
              "base"
              []
              [ mkTextVar "project.name" Nothing True,
                mkTextVar "license" Nothing False
              ]
              []
              [ mkPrompt "project.name" "What is the project name?",
                mkPrompt "license" "License"
              ]
          modules = [(primaryInstance (m ^. #name), m, "/fake/base")]
      (result, st) <-
        runEff $
          runConsolePure ["my-app", "MIT"] $
            resolveWithPrompts modules Map.empty Map.empty "" "" Map.empty Map.empty Map.empty Map.empty
      case result of
        Left errs -> expectationFailure $ "Expected Right, got: " ++ show errs
        Right resolved -> do
          let baseVars = resolved Map.! primaryInstance "base"
          ((baseVars Map.! "project.name") ^. #value) `shouldBe` VText "my-app"
          ((baseVars Map.! "project.name") ^. #source) `shouldBe` FromPrompt
          ((baseVars Map.! "license") ^. #value) `shouldBe` VText "MIT"
          ((baseVars Map.! "license") ^. #source) `shouldBe` FromPrompt
      (st ^. #outputs) `shouldSatisfy` any (== "Optional configuration:")

    it "skips optional variable when user presses Enter" $ do
      let m =
            mkModule
              "base"
              []
              [ mkTextVar "project.name" Nothing True,
                mkTextVar "license" Nothing False
              ]
              []
              [ mkPrompt "project.name" "What is the project name?",
                mkPrompt "license" "License"
              ]
          modules = [(primaryInstance (m ^. #name), m, "/fake/base")]
      (result, _st) <-
        runEff $
          runConsolePure ["my-app", ""] $
            resolveWithPrompts modules Map.empty Map.empty "" "" Map.empty Map.empty Map.empty Map.empty
      case result of
        Left errs -> expectationFailure $ "Expected Right, got: " ++ show errs
        Right resolved -> do
          let baseVars = resolved Map.! primaryInstance "base"
          Map.member "project.name" baseVars `shouldBe` True
          Map.member "license" baseVars `shouldBe` False

    it "shows Optional configuration separator for optional-only modules" $ do
      let m =
            mkModule
              "base"
              []
              [mkTextVar "license" Nothing False]
              []
              [mkPrompt "license" "License"]
          modules = [(primaryInstance (m ^. #name), m, "/fake/base")]
      (result, st) <-
        runEff $
          runConsolePure ["MIT"] $
            resolveWithPrompts modules Map.empty Map.empty "" "" Map.empty Map.empty Map.empty Map.empty
      case result of
        Left errs -> expectationFailure $ "Expected Right, got: " ++ show errs
        Right resolved -> do
          let baseVars = resolved Map.! primaryInstance "base"
          ((baseVars Map.! "license") ^. #value) `shouldBe` VText "MIT"
      (st ^. #outputs) `shouldSatisfy` any (== "Optional configuration:")

    it "does not show optional prompts in non-interactive mode" $ do
      let m =
            mkModule
              "base"
              []
              [mkTextVar "license" Nothing False]
              []
              [mkPrompt "license" "License"]
          modules = [(primaryInstance (m ^. #name), m, "/fake/base")]
      (result, st) <-
        runEff $
          runConsolePureNonInteractive $
            resolveWithPrompts modules Map.empty Map.empty "" "" Map.empty Map.empty Map.empty Map.empty
      case result of
        Left _ -> expectationFailure "Expected Right (no required vars)"
        Right resolved -> do
          let baseVars = resolved Map.! primaryInstance "base"
          Map.member "license" baseVars `shouldBe` False
      (st ^. #outputs) `shouldSatisfy` all (/= "Optional configuration:")

    it "respects when condition on optional prompts" $ do
      let m =
            mkModule
              "base"
              []
              [ mkTextVar "project.name" Nothing True,
                mkTextVar "extra" Nothing False
              ]
              []
              [ mkPrompt "project.name" "Name?",
                mkConditionalPrompt "extra" "Extra?" (ExprIsSet "nonexistent")
              ]
          modules = [(primaryInstance (m ^. #name), m, "/fake/base")]
      (result, st) <-
        runEff $
          runConsolePure ["my-app"] $
            resolveWithPrompts modules Map.empty Map.empty "" "" Map.empty Map.empty Map.empty Map.empty
      case result of
        Left errs -> expectationFailure $ "Expected Right, got: " ++ show errs
        Right resolved -> do
          let baseVars = resolved Map.! primaryInstance "base"
          Map.member "extra" baseVars `shouldBe` False
      -- The condition was false so Optional configuration header should not appear
      -- (no optional prompts actually fired)
      (st ^. #outputs) `shouldSatisfy` all (/= "Extra?")

    it "does not prompt for optional variables already resolved via config" $ do
      let m =
            mkModule
              "base"
              []
              [mkTextVar "license" Nothing False]
              []
              [mkPrompt "license" "License"]
          modules = [(primaryInstance (m ^. #name), m, "/fake/base")]
          globalConfig = Map.singleton "license" "MIT"
      (result, st) <-
        runEff $
          runConsolePure [] $
            resolveWithPrompts modules Map.empty Map.empty "" "" Map.empty Map.empty Map.empty globalConfig
      case result of
        Left errs -> expectationFailure $ "Expected Right, got: " ++ show errs
        Right resolved -> do
          let baseVars = resolved Map.! primaryInstance "base"
          ((baseVars Map.! "license") ^. #value) `shouldBe` VText "MIT"
          ((baseVars Map.! "license") ^. #source) `shouldBe` FromGlobalConfig
      (st ^. #outputs) `shouldSatisfy` all (/= "Optional configuration:")

    it "flows prompted value from first module to second via exports" $ do
      let base =
            mkModule
              "base"
              []
              [mkTextVar "project.name" Nothing True]
              [mkExport "project.name"]
              [mkPrompt "project.name" "What is the project name?"]
          app =
            mkModule
              "app"
              ["base"]
              [mkTextVar "project.name" Nothing True]
              []
              []
          modules = [(primaryInstance (base ^. #name), base, "/fake/base"), (primaryInstance (app ^. #name), app, "/fake/app")]
      (result, st) <-
        runEff $
          runConsolePure ["my-app"] $
            resolveWithPrompts modules Map.empty Map.empty "" "" Map.empty Map.empty Map.empty Map.empty
      case result of
        Left errs -> expectationFailure $ "Expected Right, got: " ++ show errs
        Right resolved -> do
          -- Base module was prompted
          let baseVars = resolved Map.! primaryInstance "base"
          ((baseVars Map.! "project.name") ^. #value) `shouldBe` VText "my-app"
          ((baseVars Map.! "project.name") ^. #source) `shouldBe` FromPrompt
          -- App module received the value via export (no additional prompt needed)
          let appVars = resolved Map.! primaryInstance "app"
          ((appVars Map.! "project.name") ^. #value) `shouldBe` VText "my-app"
      -- Only one prompt should have fired (for base), not two
      let promptOutputs = filter (== "What is the project name?") (st ^. #outputs)
      length promptOutputs `shouldBe` 1