packages feed

seihou-core-0.4.0.0: test/Seihou/Engine/ConflictSpec.hs

module Seihou.Engine.ConflictSpec (tests) where

import Data.Text qualified as T
import Effectful
import Seihou.Core.Types
import Seihou.Effect.ConsolePure (ConsoleState (..), runConsolePure, runConsolePureNonInteractive)
import Seihou.Engine.Conflict (resolveConflicts, resolveConflictsInteractive)
import Seihou.Manifest.Hash (hashContent)
import Test.Hspec
import Test.Tasty
import Test.Tasty.Hspec (testSpec)

tests :: IO TestTree
tests = testSpec "Seihou.Engine.Conflict" spec

-- | Helper to make a ConflictFile with realistic hashes.
mkConflict :: FilePath -> ConflictFile
mkConflict path =
  ConflictFile
    { path = path,
      moduleName = ModuleName "test-module",
      manifestHash = hashContent "original content",
      diskHash = hashContent "user edited content",
      planContent = "new generated content"
    }

spec :: Spec
spec = do
  describe "resolveConflicts" $ do
    it "returns Just [] for empty conflict list" $ do
      let (result, _st) = runPureEff $ runConsolePure [] $ resolveConflicts False []
      result `shouldBe` Just []

    it "returns Just [] for empty conflict list with force" $ do
      let (result, _st) = runPureEff $ runConsolePure [] $ resolveConflicts True []
      result `shouldBe` Just []

    it "returns all AcceptNew when force is True" $ do
      let conflicts = [mkConflict "a.txt", mkConflict "b.txt", mkConflict "c.txt"]
          (result, _st) = runPureEff $ runConsolePure [] $ resolveConflicts True conflicts
      case result of
        Just resolved -> do
          length resolved `shouldBe` 3
          all (\(_, r) -> r == AcceptNew) resolved `shouldBe` True
        Nothing -> expectationFailure "Expected Just, got Nothing"

    it "returns Nothing in non-interactive mode without force" $ do
      let conflicts = [mkConflict "a.txt"]
          (result, _st) = runPureEff $ runConsolePureNonInteractive $ resolveConflicts False conflicts
      result `shouldBe` Nothing

    it "does not produce console output when force is True" $ do
      let conflicts = [mkConflict "a.txt"]
          (_result, st) = runPureEff $ runConsolePure [] $ resolveConflicts True conflicts
      st.consoleOutputs `shouldBe` []

  describe "resolveConflictsInteractive" $ do
    it "resolves accept with 'a'" $ do
      let conflict = mkConflict "readme.md"
          (result, _st) = runPureEff $ runConsolePure ["a"] $ resolveConflictsInteractive [conflict]
      case result of
        Just [(_, res)] -> res `shouldBe` AcceptNew
        _ -> expectationFailure "Expected Just with one AcceptNew resolution"

    it "resolves keep with 'k'" $ do
      let conflict = mkConflict "readme.md"
          (result, _st) = runPureEff $ runConsolePure ["k"] $ resolveConflictsInteractive [conflict]
      case result of
        Just [(_, res)] -> res `shouldBe` KeepCurrent
        _ -> expectationFailure "Expected Just with one KeepCurrent resolution"

    it "resolves skip with 's'" $ do
      let conflict = mkConflict "readme.md"
          (result, _st) = runPureEff $ runConsolePure ["s"] $ resolveConflictsInteractive [conflict]
      case result of
        Just [(_, res)] -> res `shouldBe` Skip
        _ -> expectationFailure "Expected Just with one Skip resolution"

    it "returns Nothing on abort with 'A'" $ do
      let conflicts = [mkConflict "a.txt", mkConflict "b.txt"]
          (result, _st) = runPureEff $ runConsolePure ["A"] $ resolveConflictsInteractive conflicts
      result `shouldBe` Nothing

    it "re-prompts on invalid input then accepts valid input" $ do
      let conflict = mkConflict "readme.md"
          (result, st) = runPureEff $ runConsolePure ["x", "a"] $ resolveConflictsInteractive [conflict]
      case result of
        Just [(_, res)] -> res `shouldBe` AcceptNew
        _ -> expectationFailure "Expected Just with one AcceptNew resolution"
      any (T.isInfixOf "Invalid choice") (st.consoleOutputs) `shouldBe` True

    it "resolves multiple files in order" $ do
      let conflicts = [mkConflict "a.txt", mkConflict "b.txt", mkConflict "c.txt"]
          (result, _st) = runPureEff $ runConsolePure ["a", "k", "s"] $ resolveConflictsInteractive conflicts
      case result of
        Just resolved -> do
          length resolved `shouldBe` 3
          map snd resolved `shouldBe` [AcceptNew, KeepCurrent, Skip]
          map ((.path) . fst) resolved `shouldBe` ["a.txt", "b.txt", "c.txt"]
        Nothing -> expectationFailure "Expected Just, got Nothing"

    it "abort on second file stops prompting" $ do
      let conflicts = [mkConflict "a.txt", mkConflict "b.txt", mkConflict "c.txt"]
          (result, st) = runPureEff $ runConsolePure ["a", "A"] $ resolveConflictsInteractive conflicts
      result `shouldBe` Nothing
      -- Should have prompted for a.txt and b.txt, but not c.txt
      let outputs = T.unlines (st.consoleOutputs)
      T.isInfixOf "a.txt" outputs `shouldBe` True
      T.isInfixOf "b.txt" outputs `shouldBe` True
      T.isInfixOf "c.txt" outputs `shouldBe` False

    it "outputs file paths in prompt messages" $ do
      let conflict = mkConflict "src/Main.hs"
          (_result, st) = runPureEff $ runConsolePure ["a"] $ resolveConflictsInteractive [conflict]
          outputs = T.unlines (st.consoleOutputs)
      T.isInfixOf "src/Main.hs" outputs `shouldBe` True
      T.isInfixOf "modified since last generation" outputs `shouldBe` True

    it "accepts full word inputs" $ do
      let conflicts = [mkConflict "a.txt", mkConflict "b.txt", mkConflict "c.txt", mkConflict "d.txt"]
          (result, _st) = runPureEff $ runConsolePure ["accept", "keep", "skip", "abort"] $ resolveConflictsInteractive conflicts
      -- abort on d.txt → Nothing
      result `shouldBe` Nothing

  describe "resolveConflicts integration" $ do
    it "dispatches to interactive prompts when interactive and not force" $ do
      let conflicts = [mkConflict "config.yaml", mkConflict "Makefile"]
          (result, st) = runPureEff $ runConsolePure ["k", "a"] $ resolveConflicts False conflicts
      case result of
        Just resolved -> do
          map snd resolved `shouldBe` [KeepCurrent, AcceptNew]
        Nothing -> expectationFailure "Expected Just, got Nothing"
      -- Verify prompt output was produced
      let outputs = T.unlines (st.consoleOutputs)
      T.isInfixOf "config.yaml" outputs `shouldBe` True
      T.isInfixOf "Makefile" outputs `shouldBe` True

    it "non-interactive mode produces no console output" $ do
      let conflicts = [mkConflict "a.txt"]
          (_result, st) = runPureEff $ runConsolePureNonInteractive $ resolveConflicts False conflicts
      st.consoleOutputs `shouldBe` []

    it "force mode preserves conflict file references in resolution" $ do
      let c = mkConflict "important.txt"
          (result, _st) = runPureEff $ runConsolePure [] $ resolveConflicts True [c]
      case result of
        Just [(resolved_c, AcceptNew)] ->
          resolved_c.path `shouldBe` "important.txt"
        _ -> expectationFailure "Expected Just with AcceptNew for important.txt"

    it "interactive abort via resolveConflicts returns Nothing" $ do
      let conflicts = [mkConflict "first.txt", mkConflict "second.txt"]
          (result, st) = runPureEff $ runConsolePure ["A"] $ resolveConflicts False conflicts
      result `shouldBe` Nothing
      -- Only first.txt was prompted before abort
      let outputs = T.unlines (st.consoleOutputs)
      T.isInfixOf "first.txt" outputs `shouldBe` True
      T.isInfixOf "second.txt" outputs `shouldBe` False

    it "choice prompt text includes all four options" $ do
      let conflict = mkConflict "test.txt"
          (_result, st) = runPureEff $ runConsolePure ["s"] $ resolveConflicts False [conflict]
          outputs = T.unlines (st.consoleOutputs)
      T.isInfixOf "[a]ccept" outputs `shouldBe` True
      T.isInfixOf "[k]eep" outputs `shouldBe` True
      T.isInfixOf "[s]kip" outputs `shouldBe` True
      T.isInfixOf "[A]bort" outputs `shouldBe` True