packages feed

seihou-core-0.7.0.0: test/Seihou/Core/EntailmentSpec.hs

-- | Tests for 'expandEntailedEdges', the pure heart of blueprint migration
-- fan-out. It turns the edges a version window selected into the flat, ordered
-- list of steps a run actually performs, following each edge's declared
-- entailments into other blueprints.
--
-- Most of the risk in the feature lives here: a subtle bug produces a
-- plausible-looking plan that runs the wrong work, in the wrong order, or
-- twice.
module Seihou.Core.EntailmentSpec (tests) where

import Control.Lens ((^.))
import Data.Generics.Labels ()
import Data.Text (Text)
import Seihou.Core.Migration
  ( BlueprintMigration (..),
    BlueprintMigrationStep (..),
    EntailedEdge (..),
    EntailmentError (..),
    EntailmentSite (..),
    expandEntailedEdges,
  )
import Test.Hspec
import Test.Tasty (TestTree)
import Test.Tasty.Hspec (testSpec)

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

spec :: Spec
spec = describe "expandEntailedEdges" $ do
  it "leaves a step with no entailed edges alone" $ do
    let step = ownedStep "keiro-upgrade" (edge "2.4.0" "3.0.0" [])
    expandEntailedEdges (const Nothing) [step] `shouldBe` Right [step]

  -- The ordering rule the whole design rests on: the entailed edge is the
  -- deeper change, and the declaring edge's guidance may assume it landed.
  it "runs one entailed edge before the edge that declares it" $ do
    let kirokuEdge = edge "1.9.0" "2.0.0" []
        keiroEdge = edge "2.4.0" "3.0.0" [EntailedEdge "kiroku-upgrade" "1.9.0" "2.0.0"]
        declared = library [("kiroku-upgrade", [kirokuEdge])]
    expandEntailedEdges declared [ownedStep "keiro-upgrade" keiroEdge]
      `shouldBe` Right
        [ entailedStep "kiroku-upgrade" kirokuEdge (EntailmentSite "keiro-upgrade" "2.4.0" "3.0.0"),
          ownedStep "keiro-upgrade" keiroEdge
        ]

  it "runs several entailed edges in declaration order, all before the declaring edge" $ do
    let firstEdge = edge "1.0.0" "1.1.0" []
        secondEdge = edge "5.0.0" "6.0.0" []
        declaring =
          edge
            "2.4.0"
            "3.0.0"
            [ EntailedEdge "alpha" "1.0.0" "1.1.0",
              EntailedEdge "beta" "5.0.0" "6.0.0"
            ]
        declared = library [("alpha", [firstEdge]), ("beta", [secondEdge])]
        site = EntailmentSite "keiro-upgrade" "2.4.0" "3.0.0"
    fmap (map label) (expandEntailedEdges declared [ownedStep "keiro-upgrade" declaring])
      `shouldBe` Right
        [ "alpha 1.0.0 -> 1.1.0",
          "beta 5.0.0 -> 6.0.0",
          "keiro-upgrade 2.4.0 -> 3.0.0"
        ]
    -- and the middle step remembers what pulled it in
    fmap (map (^. #entailedBy)) (expandEntailedEdges declared [ownedStep "keiro-upgrade" declaring])
      `shouldBe` Right [Just site, Just site, Nothing]

  -- Recursion is what lets a three-deep cohort work without every blueprint
  -- knowing the whole graph.
  it "expands transitive entailment depth first" $ do
    let deepest = edge "0.1.0" "0.2.0" []
        middle = edge "1.9.0" "2.0.0" [EntailedEdge "shibuya" "0.1.0" "0.2.0"]
        top = edge "2.4.0" "3.0.0" [EntailedEdge "kiroku-upgrade" "1.9.0" "2.0.0"]
        declared = library [("kiroku-upgrade", [middle]), ("shibuya", [deepest])]
    fmap (map label) (expandEntailedEdges declared [ownedStep "keiro-upgrade" top])
      `shouldBe` Right
        [ "shibuya 0.1.0 -> 0.2.0",
          "kiroku-upgrade 1.9.0 -> 2.0.0",
          "keiro-upgrade 2.4.0 -> 3.0.0"
        ]

  -- Two selected edges of one blueprint can both depend on the same upstream
  -- edge. It is one piece of work and must run once.
  it "emits a shared entailed edge only once" $ do
    let shared = edge "1.9.0" "2.0.0" []
        earlier = edge "2.0.0" "2.4.0" [EntailedEdge "kiroku-upgrade" "1.9.0" "2.0.0"]
        later = edge "2.4.0" "3.0.0" [EntailedEdge "kiroku-upgrade" "1.9.0" "2.0.0"]
        declared = library [("kiroku-upgrade", [shared])]
    fmap
      (map label)
      ( expandEntailedEdges
          declared
          [ownedStep "keiro-upgrade" earlier, ownedStep "keiro-upgrade" later]
      )
      `shouldBe` Right
        [ "kiroku-upgrade 1.9.0 -> 2.0.0",
          "keiro-upgrade 2.0.0 -> 2.4.0",
          "keiro-upgrade 2.4.0 -> 3.0.0"
        ]

  -- The over-eager cycle check this test exists to catch keys on blueprint
  -- name. Two blueprints may legitimately entail each other at *different*
  -- edges, which is a chain, not a cycle.
  it "does not mistake mutual entailment at different edges for a cycle" $ do
    let kirokuEarly = edge "1.0.0" "1.5.0" []
        kirokuLate = edge "1.9.0" "2.0.0" [EntailedEdge "keiro-upgrade" "1.0.0" "2.0.0"]
        keiroEarly = edge "1.0.0" "2.0.0" [EntailedEdge "kiroku-upgrade" "1.0.0" "1.5.0"]
        keiroLate = edge "2.4.0" "3.0.0" [EntailedEdge "kiroku-upgrade" "1.9.0" "2.0.0"]
        declared =
          library
            [ ("kiroku-upgrade", [kirokuEarly, kirokuLate]),
              ("keiro-upgrade", [keiroEarly, keiroLate])
            ]
    fmap (map label) (expandEntailedEdges declared [ownedStep "keiro-upgrade" keiroLate])
      `shouldBe` Right
        [ "kiroku-upgrade 1.0.0 -> 1.5.0",
          "keiro-upgrade 1.0.0 -> 2.0.0",
          "kiroku-upgrade 1.9.0 -> 2.0.0",
          "keiro-upgrade 2.4.0 -> 3.0.0"
        ]

  it "reports a cycle with its chain rather than looping" $ do
    let keiroEdge = edge "2.4.0" "3.0.0" [EntailedEdge "kiroku-upgrade" "1.9.0" "2.0.0"]
        kirokuEdge = edge "1.9.0" "2.0.0" [EntailedEdge "keiro-upgrade" "2.4.0" "3.0.0"]
        declared =
          library [("keiro-upgrade", [keiroEdge]), ("kiroku-upgrade", [kirokuEdge])]
    expandEntailedEdges declared [ownedStep "keiro-upgrade" keiroEdge]
      `shouldBe` Left
        ( EntailmentCycle
            [ "keiro-upgrade 2.4.0 -> 3.0.0",
              "kiroku-upgrade 1.9.0 -> 2.0.0",
              "keiro-upgrade 2.4.0 -> 3.0.0"
            ]
        )

  -- A cycle that does not include the edge the run started from. The reported
  -- chain should begin where the repetition begins, not at the entry point.
  it "reports a cycle deeper than the entry point from where it closes" $ do
    let top = edge "2.4.0" "3.0.0" [EntailedEdge "kiroku-upgrade" "1.9.0" "2.0.0"]
        kirokuEdge = edge "1.9.0" "2.0.0" [EntailedEdge "shibuya" "0.1.0" "0.2.0"]
        shibuyaEdge = edge "0.1.0" "0.2.0" [EntailedEdge "kiroku-upgrade" "1.9.0" "2.0.0"]
        declared =
          library [("kiroku-upgrade", [kirokuEdge]), ("shibuya", [shibuyaEdge])]
    expandEntailedEdges declared [ownedStep "keiro-upgrade" top]
      `shouldBe` Left
        ( EntailmentCycle
            [ "kiroku-upgrade 1.9.0 -> 2.0.0",
              "shibuya 0.1.0 -> 0.2.0",
              "kiroku-upgrade 1.9.0 -> 2.0.0"
            ]
        )

  -- Skipping an unresolvable member silently would leave a half-migrated
  -- project with no signal, because the consumer does not know the cohort.
  it "refuses when an entailed blueprint cannot be resolved" $ do
    let keiroEdge = edge "2.4.0" "3.0.0" [EntailedEdge "kiroku-upgrade" "1.9.0" "2.0.0"]
    expandEntailedEdges (const Nothing) [ownedStep "keiro-upgrade" keiroEdge]
      `shouldBe` Left
        ( EntailedBlueprintNotFound
            (EntailmentSite "keiro-upgrade" "2.4.0" "3.0.0")
            "kiroku-upgrade"
        )

  -- Entailment names one exact edge. Falling back to window planning inside
  -- the entailed blueprint would let a release silently change which upstream
  -- work it implies.
  it "refuses when the entailed blueprint declares no such edge" $ do
    let keiroEdge = edge "2.4.0" "3.0.0" [EntailedEdge "kiroku-upgrade" "1.9.0" "2.0.0"]
        declared =
          library [("kiroku-upgrade", [edge "1.0.0" "1.5.0" [], edge "1.5.0" "2.0.0" []])]
    expandEntailedEdges declared [ownedStep "keiro-upgrade" keiroEdge]
      `shouldBe` Left
        ( EntailedEdgeNotDeclared
            (EntailmentSite "keiro-upgrade" "2.4.0" "3.0.0")
            "kiroku-upgrade"
            "1.9.0"
            "2.0.0"
        )

  it "matches an entailed edge on both ends of its window, not just its start" $ do
    let keiroEdge = edge "2.4.0" "3.0.0" [EntailedEdge "kiroku-upgrade" "1.9.0" "2.0.0"]
        declared = library [("kiroku-upgrade", [edge "1.9.0" "1.9.5" []])]
    expandEntailedEdges declared [ownedStep "keiro-upgrade" keiroEdge]
      `shouldSatisfy` \result -> case result of
        Left (EntailedEdgeNotDeclared _ _ _ _) -> True
        _ -> False

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

edge :: Text -> Text -> [EntailedEdge] -> BlueprintMigration
edge fromVersion toVersion entailed =
  BlueprintMigration
    { from = fromVersion,
      to = toVersion,
      prompt = "migrate " <> fromVersion <> " -> " <> toVersion,
      entails = entailed
    }

ownedStep :: Text -> BlueprintMigration -> BlueprintMigrationStep
ownedStep owner declared =
  BlueprintMigrationStep {owner = owner, edge = declared, entailedBy = Nothing}

entailedStep :: Text -> BlueprintMigration -> EntailmentSite -> BlueprintMigrationStep
entailedStep owner declared site =
  BlueprintMigrationStep {owner = owner, edge = declared, entailedBy = Just site}

-- | A stand-in for the blueprints a run has loaded off disk.
library :: [(Text, [BlueprintMigration])] -> Text -> Maybe [BlueprintMigration]
library table name = lookup name table

-- | The shape a failure is easiest to read in: owner and window per step.
label :: BlueprintMigrationStep -> Text
label step =
  step ^. #owner <> " " <> step ^. #edge . #from <> " -> " <> step ^. #edge . #to