seihou-cli-0.4.0.0: test/Seihou/CLI/ListSpec.hs
module Seihou.CLI.ListSpec (tests) where
import Data.Map.Strict qualified as Map
import Data.Text qualified as T
import Seihou.CLI.List (Entry (..), ListFilter (..), applyFilters, formatListOutput, formatListOutputEntries, runnableToEntryWithOrigin)
import Seihou.Core.Module (DiscoveredModule (..), DiscoveredRunnable (..), ModuleSource (..), RunnableKind (..))
import Seihou.Core.Types
import Test.Hspec
import Test.Tasty
import Test.Tasty.Hspec (testSpec)
tests :: IO TestTree
tests = testSpec "Seihou.CLI.List" spec
-- | A valid discovered module for testing.
validModule :: String -> String -> ModuleSource -> DiscoveredModule
validModule name desc src =
DiscoveredModule
{ discoveredResult =
Right
Module
{ name = ModuleName (T.pack name),
version = Nothing,
description = Just (T.pack desc),
vars = [],
exports = [],
prompts = [],
steps = [],
commands = [],
dependencies = [],
removal = Nothing,
migrations = []
},
discoveredSource = src,
discoveredDir = "/fake/" ++ name
}
-- | A broken discovered module for testing.
brokenModule :: String -> ModuleSource -> DiscoveredModule
brokenModule name src =
DiscoveredModule
{ discoveredResult = Left (DhallEvalError (ModuleName (T.pack name)) "parse error"),
discoveredSource = src,
discoveredDir = "/fake/" ++ name
}
-- | Helper to build an Entry for filter tests. Defaults the kind to a module.
mkEntry :: T.Text -> Maybe T.Text -> [T.Text] -> Entry
mkEntry = mkEntryK KindModule
-- | Helper to build an Entry with an explicit kind for kind-filter tests.
mkEntryK :: RunnableKind -> T.Text -> Maybe T.Text -> [T.Text] -> Entry
mkEntryK kind name repo tags =
Entry
{ entryName = name,
entryDesc = "desc",
entrySource = "installed",
entryIsError = False,
entryRepoName = repo,
entryTags = tags,
entryKind = kind
}
noFilter :: ListFilter
noFilter = ListFilter Nothing Nothing []
spec :: Spec
spec = do
describe "formatListOutput" $ do
it "shows no-items message when list is empty" $ do
let result = formatListOutput False [] ["path1", "path2"]
T.isInfixOf "No items found." result `shouldBe` True
T.isInfixOf "path1" result `shouldBe` True
T.isInfixOf "path2" result `shouldBe` True
it "shows available modules header" $ do
let mods = [validModule "test-mod" "A test module" SourceUser]
result = formatListOutput False mods ["p1", "p2", "p3"]
T.isInfixOf "Available modules, recipes, blueprints, and prompts:" result `shouldBe` True
it "shows module name and description" $ do
let mods = [validModule "haskell-base" "Haskell boilerplate" SourceUser]
result = formatListOutput False mods ["p1"]
T.isInfixOf "haskell-base" result `shouldBe` True
T.isInfixOf "Haskell boilerplate" result `shouldBe` True
it "shows source tag in parentheses" $ do
let mods = [validModule "my-mod" "desc" SourceInstalled]
result = formatListOutput False mods ["p1"]
T.isInfixOf "(installed)" result `shouldBe` True
it "shows error indicator for failed modules" $ do
let mods = [brokenModule "broken-mod" SourceUser]
result = formatListOutput False mods ["p1"]
T.isInfixOf "[error:" result `shouldBe` True
T.isInfixOf "broken-mod" result `shouldBe` True
it "shows count summary" $ do
let mods =
[ validModule "mod-a" "desc a" SourceProject,
validModule "mod-b" "desc b" SourceInstalled
]
result = formatListOutput False mods ["p1", "p2", "p3"]
T.isInfixOf "2 modules found" result `shouldBe` True
T.isInfixOf "3 sources searched" result `shouldBe` True
it "shows singular noun for one module" $ do
let mods = [validModule "only-one" "desc" SourceUser]
result = formatListOutput False mods ["p1"]
T.isInfixOf "1 module found" result `shouldBe` True
describe "applyFilters" $ do
let entries =
[ mkEntry "mod-a" (Just "repo-x") ["haskell", "cli"],
mkEntry "mod-b" (Just "repo-x") ["python"],
mkEntry "mod-c" (Just "repo-y") ["haskell"],
mkEntry "mod-d" Nothing []
]
it "returns all entries with no filters" $ do
let result = applyFilters noFilter entries
length result `shouldBe` 4
it "filters by repo name" $ do
let opts = ListFilter (Just "repo-x") Nothing []
result = applyFilters opts entries
length result `shouldBe` 2
map (.entryName) result `shouldBe` ["mod-a", "mod-b"]
it "filters by tag" $ do
let opts = ListFilter Nothing (Just "haskell") []
result = applyFilters opts entries
length result `shouldBe` 2
map (.entryName) result `shouldBe` ["mod-a", "mod-c"]
it "combines repo and tag filters with AND" $ do
let opts = ListFilter (Just "repo-x") (Just "haskell") []
result = applyFilters opts entries
length result `shouldBe` 1
map (.entryName) result `shouldBe` ["mod-a"]
it "returns empty list when repo filter matches nothing" $ do
let opts = ListFilter (Just "nonexistent") Nothing []
result = applyFilters opts entries
result `shouldBe` []
it "returns empty list when tag filter matches nothing" $ do
let opts = ListFilter Nothing (Just "ruby") []
result = applyFilters opts entries
result `shouldBe` []
it "excludes modules without origin metadata when repo filter is active" $ do
let opts = ListFilter (Just "repo-x") Nothing []
result = applyFilters opts entries
all (\e -> e.entryRepoName == Just "repo-x") result `shouldBe` True
describe "applyFilters (by kind)" $ do
let mixed =
[ mkEntryK KindModule "mod-a" Nothing [],
mkEntryK KindRecipe "rec-a" Nothing [],
mkEntryK KindBlueprint "bp-a" Nothing [],
mkEntryK KindPrompt "prompt-a" Nothing [],
mkEntryK KindModule "mod-b" (Just "repo-x") ["haskell"]
]
it "keeps all kinds when filterKinds is empty" $ do
length (applyFilters (ListFilter Nothing Nothing []) mixed) `shouldBe` 5
it "keeps only modules with --modules" $ do
let result = applyFilters (ListFilter Nothing Nothing [KindModule]) mixed
map (.entryName) result `shouldBe` ["mod-a", "mod-b"]
it "keeps only recipes with --recipes" $ do
let result = applyFilters (ListFilter Nothing Nothing [KindRecipe]) mixed
map (.entryName) result `shouldBe` ["rec-a"]
it "keeps only blueprints with --blueprints" $ do
let result = applyFilters (ListFilter Nothing Nothing [KindBlueprint]) mixed
map (.entryName) result `shouldBe` ["bp-a"]
it "keeps only prompts with --prompts" $ do
let result = applyFilters (ListFilter Nothing Nothing [KindPrompt]) mixed
map (.entryName) result `shouldBe` ["prompt-a"]
it "unions kinds when several flags are given" $ do
let result = applyFilters (ListFilter Nothing Nothing [KindModule, KindRecipe]) mixed
map (.entryName) result `shouldBe` ["mod-a", "rec-a", "mod-b"]
it "combines kind and repo with AND" $ do
let result = applyFilters (ListFilter (Just "repo-x") Nothing [KindModule]) mixed
map (.entryName) result `shouldBe` ["mod-b"]
it "returns empty when kind matches nothing in the set" $ do
let onlyRecipes = filter (\e -> e.entryKind == KindRecipe) mixed
result = applyFilters (ListFilter Nothing Nothing [KindBlueprint]) onlyRecipes
result `shouldBe` []
describe "formatListOutputEntries (count noun)" $ do
let noFilterF = ListFilter Nothing Nothing []
it "uses the kind noun when all shown entries share one kind" $ do
let entries = [mkEntryK KindBlueprint "bp-a" Nothing [], mkEntryK KindBlueprint "bp-b" Nothing []]
result = formatListOutputEntries False entries ["p1"] noFilterF
T.isInfixOf "2 blueprints found" result `shouldBe` True
it "uses the singular kind noun for one entry" $ do
let entries = [mkEntryK KindRecipe "rec-a" Nothing []]
result = formatListOutputEntries False entries ["p1"] noFilterF
T.isInfixOf "1 recipe found" result `shouldBe` True
it "uses the prompt noun when all shown entries are prompts" $ do
let entries = [mkEntryK KindPrompt "review" Nothing [], mkEntryK KindPrompt "explain" Nothing []]
result = formatListOutputEntries False entries ["p1"] noFilterF
T.isInfixOf "2 prompts found" result `shouldBe` True
it "falls back to the neutral noun for a mix of kinds" $ do
let entries = [mkEntryK KindModule "mod-a" Nothing [], mkEntryK KindRecipe "rec-a" Nothing []]
result = formatListOutputEntries False entries ["p1"] noFilterF
T.isInfixOf "2 items found" result `shouldBe` True
it "names the kind in the empty message when filtered to one kind" $ do
let result = formatListOutputEntries False [] ["p1"] (ListFilter Nothing Nothing [KindBlueprint])
T.isInfixOf "No blueprints found." result `shouldBe` True
it "names prompts in the empty message when filtered to prompts" $ do
let result = formatListOutputEntries False [] ["p1"] (ListFilter Nothing Nothing [KindPrompt])
T.isInfixOf "No prompts found." result `shouldBe` True
it "uses the neutral noun in the empty message with no kind filter" $ do
let result = formatListOutputEntries False [] ["p1"] noFilterF
T.isInfixOf "No items found." result `shouldBe` True
describe "runnableToEntryWithOrigin (blueprint)" $ do
it "tags blueprint entries with [blueprint] in the source label" $ do
let dr =
DiscoveredRunnable
{ drName = "demo",
drDescription = Just "A new seihou blueprint",
drKind = KindBlueprint,
drSource = SourceProject,
drDir = "/fake/demo",
drIsError = False,
drError = Nothing
}
entry = runnableToEntryWithOrigin Map.empty dr
entry.entrySource `shouldBe` "project [blueprint]"
entry.entryName `shouldBe` "demo"
entry.entryIsError `shouldBe` False
entry.entryKind `shouldBe` KindBlueprint
describe "runnableToEntryWithOrigin (prompt)" $ do
it "tags prompt entries with [prompt] in the source label" $ do
let dr =
DiscoveredRunnable
{ drName = "review",
drDescription = Just "Review current changes",
drKind = KindPrompt,
drSource = SourceProject,
drDir = "/fake/review",
drIsError = False,
drError = Nothing
}
entry = runnableToEntryWithOrigin Map.empty dr
entry.entrySource `shouldBe` "project [prompt]"
entry.entryName `shouldBe` "review"
entry.entryIsError `shouldBe` False
entry.entryKind `shouldBe` KindPrompt