packages feed

seihou-core-0.6.0.0: test/Seihou/Core/ApplicationSpec.hs

module Seihou.Core.ApplicationSpec (tests) where

import Control.Lens ((&), (?~), (^.))
import Data.Generics.Labels ()
import Data.Map.Strict qualified as Map
import Data.Set qualified as Set
import Data.Text (Text)
import Data.Text qualified as T
import Data.Time (UTCTime, defaultTimeLocale, parseTimeOrError)
import Seihou.Composition.Instance (ModuleInstance (..))
import Seihou.Core.Application
import Seihou.Core.Types
import Seihou.Manifest.Hash (hashContent)
import Test.Hspec
import Test.Tasty
import Test.Tasty.Hspec (testSpec)

tests :: IO TestTree
tests = testSpec "Seihou.Core.Application" spec

fixedTime :: UTCTime
fixedTime = parseTimeOrError True defaultTimeLocale "%Y-%m-%dT%H:%M:%SZ" "2026-07-19T10:30:00Z"

moduleTarget :: AppliedTarget
moduleTarget = AppliedModuleTarget (ModuleName "master-plan")

mkModule :: ModuleName -> Maybe Text -> Module
mkModule name version =
  Module
    { name = name,
      version = version,
      description = Nothing,
      vars = [],
      exports = [],
      prompts = [],
      steps = [],
      commands = [],
      dependencies = [],
      removal = Nothing,
      migrations = []
    }

mkResolved :: VarName -> VarValue -> ResolvedVar
mkResolved name value =
  ResolvedVar
    { value = value,
      source = FromDefault,
      decl =
        VarDecl
          { name = name,
            type_ = VTText,
            default_ = Nothing,
            description = Nothing,
            required = False,
            validation = Nothing
          }
    }

mkComposition :: AppliedTarget -> [ModuleName] -> AppliedComposition
mkComposition target additional =
  AppliedComposition
    { applicationId = mkApplicationId target additional,
      target = target,
      targetOrigin = LocalOrigin "root",
      targetVersion = Just "1.0.0",
      additionalModules = additional,
      namespace = Just "root",
      context = Nothing,
      instances = [],
      commandReceipts = Map.empty,
      appliedAt = fixedTime
    }

spec :: Spec
spec = do
  describe "mkApplicationId" $ do
    it "is deterministic and uses the full SHA-256 digest" $ do
      let first = mkApplicationId moduleTarget [ModuleName "docs"]
          second = mkApplicationId moduleTarget [ModuleName "docs"]
      first `shouldBe` second
      T.length (first ^. #unApplicationId) `shouldBe` 64

    it "changes when additional-root order changes" $ do
      let first = mkApplicationId moduleTarget [ModuleName "a", ModuleName "b"]
          second = mkApplicationId moduleTarget [ModuleName "b", ModuleName "a"]
      first `shouldNotBe` second

    it "distinguishes a module target from a recipe target with the same name" $ do
      mkApplicationId (AppliedModuleTarget "shared") []
        `shouldNotBe` mkApplicationId (AppliedRecipeTarget "shared") []

  describe "buildAppliedComposition" $ do
    it "keeps separately-scoped values for two instances of the same module" $ do
      let moduleName = ModuleName "link-skill"
          pv1 = ParentVars (Map.singleton (VarName "skill.name") "exec-plan")
          pv2 = ParentVars (Map.singleton (VarName "skill.name") "master-plan")
          inst1 = ModuleInstance moduleName pv1
          inst2 = ModuleInstance moduleName pv2
          modul = mkModule moduleName (Just "0.7.0")
          modulesInOrder =
            [ (inst1, modul, LocalOrigin "link-skill"),
              (inst2, modul, LocalOrigin "link-skill")
            ]
          resolved =
            Map.fromList
              [ (inst1, Map.singleton (VarName "skill.name") (mkResolved "skill.name" (VText "exec-plan"))),
                (inst2, Map.singleton (VarName "skill.name") (mkResolved "skill.name" (VText "master-plan")))
              ]
          composition =
            buildAppliedComposition moduleTarget (LocalOrigin "master-plan") (Just "0.7.0") [] (Just "docs") Nothing modulesInOrder resolved fixedTime
      map (^. #parentVars) (composition ^. #instances) `shouldBe` [pv1, pv2]
      map (^. #resolvedVars) (composition ^. #instances)
        `shouldBe` [Map.singleton "skill.name" "exec-plan", Map.singleton "skill.name" "master-plan"]

    it "keeps identity independent of versions, source paths, and resolved values" $ do
      let inst = ModuleInstance "dep" emptyParentVars
          first =
            buildAppliedComposition
              moduleTarget
              (LocalOrigin "root")
              (Just "1.0.0")
              ["extra"]
              Nothing
              Nothing
              [(inst, mkModule "dep" (Just "1.0.0"), LocalOrigin "dep")]
              (Map.singleton inst (Map.singleton "value" (mkResolved "value" (VText "old"))))
              fixedTime
          second =
            buildAppliedComposition
              moduleTarget
              (LocalOrigin "root")
              (Just "2.0.0")
              ["extra"]
              Nothing
              Nothing
              [(inst, mkModule "dep" (Just "2.0.0"), LocalOrigin "dep")]
              (Map.singleton inst (Map.singleton "value" (mkResolved "value" (VText "new"))))
              fixedTime
      (first ^. #applicationId) `shouldBe` (second ^. #applicationId)

    it "preserves the original module or recipe target" $ do
      let moduleComposition = buildAppliedComposition moduleTarget (LocalOrigin "module") Nothing [] Nothing Nothing [] Map.empty fixedTime
          recipeTarget = AppliedRecipeTarget "service"
          recipeComposition = buildAppliedComposition recipeTarget (LocalOrigin "recipe") (Just "2") [] Nothing Nothing [] Map.empty fixedTime
      (moduleComposition ^. #target) `shouldBe` moduleTarget
      (recipeComposition ^. #target) `shouldBe` recipeTarget

  describe "replaceAppliedComposition" $ do
    it "replaces in place and appends new applications" $ do
      let first = mkComposition moduleTarget []
          second = mkComposition (AppliedModuleTarget "other") []
          replacement = (first & #targetVersion ?~ "2.0.0")
          third = mkComposition (AppliedRecipeTarget "third") []
      replaceAppliedComposition replacement [first, second] `shouldBe` [replacement, second]
      replaceAppliedComposition third [first, second] `shouldBe` [first, second, third]

  describe "attachApplication" $ do
    it "unions prior and current ownership and preserves the generated baseline" $ do
      let priorId = ApplicationId "prior"
          currentId = ApplicationId "current"
          prior = FileRecord (hashContent "old") "module" Template fixedTime Nothing (Set.singleton priorId)
          current = FileRecord (hashContent "new") "module" Template fixedTime (Just (BaselineRef (hashContent "generated"))) Set.empty
          attached = attachApplication currentId (Just prior) current
      (attached ^. #applicationIds) `shouldBe` Set.fromList [priorId, currentId]
      (attached ^. #baseline) `shouldBe` Just (BaselineRef (hashContent "generated"))