packages feed

seihou-core-0.4.0.0: test/Seihou/Composition/GraphSpec.hs

module Seihou.Composition.GraphSpec (tests) where

import Data.Either (isLeft)
import Data.Map.Strict qualified as Map
import Seihou.Composition.Graph
import Seihou.Composition.Instance (ModuleInstance (..), mkInstance, primaryInstance)
import Seihou.Core.Types
import Test.Hspec
import Test.Tasty
import Test.Tasty.Hspec (testSpec)

tests :: IO TestTree
tests = testSpec "Seihou.Composition.Graph" spec

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

-- | Helper to build a graph from a list of modules, wrapping each in a
-- primary 'ModuleInstance' (no parent bindings). Works for all the
-- bare-name test scenarios below.
fromModules :: [Module] -> CompositionGraph
fromModules ms = buildGraph [(primaryInstance m.name, m) | m <- ms]

spec :: Spec
spec = do
  describe "buildGraph" $ do
    it "builds a graph from a single module with no dependencies" $ do
      let m = mkModule "base" []
          g = fromModules [m]
      length g.cgModules `shouldBe` 1
      length g.cgEdges `shouldBe` 1

    it "builds a graph preserving dependency edges" $ do
      let a = mkModule "a" ["b", "c"]
          b = mkModule "b" []
          c = mkModule "c" []
          g = fromModules [a, b, c]
      length g.cgModules `shouldBe` 3
      length g.cgEdges `shouldBe` 3

  describe "topoSort" $ do
    it "returns a single module with no dependencies" $ do
      let g = fromModules [mkModule "base" []]
      fmap (map (.instanceModule)) (topoSort g) `shouldBe` Right ["base"]

    it "orders a linear chain: A -> B -> C" $ do
      let a = mkModule "a" ["b"]
          b = mkModule "b" ["c"]
          c = mkModule "c" []
          g = fromModules [a, b, c]
      case topoSort g of
        Right order -> do
          let names = map (.instanceModule) order
          indexOf "c" names `shouldSatisfy` (< indexOf "b" names)
          indexOf "b" names `shouldSatisfy` (< indexOf "a" names)
        Left err -> expectationFailure $ "Expected Right, got: " ++ show err

    it "handles a diamond: A -> [B,C], B -> D, C -> D" $ do
      let a = mkModule "a" ["b", "c"]
          b = mkModule "b" ["d"]
          c = mkModule "c" ["d"]
          d = mkModule "d" []
          g = fromModules [a, b, c, d]
      case topoSort g of
        Right order -> do
          let names = map (.instanceModule) order
          length names `shouldBe` 4
          indexOf "d" names `shouldSatisfy` (< indexOf "b" names)
          indexOf "d" names `shouldSatisfy` (< indexOf "c" names)
          indexOf "b" names `shouldSatisfy` (< indexOf "a" names)
          indexOf "c" names `shouldSatisfy` (< indexOf "a" names)
        Left err -> expectationFailure $ "Expected Right, got: " ++ show err

    it "detects a self-loop" $ do
      let a = mkModule "a" ["a"]
          g = fromModules [a]
      topoSort g `shouldSatisfy` isLeft

    it "detects a cycle: A -> B -> C -> A" $ do
      let a = mkModule "a" ["b"]
          b = mkModule "b" ["c"]
          c = mkModule "c" ["a"]
          g = fromModules [a, b, c]
      topoSort g `shouldSatisfy` isLeft

    it "handles disconnected components" $ do
      let a = mkModule "a" []
          b = mkModule "b" []
          c = mkModule "c" []
          g = fromModules [a, b, c]
      case topoSort g of
        Right order -> length order `shouldBe` 3
        Left err -> expectationFailure $ "Expected Right, got: " ++ show err

    it "handles a larger graph with multiple dependencies" $ do
      let e = mkModule "e" []
          d = mkModule "d" ["e"]
          c = mkModule "c" ["e"]
          b = mkModule "b" ["c", "d"]
          a = mkModule "a" ["b"]
          g = fromModules [a, b, c, d, e]
      case topoSort g of
        Right order -> do
          let names = map (.instanceModule) order
          length names `shouldBe` 5
          indexOf "e" names `shouldSatisfy` (< indexOf "d" names)
          indexOf "e" names `shouldSatisfy` (< indexOf "c" names)
          indexOf "c" names `shouldSatisfy` (< indexOf "b" names)
          indexOf "d" names `shouldSatisfy` (< indexOf "b" names)
          indexOf "b" names `shouldSatisfy` (< indexOf "a" names)
        Left err -> expectationFailure $ "Expected Right, got: " ++ show err

  describe "multi-instantiation" $ do
    it "treats two dependency edges with different depVars as distinct instances" $ do
      let helper = mkModule "helper" []
          -- Parent depends on 'helper' twice with different bindings.
          parent' =
            Module
              { name = "parent",
                version = Nothing,
                description = Nothing,
                vars = [],
                exports = [],
                prompts = [],
                steps = [],
                commands = [],
                dependencies =
                  [ Dependency "helper" (Map.singleton "skill.name" "exec-plan"),
                    Dependency "helper" (Map.singleton "skill.name" "master-plan")
                  ],
                removal = Nothing,
                migrations = []
              }
          parentInst = primaryInstance "parent"
          helperA = mkInstance "helper" (ParentVars (Map.singleton "skill.name" "exec-plan"))
          helperB = mkInstance "helper" (ParentVars (Map.singleton "skill.name" "master-plan"))
          g = buildGraph [(parentInst, parent'), (helperA, helper), (helperB, helper)]
      case topoSort g of
        Right order -> do
          length order `shouldBe` 3
          helperA `elem` order `shouldBe` True
          helperB `elem` order `shouldBe` True
          parentInst `elem` order `shouldBe` True
          -- Both helper invocations must precede the parent.
          let idx x = length (takeWhile (/= x) order)
          idx helperA `shouldSatisfy` (< idx parentInst)
          idx helperB `shouldSatisfy` (< idx parentInst)
        Left err -> expectationFailure $ "Expected Right, got: " ++ show err

    it "dedupes two identical dependency edges into one instance" $ do
      let child = mkModule "child" []
          parent' =
            Module
              { name = "parent",
                version = Nothing,
                description = Nothing,
                vars = [],
                exports = [],
                prompts = [],
                steps = [],
                commands = [],
                dependencies =
                  [ Dependency "child" (Map.singleton "x" "1"),
                    Dependency "child" (Map.singleton "x" "1")
                  ],
                removal = Nothing,
                migrations = []
              }
          parentInst = primaryInstance "parent"
          childInst = mkInstance "child" (ParentVars (Map.singleton "x" "1"))
          g = buildGraph [(parentInst, parent'), (childInst, child)]
      case topoSort g of
        Right order -> length order `shouldBe` 2
        Left err -> expectationFailure $ "Expected Right, got: " ++ show err

-- | Get the index of a ModuleName in a list, or fail.
indexOf :: ModuleName -> [ModuleName] -> Int
indexOf name xs = case lookup name (zip xs [0 ..]) of
  Just i -> i
  Nothing -> error $ "Module not found in order: " ++ show name