packages feed

seihou-core-0.4.0.0: test/Seihou/Core/MigrationSpec.hs

module Seihou.Core.MigrationSpec (tests) where

import Data.Text (Text)
import Seihou.Core.Migration
  ( Migration (..),
    MigrationOp (..),
    MigrationPlan (..),
    MigrationPlanError (..),
    planMigrationChain,
  )
import Seihou.Core.Version (Version, parseVersion)
import Test.Hspec
import Test.Tasty
import Test.Tasty.Hspec (testSpec)

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

spec :: Spec
spec = do
  describe "planMigrationChain (window walker)" $ do
    it "returns Nothing when installed equals target" $ do
      let r = planMigrationChain "demo" [] (mkV "1.0.0") (mkV "1.0.0")
      r `shouldBe` Right Nothing

    it "rejects a downgrade with MigrationDowngradeNotSupported" $ do
      let r = planMigrationChain "demo" [] (mkV "2.0.0") (mkV "1.0.0")
      r `shouldBe` Left (MigrationDowngradeNotSupported (mkV "2.0.0") (mkV "1.0.0"))

    it "builds a single-edge plan whose target equals the edge's `to`" $ do
      let m = Migration {from = "1.0.0", to = "2.0.0", ops = [DeleteFile {path = "a"}]}
          r = planMigrationChain "demo" [m] (mkV "1.0.0") (mkV "2.0.0")
      case r of
        Right (Just plan) -> do
          plan.planModule `shouldBe` "demo"
          plan.planFrom `shouldBe` mkV "1.0.0"
          plan.planTo `shouldBe` mkV "2.0.0"
          plan.planSteps `shouldBe` [m]
        other -> expectationFailure ("Expected Right (Just ...), got: " <> show other)

    it "builds a two-edge plan in order regardless of declaration order" $ do
      let m1 = Migration "1.0.0" "2.0.0" [DeleteFile "a"]
          m2 = Migration "2.0.0" "3.0.0" [DeleteFile "b"]
          r = planMigrationChain "demo" [m2, m1] (mkV "1.0.0") (mkV "3.0.0")
      case r of
        Right (Just plan) -> do
          plan.planSteps `shouldBe` [m1, m2]
          plan.planTo `shouldBe` mkV "3.0.0"
        other -> expectationFailure ("Expected Right (Just ...), got: " <> show other)

    -- Live-tree master-plan fixture (manifest=0.1.0, target=0.3.0,
    -- declared [0.1.0 -> 0.2.0]). The chain reaches 0.2.0 via ops; the
    -- manifest still advances to the supplied target 0.3.0.
    it "yields a partial-cover plan for the EP-5 master-plan fixture" $ do
      let m = Migration "0.1.0" "0.2.0" []
          r = planMigrationChain "demo" [m] (mkV "0.1.0") (mkV "0.3.0")
      case r of
        Right (Just plan) -> do
          plan.planSteps `shouldBe` [m]
          plan.planFrom `shouldBe` mkV "0.1.0"
          plan.planTo `shouldBe` mkV "0.3.0"
        other -> expectationFailure ("Expected Right (Just ...), got: " <> show other)

    it "yields an empty-steps plan when no declared migration falls in the window" $ do
      let r = planMigrationChain "demo" [] (mkV "0.1.3") (mkV "0.3.0")
      case r of
        Right (Just plan) -> do
          plan.planSteps `shouldBe` []
          plan.planFrom `shouldBe` mkV "0.1.3"
          plan.planTo `shouldBe` mkV "0.3.0"
        other -> expectationFailure ("Expected Right (Just ...), got: " <> show other)

    it "reports MigrationVersionUnparseable when a from string is malformed" $ do
      let m = Migration "not-a-version" "2.0.0" []
          r = planMigrationChain "demo" [m] (mkV "1.0.0") (mkV "2.0.0")
      r `shouldBe` Left (MigrationVersionUnparseable "not-a-version")

    it "reports MigrationVersionUnparseable when a to string is malformed" $ do
      let m = Migration "1.0.0" "" []
          r = planMigrationChain "demo" [m] (mkV "1.0.0") (mkV "2.0.0")
      r `shouldBe` Left (MigrationVersionUnparseable "")

    it "rejects two edges sharing the same from with MigrationDuplicateEdge" $ do
      let m1 = Migration "1.0.0" "2.0.0" []
          m2 = Migration "1.0.0" "1.5.0" []
          r = planMigrationChain "demo" [m1, m2] (mkV "1.0.0") (mkV "2.0.0")
      case r of
        Left (MigrationDuplicateEdge fromV _) -> fromV `shouldBe` mkV "1.0.0"
        other -> expectationFailure ("Expected MigrationDuplicateEdge, got: " <> show other)

    it "ignores migrations whose from precedes the installed version" $ do
      -- An old 0.5.0 → 1.0.0 migration is irrelevant when installed is 1.0.0.
      let stale = Migration "0.5.0" "1.0.0" []
          live = Migration "1.0.0" "2.0.0" [DeleteFile "x"]
          r = planMigrationChain "demo" [stale, live] (mkV "1.0.0") (mkV "2.0.0")
      case r of
        Right (Just plan) -> do
          plan.planSteps `shouldBe` [live]
          plan.planTo `shouldBe` mkV "2.0.0"
        other -> expectationFailure ("Expected Right (Just ...), got: " <> show other)

    it "treats version equality with trailing zeros consistently" $ do
      -- `mkV "1.0"` and `mkV "1.0.0"` compare equal; planner should treat
      -- them as no-op.
      let r = planMigrationChain "demo" [] (mkV "1.0") (mkV "1.0.0")
      r `shouldBe` Right Nothing

    -- ----------------------------------------------------------------
    -- New EP-35 contract pins
    -- ----------------------------------------------------------------

    it "user's two-component fixture: 0.2 / 0.6 with [{0.2→0.3}, {0.5→0.6}] yields both edges" $ do
      let early = Migration "0.2" "0.3" [DeleteFile "v2"]
          late = Migration "0.5" "0.6" [DeleteFile "v5"]
          r = planMigrationChain "foo" [early, late] (mkV "0.2") (mkV "0.6")
      case r of
        Right (Just plan) -> do
          plan.planFrom `shouldBe` mkV "0.2"
          plan.planTo `shouldBe` mkV "0.6"
          plan.planSteps `shouldBe` [early, late]
        other -> expectationFailure ("Expected Right (Just ...), got: " <> show other)

    it "skips migrations that overshoot the supplied target" $ do
      let m = Migration "0.5" "1.0" []
          r = planMigrationChain "demo" [m] (mkV "0.4") (mkV "0.6")
      case r of
        Right (Just plan) -> do
          plan.planSteps `shouldBe` []
          plan.planTo `shouldBe` mkV "0.6"
        other -> expectationFailure ("Expected Right (Just ...), got: " <> show other)

    it "skips overlapping migrations once the cursor has advanced past them" $ do
      let big = Migration "0.2" "0.5" [DeleteFile "a"]
          small = Migration "0.3" "0.4" [DeleteFile "b"]
          r = planMigrationChain "demo" [big, small] (mkV "0.2") (mkV "0.5")
      case r of
        Right (Just plan) ->
          plan.planSteps `shouldBe` [big]
        other -> expectationFailure ("Expected Right (Just ...), got: " <> show other)

    it "empty migrations list with installed != target yields empty-steps plan with target" $ do
      let r = planMigrationChain "demo" [] (mkV "0.1") (mkV "0.3")
      case r of
        Right (Just plan) -> do
          plan.planSteps `shouldBe` []
          plan.planFrom `shouldBe` mkV "0.1"
          plan.planTo `shouldBe` mkV "0.3"
        other -> expectationFailure ("Expected Right (Just ...), got: " <> show other)

    it "edges with `to == target` are picked" $ do
      let m = Migration "0.2" "0.3" [DeleteFile "x"]
          r = planMigrationChain "demo" [m] (mkV "0.2") (mkV "0.3")
      case r of
        Right (Just plan) ->
          plan.planSteps `shouldBe` [m]
        other -> expectationFailure ("Expected Right (Just ...), got: " <> show other)

-- ---------------------------------------------------------------------------
-- Helpers
-- ---------------------------------------------------------------------------

mkV :: Text -> Version
mkV t = case parseVersion t of
  Just ver -> ver
  Nothing -> error ("MigrationSpec.mkV: bad version literal " <> show t)